feat: scene traps, material legends, and scene editor window
Add trap overlays with session state, material legend editor/panel, and a dedicated scene editor window wired through IPC and project persistence. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
.panel {
|
||||
position: absolute;
|
||||
z-index: 45;
|
||||
min-width: 200px;
|
||||
max-width: min(360px, 42vw);
|
||||
max-height: min(70vh, 520px);
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: linear-gradient(160deg, rgba(18, 22, 30, 0.94), rgba(12, 14, 20, 0.92));
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
|
||||
color: #f2f4f8;
|
||||
backdrop-filter: blur(8px);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.item:first-of-type {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
background: #1e3a5f;
|
||||
border: 2px solid #f5c542;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
padding-top: 4px;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import type { MaterialLegend, MaterialsOverlayLayout } from '../../../shared/types';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||||
|
||||
import styles from './MaterialLegendPanel.module.css';
|
||||
|
||||
type Props = {
|
||||
legend: MaterialLegend;
|
||||
layout: MaterialsOverlayLayout;
|
||||
editable?: boolean;
|
||||
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
|
||||
};
|
||||
|
||||
export function MaterialLegendPanel({
|
||||
legend,
|
||||
layout,
|
||||
editable = false,
|
||||
onLayoutChange,
|
||||
}: Props) {
|
||||
const host = useSceneOverlayView();
|
||||
const view = host?.view ?? { w: 1, h: 1 };
|
||||
const dragRef = useRef<{ startX: number; startY: number; origin: MaterialsOverlayLayout } | null>(
|
||||
null,
|
||||
);
|
||||
const effective = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
|
||||
const w = Math.max(180, Math.min(view.w * 0.36, 340) * effective.scale);
|
||||
const left = effective.cx * view.w - w / 2;
|
||||
const top = effective.cy * view.h - 40;
|
||||
|
||||
if (!legend.enabled || legend.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
style={{ left, top, width: w }}
|
||||
onPointerDown={(e) => {
|
||||
if (!editable || !onLayoutChange) return;
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origin: { ...effective },
|
||||
};
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || !onLayoutChange) return;
|
||||
const dx = (e.clientX - d.startX) / Math.max(1, view.w);
|
||||
const dy = (e.clientY - d.startY) / Math.max(1, view.h);
|
||||
onLayoutChange({
|
||||
...d.origin,
|
||||
cx: d.origin.cx + dx,
|
||||
cy: d.origin.cy + dy,
|
||||
});
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
dragRef.current = null;
|
||||
}}
|
||||
>
|
||||
<div className={styles.title}>Легенда</div>
|
||||
{legend.items
|
||||
.slice()
|
||||
.sort((a, b) => a.number - b.number)
|
||||
.map((item) => (
|
||||
<div key={item.id} className={styles.item}>
|
||||
<div className={styles.num}>{item.number}</div>
|
||||
<div className={styles.text}>{item.text || '—'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,15 +50,33 @@
|
||||
|
||||
.image {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: fill;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.legendMarker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #1e3a5f;
|
||||
border: 2px solid #f5c542;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.handle {
|
||||
|
||||
@@ -22,6 +22,8 @@ type MaterialOverlayProps = {
|
||||
onZoomAt?: (nx: number, ny: number) => void;
|
||||
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
|
||||
embedded?: boolean;
|
||||
/** Маркеры легенды (норм. координаты картинки). */
|
||||
legendMarkers?: readonly { id: string; number: number; nx: number; ny: number }[];
|
||||
};
|
||||
|
||||
function RotateIcon() {
|
||||
@@ -99,6 +101,7 @@ export function MaterialOverlay({
|
||||
onLayoutChange,
|
||||
onZoomAt,
|
||||
embedded = false,
|
||||
legendMarkers,
|
||||
}: MaterialOverlayProps) {
|
||||
const url = useAssetUrl(assetId);
|
||||
const host = useSceneOverlayView();
|
||||
@@ -381,16 +384,23 @@ export function MaterialOverlay({
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
||||
}}
|
||||
/>
|
||||
{(legendMarkers ?? []).map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={styles.legendMarker}
|
||||
style={{
|
||||
left: `${String(m.nx * 100)}%`,
|
||||
top: `${String(m.ny * 100)}%`,
|
||||
}}
|
||||
>
|
||||
{m.number}
|
||||
</div>
|
||||
))}
|
||||
{editable && !zoomTool
|
||||
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user