diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 34c288d1..5a28397e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,11 +9,13 @@ export type { EventSuffix, FenceEvent, GridEvent, + GuideEvent, ItemEvent, LevelEvent, NodeEvent, RoofEvent, RoofSegmentEvent, + ScanEvent, ShelfEvent, SiteEvent, SlabEvent, diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index ff1e7268..d87e8b5d 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -1,9 +1,12 @@ export { + discoverPlugins, getSelectableKinds, isRegistrySelectable, loadPlugin, nodeRegistry, + type PluginDiscovery, registerNode, + setPluginDiscovery, } from './registry' export { type CascadeContext, @@ -22,7 +25,14 @@ export type { CuttableConfig, DragAction, EditorCtx, + FloorplanAffordance, + FloorplanAffordanceModifiers, + FloorplanAffordancePoint, + FloorplanAffordanceSession, FloorplanGeometry, + FloorplanMoveTarget, + FloorplanMoveTargetSession, + FloorplanPalette, FloorplanPoint, FloorplanStyle, GeometryContext, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 3a37664b..50021bee 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -95,3 +95,41 @@ export async function loadPlugin(plugin: Plugin): Promise { registerNode(def) } } + +/** + * App-level plugin discovery hook. The bootstrap loads `builtinPlugin` + * unconditionally and then awaits this to pick up any extra plugins + * (third-party node packs, AI-authored bundles, user-installed kinds). + * Defaults to returning `[]` — apps that want external plugins call + * {@link setPluginDiscovery} before the bootstrap module runs. + * + * Kept async so a future loader can fetch over the network without + * changing the contract. See `wiki/editor-plugin-authoring.md` for the + * plugin author surface this enables. + */ +export type PluginDiscovery = () => Promise + +let pluginDiscovery: PluginDiscovery = async () => [] + +/** + * Replace the plugin discovery implementation. Call once at app startup + * before {@link discoverPlugins} is invoked (bootstrap order matters). + * + * The contract is intentionally minimal — just "return a list of + * plugins to load." The loader can be a static `import.meta.glob`, a + * `fetch` against a registry endpoint, a worker IPC, etc. Each returned + * plugin still goes through {@link loadPlugin} so the same API-version + * gate + duplicate-kind protection applies. + */ +export function setPluginDiscovery(fn: PluginDiscovery): void { + pluginDiscovery = fn +} + +/** + * Run the active plugin discovery and return the discovered plugins. + * Bootstrap code is expected to call this after `loadPlugin(builtinPlugin)` + * and then `await loadPlugin(...)` each result in order. + */ +export function discoverPlugins(): Promise { + return pluginDiscovery() +} diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 137c73ae..f7de31c2 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -24,6 +24,90 @@ export type GeometryContext = { siblings: AnyNode[] /** Resolved parent (null for root-level nodes). */ parent: AnyNode | null + /** + * Pre-computed level-batch data, populated by the dispatcher when the + * kind declares `def.computeLevelData`. Shared across every + * `def.geometry(node, ctx)` call in the same level batch within a + * single frame, so kinds whose geometry depends on cross-sibling + * data (wall mitering, gradient sky uniforms across a zone, etc.) + * don't pay an O(N²) recomputation cost. + * + * Typed as `unknown` at the framework boundary — kinds cast to their + * own `LevelData` shape inside `def.geometry` (the same kind owns + * both the `computeLevelData` return shape and the `geometry` + * consumer, so the cast is internal). Only populated for `def. + * geometry` calls today; not used by `def.floorplan` (which already + * has cheap access to siblings through `ctx.siblings`). + */ + levelData?: unknown + /** + * Optional view state — only populated for `def.floorplan` builders. The + * 2D floor-plan layer surfaces selection / hover here so kinds can vary + * their output (themed stroke when selected, endpoint dots when + * selected, hatch overlay, hover-side highlight). For `def.geometry` + * (3D) this is always undefined — the 3D selection outline is handled + * by the merged-outline post-process pass instead. + */ + viewState?: { + selected: boolean + /** Marquee or programmatic highlight — shows selected chrome without keyboard focus. */ + highlighted: boolean + /** Pointer-hovered. */ + hovered: boolean + /** + * True while this node is the target of an active 2D move (i.e. + * `useEditor.movingNode === node`). Used by kinds whose move + * preview includes extra chrome — e.g. door / window emit + * dimension lines showing the distance to adjacent openings or + * wall ends only during the move. + */ + moving: boolean + /** + * The kind's theme palette. Theme-aware colors (selection stroke, + * endpoint handle fill, hatch color) live here so kinds don't need + * to import `useViewer.theme` themselves. + */ + palette: FloorplanPalette + } +} + +// ─── FloorplanPalette ──────────────────────────────────────────────── +// +// Centralised set of themed colors that kinds pull from when building +// their floor-plan geometry. Mirrors the legacy `FloorplanPalette` in +// `floorplan-panel.tsx`. The 2D layer constructs this from +// `useViewer.theme` and passes it via `GeometryContext.viewState.palette`. + +export type FloorplanPalette = { + selectedStroke: string + selectedFill: string + /** Hatch / cross-stroke color used for selected fills with patterns. */ + selectedHatch: string + /** + * Stroke colour applied to a wall (and fence by analogy) when the + * pointer hovers it. Light blue in the legacy palette — distinct from + * the orange endpoint-handle hover so the body and its handles can + * both glow independently. Pass through `viewState.palette.wall + * HoverStroke` in `def.floorplan` when `viewState.hovered === true` + * and the node isn't selected. + */ + wallHoverStroke: string + endpointHandleFill: string + endpointHandleStroke: string + endpointHandleHoverStroke: string + endpointHandleActiveFill: string + endpointHandleActiveStroke: string + /** + * Curve sagitta handle slot — distinct teal colour-set the legacy + * `FloorplanWallCurveLayer` uses so users can tell endpoint dots + * (orange) and curve dots (teal) apart at a glance. + */ + curveHandleFill: string + curveHandleStroke: string + curveHandleHoverStroke: string + measurementStroke: string + measurementLabelBackground: string + measurementLabelText: string } // ─── FloorplanGeometry ─────────────────────────────────────────────── @@ -45,6 +129,22 @@ export type FloorplanStyle = { strokeWidth?: number strokeDasharray?: string opacity?: number + /** + * When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth` + * as a constant screen-pixel width regardless of viewport zoom. Maps + * straight to the SVG `vector-effect` attribute. Default (undefined) + * treats `strokeWidth` as plan-unit metres. + * + * Kinds that emit hand-drawn-looking strokes (fence body, wall hairlines, + * post markers) want non-scaling so the visual weight stays stable as + * the user zooms. Kinds whose stroke represents a real-world thickness + * (wall body in floor plan, slab outline) leave it undefined. + */ + vectorEffect?: 'non-scaling-stroke' + strokeLinecap?: 'butt' | 'round' | 'square' + strokeLinejoin?: 'miter' | 'round' | 'bevel' + strokeOpacity?: number + fillOpacity?: number } // ─── ToolHint ──────────────────────────────────────────────────────── @@ -85,12 +185,319 @@ export type FloorplanGeometry = x2: number y2: number } & FloorplanStyle) + /** + * Plain SVG text in plan space. Used for short labels that need to + * sit at a specific plan coordinate — e.g. the elevator served-level + * chips' floor numbers. Rotates with the floor plan's transform + * (same as polygon coordinates) so it shares the building's + * orientation. For text that needs to stay screen-upright regardless + * of plan rotation, use `dimension-label` instead (it auto-flips + * upside-down labels). + * + * `fontSize` is in plan metres — typical values are 0.1–0.2m. The + * registry layer doesn't apply any text-rendering chrome (no plate, + * no rotation auto-flip) — it's just a styled `` element. + */ + | { + kind: 'text' + x: number + y: number + text: string + fontSize: number + fill?: string + fontWeight?: number | string + fontFamily?: string + textAnchor?: 'start' | 'middle' | 'end' + dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'alphabetic' + opacity?: number + /** + * Outlined-text styling — when `stroke` is set the renderer applies + * `stroke` / `strokeWidth` plus `paintOrder='stroke'` so the stroke + * is drawn under the fill. Used by zone name labels for the + * "white text inside a colored outline" look that stays legible + * against any fill color. + */ + stroke?: string + strokeWidth?: number + paintOrder?: 'stroke' | 'fill' | 'normal' + } + /** + * Bitmap overlay — captured top-down asset thumbnail, AI-generated + * floor-plan symbol, scan slice, etc. `url` is passed through the + * editor's `loadAssetUrl` resolver (handles CDN / Supabase storage), + * so kinds emit the raw `asset.floorPlanUrl` and don't worry about + * fetching. + * + * `rotation` is in radians around `center`. The image is drawn at + * `center` with size `width × height` in plan-local metres; + * `preserveAspectRatio` controls letterboxing (default + * `'xMidYMid meet'`). + */ + | { + kind: 'image' + url: string + center: FloorplanPoint + width: number + height: number + rotation?: number + preserveAspectRatio?: string + opacity?: number + } | { kind: 'group' children: FloorplanGeometry[] /** Optional transform applied to all children. Rotation in radians. */ transform?: { translate?: FloorplanPoint; rotate?: number } } + /** + * Hatched fill overlay — same polygon shape as the kind's main fill but + * stroked with diagonal lines on top. Used for the selected-wall hatch + * effect from the legacy floor-plan panel. The 2D layer mounts a + * shared `` in `` and references it via `fill=url(...)`. + */ + | { kind: 'hatch'; points: readonly FloorplanPoint[]; color: string; opacity?: number } + /** + * Transparent click-detection segment. Sits on top of the kind's main + * geometry with a wide stroke so the user doesn't need to pixel-hunt + * the polygon. `select` is the only affordance for now — clicking + * triggers selection of the owning node. + */ + | { + kind: 'hit-line' + x1: number + y1: number + x2: number + y2: number + /** Stroke width in screen pixels — converted to plan units by the dispatcher. */ + strokeWidthPx: number + cursor?: string + } + /** + * Endpoint manipulation handle — the 5-circle stack from the legacy + * floor-plan: outer hover glow ring + hover ring + filled outer + + * inner dot + transparent hit. Rendered with theme-aware colors from + * `viewState.palette`. `affordance` keys into a kind-owned drag flow + * the dispatcher invokes; `payload` is opaque kind data the + * affordance handler unpacks. + */ + | { + kind: 'endpoint-handle' + point: FloorplanPoint + /** `active` = currently being dragged; `idle` = visible but inert. */ + state: 'idle' | 'active' + /** + * Visual colour-set. `'endpoint'` (default) → orange — wall / + * fence endpoints, polygon vertices. `'curve'` → teal — the + * sagitta midpoint handle. Other values are reserved for future + * affordances (rotation, scale) without expanding the union. + */ + variant?: 'endpoint' | 'curve' + affordance: string + payload: unknown + } + /** + * Smaller "insert here" handle drawn between two polygon vertices. + * Visually a small white dot with a `+` icon; hover-expanded. Triggers + * an affordance that typically inserts a new vertex at the midpoint + * and then drags it (matches the legacy slab / ceiling boundary + * editor's edge-midpoint behaviour). + */ + | { + kind: 'midpoint-handle' + point: FloorplanPoint + affordance: string + payload: unknown + } + /** + * Hit-target along an entire polygon edge. Renders as a transparent + * wide stroke for click detection; the dispatcher overlays a glow + + * solid stroke when hovered or actively being dragged. Used by the + * slab / ceiling boundary editor's "drag whole edge perpendicular" + * affordance — both endpoints translate together along the edge + * normal. + */ + | { + kind: 'edge-handle' + x1: number + y1: number + x2: number + y2: number + affordance: string + payload: unknown + } + /** + * "Grab to move" handle drawn at a node's centroid — the orange dot + * users click-and-drag to move a door / window / item in the + * floorplan without going through the inspector's Move button. + * + * Pointer-down on the handle sets `useEditor.movingNode` to the + * owning node, which `FloorplanRegistryMoveOverlay` picks up and + * routes through the kind's `def.floorplanMoveTarget`. So both + * entry points (Move button + dot grab) share the same move + * pipeline — no parallel kind-side logic. + */ + | { + kind: 'move-handle' + point: FloorplanPoint + } + /** + * Centered length / distance label. Renders as a small rounded + * background plate with text, oriented along `angle` (radians). The + * 2D layer flips the label upright when it would otherwise be upside + * down. Use this for simple "what length am I?" badges (fence, item + * width, draft preview). + */ + | { + kind: 'dimension-label' + cx: number + cy: number + text: string + /** Rotation in radians. The renderer auto-flips to keep text upright. */ + angle: number + } + /** + * Architect's dimension overlay — extension lines from the edge + * endpoints out past the dimension line, two dimension line halves + * with the label sitting in the gap, end ticks perpendicular to the + * line. Used for the selected wall's full measurement; the rounded + * plate label is the wrong shape when you want plan-drawing chrome. + * + * The renderer computes the segment geometry from these inputs so the + * kind only needs to know "where is the edge and which way does the + * dimension line offset." `offsetNormal` is a unit vector + * perpendicular to the edge; pass the *outward* normal so the line + * sits on the side facing away from the wall interior. + */ + | { + kind: 'dimension' + start: FloorplanPoint + end: FloorplanPoint + /** Outward-pointing unit normal — the dimension line offsets along this. */ + offsetNormal: FloorplanPoint + /** Distance (plan units) from the edge to the dimension line. */ + offsetDistance: number + /** How far past the offset point the extension line continues. */ + extensionOvershoot: number + text: string + /** Optional override for the line/text colour. Defaults to the palette accent. */ + stroke?: string + } + +// ─── FloorplanAffordance ───────────────────────────────────────────── +// +// 2D drag session contract for floor-plan interactions. The registry +// layer (`FloorplanRegistryLayer`) drives the SVG event plumbing; each +// affordance handler owns the actual mutation logic for its kind. +// +// Lifecycle: +// 1. Pointer-down on a handle whose `affordance` key matches. +// 2. Layer captures node snapshots for `affectedIds` and pauses +// history. +// 3. Layer calls `apply` on every pointer-move with the current plan +// point + modifier keys. +// 4. On pointer-up: layer reads the resulting scene state, reverts to +// the snapshot (still paused, untracked), resumes history, then +// re-applies the final state as a single tracked change (single- +// undo dance — same shape as Stage D 3D moves). +// 5. On pointer-cancel / unmount: revert + resume without committing. +// +// `apply` is expected to call `scene.updateNodes` directly to drive +// previews — the layer doesn't keep a separate draft state. + +export type FloorplanAffordancePoint = readonly [x: number, y: number] + +export type FloorplanAffordanceModifiers = { + shiftKey: boolean + altKey: boolean + ctrlKey: boolean + metaKey: boolean +} + +export type FloorplanAffordanceSession = { + /** Node IDs the drag may mutate. Used by the dispatcher for the snapshot. */ + affectedIds: AnyNodeId[] + /** + * Run a single drag tick. Implementations call `scene.updateNodes` to + * preview the next position. Snap logic, linked-node cascade, and + * angle locking live here. + */ + apply(args: { + planPoint: FloorplanAffordancePoint + modifiers: FloorplanAffordanceModifiers + }): void + /** + * Called on pointer-up. Return `true` if the scene's current state + * should be committed; `false` reverts to the snapshot (e.g. wall too + * short, vertex collapsed onto neighbour). + */ + canCommit(): boolean +} + +export type FloorplanAffordance = { + start(args: { + node: N + /** Opaque kind-specific payload from the handle primitive. */ + payload: unknown + /** Current scene snapshot at drag start. */ + nodes: Record + /** Initial pointer position in plan coordinates. */ + initialPlanPoint: FloorplanAffordancePoint + }): FloorplanAffordanceSession +} + +// ─── FloorplanMoveTarget ───────────────────────────────────────────── +// +// Kind-specific 2D move-on-floorplan handler. Distinct from +// `FloorplanAffordance` because the lifecycle is different: +// +// - `FloorplanAffordance` is **handle-driven** — the user pointer-downs +// on a specific handle (endpoint dot, vertex, edge), drags, releases. +// Has an `initialPlanPoint`. One drag = one session. +// - `FloorplanMoveTarget` is **movingNode-driven** — the user clicks +// "Move" in the inspector / action menu, the floor-plan tracks the +// cursor from that moment until pointer-up or Esc. No initial +// pointer-down. The session starts when `useEditor.movingNode` is +// set to a node whose kind exposes `floorplanMoveTarget`. +// +// Usage: +// +// - door / window: pointer must hit a wall in plan space; commit +// re-anchors to the new wall (parentId + wallId + local position + +// side + rotation). Reuses `door-math` / `window-math` clamp + +// overlap helpers. +// - item with `attachTo: 'wall'` / `'wall-side'`: same as door / +// window but the local Y is free (item can move up/down the wall). +// - item with `attachTo: 'ceiling'`: hit-test ceiling polygons, +// reparent on transition. +// - item with `attachTo: 'floor'` (or no attachTo): point-in-slab +// check, snap to slab elevation. +// +// Falls back to `FloorplanRegistryMoveOverlay`'s generic free-floating +// translate when `floorplanMoveTarget` is unset on the kind. + +export type FloorplanMoveTargetSession = { + /** Node IDs the move may mutate. Used by the dispatcher for snapshot capture. */ + affectedIds: AnyNodeId[] + /** + * Single move-preview tick. Implementations call `scene.updateNodes` + * directly to drive the live preview (no separate draft state). + */ + apply(args: { + planPoint: FloorplanAffordancePoint + modifiers: FloorplanAffordanceModifiers + }): void + /** + * Called on pointer-up. Return `true` to commit the current scene + * state; `false` reverts to the snapshot (e.g. dropped in invalid + * area, overlap detected, ...). + */ + canCommit(): boolean +} + +export type FloorplanMoveTarget = (args: { + node: N + nodes: Record +}) => FloorplanMoveTargetSession // ─── Plugin manifest ───────────────────────────────────────────────── @@ -142,6 +549,23 @@ export type NodeDefinition> = { * work (animations, named-mesh material poking). */ geometry?: (node: z.infer, ctx: GeometryContext) => Object3D + /** + * Level-batch precompute hook. Called by `` once per + * level per frame, **before** the per-node `def.geometry` calls in + * that batch. The result lands in `ctx.levelData` for every node in + * the same level. + * + * Used by kinds whose geometry depends on cross-sibling data that + * would be O(N²) to recompute per node: + * - wall: `calculateLevelMiters(walls)` — every wall's mesh + * reads its junctions from the level-wide miter graph. + * - zone (planned): shared TSL gradient uniforms. + * + * `siblings` is every node of this kind in the same level (including + * the dirty ones). The dispatcher de-duplicates per level so this + * runs once even when many walls are dirty in the same frame. + */ + computeLevelData?: (siblings: ReadonlyArray>) => unknown /** * Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits * plain `FloorplanGeometry` data (SVG-renderable) rather than three.js @@ -157,6 +581,36 @@ export type NodeDefinition> = { * the legacy `floorplan-panel.tsx` monolith. */ floorplan?: (node: z.infer, ctx: GeometryContext) => FloorplanGeometry | null + /** + * 2D drag affordances keyed by the string identifier emitted on + * `endpoint-handle` (and similar interactive floor-plan primitives) via + * the `affordance` field. The floor-plan registry layer calls + * `def.floorplanAffordances?.[affordance].start({...})` on pointer-down, + * receives a session, calls `apply(...)` on pointer-move and + * `commit()` / `cancel()` on pointer-up / pointer-cancel. The session + * mutates scene state directly during `apply`; the dispatcher handles + * the snapshot + single-undo dance around it. + * + * Mirrors the existing 3D `affordanceTools` map but for 2D SVG events, + * and operates on plain JS data instead of mounting React. Kinds with + * both 3D and 2D affordances expose both fields — they're independent. + */ + floorplanAffordances?: Record>> + /** + * Kind-specific 2D move handler for `useEditor.movingNode`-driven + * placement in the floor plan. When set, `FloorplanRegistryMove + * Overlay` invokes this once when `movingNode` becomes a node of + * this kind, and drives the session through pointer events until + * pointer-up / Esc. Falls back to the generic free-floating + * translate when unset. + * + * Use this for kinds whose move semantics are anchor-aware: + * doors / windows need wall hits + reparenting; items with + * `attachTo` need parent-surface hits. Kinds with simple + * translate-on-XZ semantics (shelf, spawn, fence) leave this + * unset and rely on the generic overlay path. + */ + floorplanMoveTarget?: FloorplanMoveTarget> system?: SystemContribution tool?: LazyComponent /** diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index a6f18920..71aefda8 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -3,11 +3,17 @@ import { type AnyNode, type AnyNodeId, + type FloorplanMoveTargetSession, nodeRegistry, + pauseSceneHistory, + resumeSceneHistory, snapPointToGrid, + useLiveTransforms, useScene, } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' +import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' const GRID_STEP = 0.5 @@ -16,20 +22,21 @@ const GRID_STEP = 0.5 * Cursor-driven placement for registered kinds in the floor plan. * * Activates when `useEditor.movingNode` is set to a node whose kind is - * registered with `def.floorplan`. Tracks the pointer on the floor plan - * SVG via the `[data-floorplan-scene]` `` (set by floorplan-panel.tsx - * via a one-line attribute) and **imperatively translates the original - * rendered entry** so the user sees the actual shape follow the cursor — - * no ghost overlay, no double rendering. + * registered with `def.floorplan`. Two dispatch paths: * - * Coordinate conversion routes through the scene ``'s `getScreenCTM` - * so cursor → meters accounts for the floor plan's pan / zoom / building - * rotation. Position snaps to a 0.5m grid (matches the 3D placement - * tool's GRID_STEP). Pointerup commits via `updateNode`; Esc cancels. + * 1. **`def.floorplanMoveTarget` present** (door / window / item): + * kind-specific 2D move handler with wall / ceiling / slab + * anchor logic. Pointer events feed `session.apply` which writes + * directly to `useScene`; pointer-up does the single-undo dance + * (revert→resume→re-apply) if `canCommit()` is true. + * 2. **Fallback — generic free-floating translate**: imperatively + * translates the rendered SVG entry on pointer-move, commits via + * `updateNode` on pointer-up. Used by shelf / spawn / fence / + * etc. whose move is "translate position on X/Z plane". * - * Lives outside the floorplan-panel.tsx monolith. Mounts once globally - * at the panel root; renders nothing unless the active movingNode is a - * registered kind. + * Lives outside the `floorplan-panel.tsx` monolith. Coordinate + * conversion routes through the scene ``'s `getScreenCTM` so + * cursor → meters accounts for pan / zoom / building rotation. */ export function FloorplanRegistryMoveOverlay() { const movingNode = useEditor((s) => s.movingNode) @@ -37,6 +44,7 @@ export function FloorplanRegistryMoveOverlay() { const def = movingNode ? nodeRegistry.get(movingNode.type) : null const isActive = !!movingNode && !!def?.floorplan + const hasMoveTarget = !!def?.floorplanMoveTarget useEffect(() => { if (!isActive || !movingNode) return @@ -44,21 +52,7 @@ export function FloorplanRegistryMoveOverlay() { const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null if (!scene) return - const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null - if (!entry) return - - // Capture the original position so the imperative translate is a - // pure delta — the inner FloorplanGeometry transform (the shelf - // builder's `translate(px pz) rotate(deg)`) stays untouched. - const originalPosition = (( - movingNode as unknown as { - position?: [number, number, number] - } - ).position ?? [0, 0, 0]) as [number, number, number] - - let lastSnapped: [number, number] | null = null - - const toMeters = (clientX: number, clientY: number): { x: number; y: number } | null => { + const toMeters = (clientX: number, clientY: number): [number, number] | null => { const svg = scene.ownerSVGElement if (!svg) return null const ctm = scene.getScreenCTM() @@ -67,13 +61,286 @@ export function FloorplanRegistryMoveOverlay() { pt.x = clientX pt.y = clientY const m = pt.matrixTransform(ctm.inverse()) - return { x: m.x, y: m.y } + return [m.x, m.y] } + // ── Path 1 — kind-owned `floorplanMoveTarget` ─────────────────── + if (hasMoveTarget && def?.floorplanMoveTarget) { + const sceneNodes = useScene.getState().nodes + const session: FloorplanMoveTargetSession = ( + def.floorplanMoveTarget as (a: { + node: AnyNode + nodes: Record + }) => FloorplanMoveTargetSession + )({ node: movingNode, nodes: sceneNodes }) + + // Capture snapshots of every affected node BEFORE the first apply + // so the single-undo dance has a clean baseline to revert to. + const snapshots = session.affectedIds + .map((id) => sceneNodes[id]) + .filter((n): n is AnyNode => !!n) + .map((n) => snapshotNode(n)) + + pauseSceneHistory(useScene) + let historyPaused = true + + // The registry action menu's Move button portals to `document.body`, + // so the trigger click's pointer-up happens OUTSIDE the floor-plan + // scene and never reaches `onPointerUp` here. That means: the very + // first window-pointer-up the overlay sees is the user's intended + // commit click. No "click-to-enter" gesture to detect — the older + // flow used an orange "Move" dot rendered inside the slab itself, + // where the trigger click DID hit the overlay's listener and had + // to be consumed. That legacy flow is gone in the registry layer; + // all entries use the action menu now. + let hasMovedSinceStart = false + + const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => { + // We can't just check `target.closest('[data-floorplan-scene]')` + // because the scene's `` only covers painted SVG elements — + // hovering empty grid background returns the parent SVG element + // as target (no ancestor with the marker), so the closest check + // fails. Compare the pointer position against the scene's + // bounding rect instead: any cursor inside the SVG viewport + // counts as "over the floor plan", regardless of whether the + // exact pixel paints a node or just blank surface. + const svg = scene.ownerSVGElement + if (!svg) return false + const rect = svg.getBoundingClientRect() + return ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) + } + + const onMove = (event: PointerEvent) => { + // Skip 3D-canvas / other-UI cursor moves so the overlay only + // tracks pointer events that actually correspond to a floor-plan + // location. The bounding-rect check (vs the legacy + // `target.closest('[data-floorplan-scene]')`) also picks up + // hovers over empty grid background — without it, the cursor + // only updated the shelf when it happened to brush over an + // existing SVG entry, leaving the move feeling "stuck" elsewhere. + if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return + const planPoint = toMeters(event.clientX, event.clientY) + if (!planPoint) return + hasMovedSinceStart = true + session.apply({ + planPoint, + modifiers: { + shiftKey: event.shiftKey, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }, + }) + } + + const commitFinalStateOrRevert = () => { + const commitValid = session.canCommit() + const sceneState = useScene.getState().nodes + const finalUpdates: Array<{ id: AnyNodeId; data: Record }> = [] + for (const snap of snapshots) { + const current = sceneState[snap.id] + if (!current) continue + const data: Record = {} + let changed = false + for (const [key, before] of Object.entries(snap.data)) { + const after = (current as unknown as Record)[key] + if (!deepEqual(before, after)) { + data[key] = Array.isArray(after) ? [...(after as unknown[])] : after + changed = true + } + } + if (changed) finalUpdates.push({ id: snap.id, data }) + } + + if (commitValid && finalUpdates.length > 0) { + // Single-undo dance: + // 1. Revert to baseline while history is still paused. + // 2. Resume history. + // 3. Re-apply the final state — recorded as one tracked change. + useScene.getState().updateNodes(snapshotsToUpdates(snapshots)) + if (historyPaused) { + resumeSceneHistory(useScene) + historyPaused = false + } + useScene.getState().updateNodes(finalUpdates) + // Strip the isNew metadata once committed (matches the legacy + // 3D move-tool that demotes duplicated nodes from "new" status + // on first successful drop). + for (const snap of snapshots) { + const current = useScene.getState().nodes[snap.id] + const meta = + current && typeof (current as { metadata?: unknown }).metadata === 'object' + ? ((current as { metadata?: Record }).metadata ?? {}) + : {} + if (meta.isNew) { + useScene.getState().updateNodes([ + { + id: snap.id, + data: { metadata: { ...meta, isNew: false } } as Record, + }, + ]) + } + } + sfxEmitter.emit('sfx:item-place') + // Re-select the moved node(s) — mirrors the legacy 3D move + // tool. The action menu cleared selection on Move click so + // selection-gated affordances (slab/ceiling boundary editor, + // etc.) would unmount during the drag; restoring it here + // brings them back at the new position. + useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) }) + } else { + useScene.getState().updateNodes(snapshotsToUpdates(snapshots)) + if (historyPaused) { + resumeSceneHistory(useScene) + historyPaused = false + } + } + } + + const onPointerUp = (event: PointerEvent) => { + if (event.button !== 0) return + // Bounding-rect check (see `isPointerOverFloorplanScene`) — same + // reason as `onMove`: commits should land for any pointer-up + // inside the SVG viewport, including empty grid background. + if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return + + commitFinalStateOrRevert() + setMovingNode(null) + + // Swallow the click event that follows this pointer-up — the + // floor-plan SVG's `handleBackgroundClick` would otherwise route + // it through `resolveFloorplanBackgroundSelection`, which clears + // the selection if the click resolved to empty space. We already + // set selection back to the moved node in `commitFinalStateOrRevert`; + // letting the background-click handler run would undo that for + // any commit click that doesn't happen to land directly on the + // node's hit-test geometry. + // + // The 3D mover doesn't need this because its grid-click fires + // via the emitter inside the R3F pointer event and can call + // `event.nativeEvent.stopPropagation()`; the 2D pointerup and + // the following click are separate DOM events, so we listen on + // window in the capture phase to intercept the click before any + // bubble-phase handler (the floor-plan SVG) sees it. + const swallowClick = (e: MouseEvent) => { + e.stopPropagation() + e.preventDefault() + window.removeEventListener('click', swallowClick, true) + } + window.addEventListener('click', swallowClick, true) + // Safety net: if no click fires (e.g. user dragged enough to + // suppress it), drop the listener on the next tick. + setTimeout(() => { + window.removeEventListener('click', swallowClick, true) + }, 0) + } + + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + // Revert untracked, then resume — no history entry. + useScene.getState().updateNodes(snapshotsToUpdates(snapshots)) + if (historyPaused) { + resumeSceneHistory(useScene) + historyPaused = false + } + // Clear any live-transform previews the session wrote (slab / + // ceiling 2D move stages a translation delta in + // `useLiveTransforms`; without this clear, escape leaves the + // 2D layer rendering the polygon at the cancelled delta). + for (const id of session.affectedIds) { + useLiveTransforms.getState().clear(id) + } + // Restore selection cleared by the action menu's Move click. + useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) }) + setMovingNode(null) + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('keydown', onKey) + // Unmount cleanup. Two scenarios when `historyPaused === true`: + // + // - User did at least one 2D apply (`hasMovedSinceStart`) but + // never committed — likely a mid-drag unmount. Revert the + // untracked writes so we don't leak partial state. + // - No 2D apply happened. The legacy `MoveItemContent` (3D + // mover) may have committed via `draftNode.commit` just + // before this unmount; clobbering that with a blind revert + // is the bug — both the rotation and position issues. Skip + // the revert and just resume history. + // + // Additionally, in split view the user may have brushed the + // cursor over the floor plan (setting `hasMovedSinceStart`) + // and then committed via a 3D mover. The 3D commit writes the + // new state to `scene` directly, so by the time this cleanup + // runs `snapshots` no longer matches scene state. Reverting + // here would stomp the 3D commit. Detect the case by + // comparing snapshot fields to current scene state — if they + // already differ, an external committer has finalised, leave + // it alone. + // + // Normal 2D commit / Escape paths set `historyPaused = false` + // inside `commitFinalStateOrRevert` / `onKey`, so this branch + // is skipped there. + if (historyPaused) { + if (hasMovedSinceStart) { + const currentNodes = useScene.getState().nodes + const externallyCommitted = snapshots.some((snap) => { + const current = currentNodes[snap.id] + if (!current) return false + for (const [key, before] of Object.entries(snap.data)) { + const after = (current as unknown as Record)[key] + if (!deepEqual(before, after)) return true + } + return false + }) + if (!externallyCommitted) { + useScene.getState().updateNodes(snapshotsToUpdates(snapshots)) + } + } + resumeSceneHistory(useScene) + } + // Belt-and-suspenders: clear any live-transform previews on + // abnormal unmount paths too. Slab / ceiling sessions write + // `useLiveTransforms` to drive the smooth drag visual; in pure + // 2D view the 3D `MoveSlabTool` cleanup isn't there to clear + // it for us. + for (const id of session.affectedIds) { + useLiveTransforms.getState().clear(id) + } + } + } + + // ── Path 2 — generic free-floating translate ──────────────────── + const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null + if (!entry) return + + const originalPosition = (( + movingNode as unknown as { + position?: [number, number, number] + } + ).position ?? [0, 0, 0]) as [number, number, number] + + let lastSnapped: [number, number] | null = null + const onMove = (event: PointerEvent) => { + // Same target guard as Path 1 — pointer must be over the floor + // plan scene; otherwise we'd react to 3D-canvas moves with garbage + // plan coords. + const target = event.target as Element | null + if (!target || !target.closest('[data-floorplan-scene]')) return const m = toMeters(event.clientX, event.clientY) if (!m) return - const [sx, sz] = snapPointToGrid([m.x, m.y], GRID_STEP) + const [sx, sz] = snapPointToGrid([m[0], m[1]], GRID_STEP) const dx = sx - originalPosition[0] const dz = sz - originalPosition[2] entry.setAttribute('transform', `translate(${dx} ${dz})`) @@ -82,9 +349,6 @@ export function FloorplanRegistryMoveOverlay() { const onPointerUp = (event: PointerEvent) => { if (event.button !== 0) return - // Commit only when the pointerup happened inside the floor plan - // SVG (so clicks on the inspector / palette / tabs don't accidentally - // commit a placement). const target = event.target as Element | null if (!target || !target.closest('[data-floorplan-scene]')) return @@ -123,10 +387,52 @@ export function FloorplanRegistryMoveOverlay() { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('keydown', onKey) - // Defensive cleanup in case the component unmounts mid-drag. entry.removeAttribute('transform') } - }, [isActive, movingNode, setMovingNode]) + }, [isActive, movingNode, setMovingNode, hasMoveTarget, def]) return null } + +// ── Snapshot helpers (shared shape with floorplan-registry-layer) ─── +// +// Kept inline here to avoid a circular dependency through a shared +// utility module. If a third call site shows up, extract. + +type NodeSnapshot = { id: AnyNodeId; data: Record } + +function snapshotNode(node: AnyNode): NodeSnapshot { + const data: Record = {} + for (const [key, value] of Object.entries(node)) { + if (key === 'id' || key === 'type' || key === 'object') continue + data[key] = Array.isArray(value) ? [...(value as unknown[])] : value + } + return { id: node.id, data } +} + +function snapshotsToUpdates(snapshots: NodeSnapshot[]) { + return snapshots.map((s) => ({ id: s.id, data: s.data })) +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false + } + return true + } + if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) { + const aKeys = Object.keys(a as Record) + const bKeys = Object.keys(b as Record) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!deepEqual((a as Record)[key], (b as Record)[key])) { + return false + } + } + return true + } + return false +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx index 6a020056..890f5eaa 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx @@ -1,7 +1,7 @@ 'use client' -import type { FloorplanGeometry } from '@pascal-app/core' -import { memo } from 'react' +import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core' +import { memo, useEffect, useState } from 'react' /** * Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by @@ -29,92 +29,103 @@ export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer return renderNode(geometry, 0) }) +function styleAttrs(g: FloorplanGeometry & { kind: Exclude }) { + // Shared SVG attribute mapping for any styled primitive. Keeps the per- + // primitive switch arms terse and ensures new style fields land + // everywhere at once. `as any` avoids re-asserting every variant + // includes the style fields — they all do, except `group` (which is + // filtered out by the caller's type bound). + const s = g as unknown as { + fill?: string + fillOpacity?: number + stroke?: string + strokeWidth?: number + strokeDasharray?: string + strokeLinecap?: 'butt' | 'round' | 'square' + strokeLinejoin?: 'miter' | 'round' | 'bevel' + strokeOpacity?: number + opacity?: number + vectorEffect?: 'non-scaling-stroke' + } + return { + fill: s.fill ?? 'none', + fillOpacity: s.fillOpacity, + stroke: s.stroke, + strokeWidth: s.strokeWidth, + strokeDasharray: s.strokeDasharray, + strokeLinecap: s.strokeLinecap, + strokeLinejoin: s.strokeLinejoin, + strokeOpacity: s.strokeOpacity, + opacity: s.opacity, + vectorEffect: s.vectorEffect, + } +} + function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement | null { switch (g.kind) { case 'path': - return ( - - ) + return case 'polygon': - return ( - - ) + return case 'polyline': - return ( - - ) + return case 'rect': return ( ) case 'circle': - return ( - - ) + return case 'line': + return + + case 'text': return ( - + {g.text} + + ) + + case 'image': + return ( + ) @@ -126,6 +137,15 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement | ) } + + // The interactive primitives (hatch / hit-line / endpoint-handle / + // dimension-label) need the SVG context + theme palette + units-per- + // pixel that only the registry layer has access to. They're rendered + // by `floorplan-registry-layer.tsx`'s interactive walker instead. If + // a caller routes one of these through this pure renderer it + // silently drops — the static renderer is for static output. + default: + return null } } @@ -143,3 +163,61 @@ function formatTransform(t?: { if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`) return parts.length > 0 ? parts.join(' ') : undefined } + +/** + * `image` primitive renderer. Resolves the URL asynchronously via + * `loadAssetUrl` (handles CDN / Supabase storage) and renders an SVG + * `` centered at `center`, rotated around it, sized in plan-local + * metres. While the resolution is in flight, renders nothing. + */ +function FloorplanImage({ + url, + center, + width, + height, + rotation, + preserveAspectRatio, + opacity, +}: { + url: string + center: readonly [number, number] + width: number + height: number + rotation: number + preserveAspectRatio: string + opacity?: number +}) { + const [resolvedUrl, setResolvedUrl] = useState(null) + useEffect(() => { + if (!url) { + setResolvedUrl(null) + return + } + let cancelled = false + setResolvedUrl(null) + loadAssetUrl(url).then((next) => { + if (!cancelled) setResolvedUrl(next) + }) + return () => { + cancelled = true + } + }, [url]) + if (!resolvedUrl) return null + const rotationDeg = (rotation * 180) / Math.PI + return ( + + + + ) +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index e531873c..3f1cc341 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -3,51 +3,137 @@ import { type AnyNode, type AnyNodeId, + type FloorplanAffordancePoint, + type FloorplanAffordanceSession, type FloorplanGeometry, + type FloorplanPalette, type GeometryContext, nodeRegistry, + pauseSceneHistory, + resumeSceneHistory, + useInteractive, + useLiveNodeOverrides, + useLiveTransforms, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { memo, useCallback, useMemo } from 'react' +import { + memo, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { useFloorplanRender } from '../floorplan-render-context' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' /** * Registry-driven floor-plan layer. * * For every node in the active level whose definition exposes - * `def.floorplan`, builds a `GeometryContext`, calls the builder, and - * emits the resulting SVG via ``. Each entry - * is wrapped in an interactive `` that handles **click → select**. + * `def.floorplan`, builds a `GeometryContext` (with `viewState` so the + * kind can theme its output), calls the builder, and walks the resulting + * tree. Static primitives (polygon / line / circle / etc.) defer to + * ``. Interactive primitives — `hatch`, + * `hit-line`, `endpoint-handle`, `dimension-label` — render here so they + * can access the SVG context for pointer events + units-per-pixel. * - * Move and delete happen via the same flow as 3D: select sets - * `useViewer.selection`, the `` opens with Move / - * Delete buttons, and the user enters move mode from there. Floor-plan - * cursor-driven placement for registry kinds (the "while in moving - * state, click in floor plan to place") is a follow-up — see the - * plan's Phase 4 follow-on section. + * Selection: clicking the entry's `` selects the node. The wall + * `def.floorplan` also emits a `hit-line` along the centerline so the + * user can grab the wall body even at zoom levels where the polygon is + * skinny. * - * Drag-to-move was prototyped here briefly but removed: the grab cursor - * + drag interaction model is inconsistent with the rest of the editor - * (3D doesn't drag, it uses select → Move button → cursor-driven - * placement) and the SVG coord conversion needs to route through the - * floor-plan scene `` (legacy `floorplanSceneRef`) to account for - * pan/zoom transforms. Both warrant a proper port via the action menu - * + movingNode flow, not an inline drag in this layer. - * - * Coexists with the legacy `floorplan-panel.tsx` inline rendering — - * unmigrated kinds keep their hand-written branches. As each kind ports - * `def.floorplan`, its inline equivalent becomes dead code and gets - * removed in the same PR. - * - * Coordinates are level-local meters; the parent SVG handles world→SVG - * transform via its viewBox. + * 2D endpoint drag: when an `endpoint-handle` is pointer-downed and its + * `affordance === 'move-endpoint'`, this layer drives the legacy wall + * endpoint flow inline — snap pointer to walls/grid, run linked-wall + * cascade, live-update positions with history paused, single undo on + * commit. The kind-generic abstraction lands once fence + slab + ceiling + * pick up their 2D drags too (next iteration). */ +// Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*` +// constants in floorplan-panel.tsx. Sizes are in screen pixels — the +// dispatcher multiplies by `unitsPerPixel` so handles stay the same on- +// screen size at any zoom. +const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8 +const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9 +const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3 +const ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX = 4 +const ENDPOINT_HIT_STROKE_WIDTH_PX = 18 +const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16 +const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7 +const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)' + +/** + * Snapshot of node fields captured at drag-start, used by the single-undo + * dance to revert untracked before re-applying as a single tracked + * change. The dispatcher only knows about the `affectedIds` the + * affordance declares; it captures whatever fields exist on each node by + * cloning the full record minus the registry-managed `id` / `type`. + */ +type NodeSnapshot = { id: AnyNodeId; data: Record } + +type ActiveDrag = { + pointerId: number + /** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */ + handleId: string + session: FloorplanAffordanceSession + snapshots: NodeSnapshot[] + historyPaused: boolean +} + +function snapshotNode(node: AnyNode): NodeSnapshot { + // Shallow-clone every non-id, non-type field. Arrays / vec tuples are + // deep-cloned to detach from the live store reference. + const data: Record = {} + for (const [key, value] of Object.entries(node)) { + if (key === 'id' || key === 'type' || key === 'object' || key === 'parentId') continue + data[key] = Array.isArray(value) ? [...(value as unknown[])] : value + } + return { id: node.id, data } +} + +function snapshotsToUpdates(snapshots: NodeSnapshot[]) { + return snapshots.map((s) => ({ id: s.id, data: s.data })) +} + export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const levelId = useViewer((s) => s.selection.levelId) const selectedIds = useViewer((s) => s.selection.selectedIds) + const previewSelectedIds = useViewer((s) => s.previewSelectedIds) + const hoveredId = useViewer((s) => s.hoveredId) + const setHoveredId = useViewer((s) => s.setHoveredId) const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) + const renderCtx = useFloorplanRender() + const movingNode = useEditor((s) => s.movingNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + // Subscribe to the live-transforms map ref so the layer re-renders + // whenever a 3D mover publishes a per-frame position (see + // `usePlacementCoordinator`). Without this the 2D floor plan only + // updates after 3D commit — the 3D drag would look frozen in 2D. + const liveTransforms = useLiveTransforms((s) => s.transforms) + // Same reactivity hook for elevator runtime state — `useInteractive` + // tracks the current / fallback level + cab travel, `useLiveNode + // Overrides` carries live-edit overrides from the inspector. Builders + // read both via `getState()` inside `def.floorplan`; subscribing here + // is what forces the layer to re-render when they change. + const liveOverrides = useLiveNodeOverrides((s) => s.overrides) + const interactiveElevators = useInteractive((s) => s.elevators) + + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + // Marquee preview selection — matches the legacy `highlightedIdSet` use + // (filter-while-marquee), surfaces selection chrome without keyboard focus. + const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds]) + + // Interactive state lives in refs; only the visible feedback bits go + // into React state to keep re-renders cheap during drag. + const dragRef = useRef(null) + const [hoveredHandleId, setHoveredHandleId] = useState(null) + const [activeDragId, setActiveDragId] = useState(null) const handleSelect = useCallback( (id: AnyNodeId, event: React.PointerEvent) => { @@ -58,20 +144,33 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { [setSelection], ) - // Outer SVG has `onClick={handleBackgroundClick}` which can deselect on - // empty-area clicks. stopPropagation on onPointerDown doesn't block the - // synthesized click that follows pointer-up. Stopping the click too - // keeps the selection set by `handleSelect`. const handleClickStop = useCallback((event: React.MouseEvent) => { event.stopPropagation() }, []) + // Build the geometry list. `viewState` flows into ctx so kinds can + // theme their output and conditionally emit selection chrome. + // + // Each entry carries TWO trees: + // - `base`: filled shapes, strokes, polygons, hatches — anything + // that should respect the kind's z-order bucket. + // - `overlay`: interactive handles (vertex / midpoint / edge / move) + // and labels (text / dimension). These always render on top of + // every base entry so selection chrome and node names stay visible + // above walls, items, etc. + // + // The split is computed by `splitFloorplanOverlay` from the single + // tree the builder returns. Builders don't need to know about the + // partition. const entries = useMemo(() => { if (!levelId) return [] const out: { id: AnyNodeId node: AnyNode - geometry: FloorplanGeometry + base: FloorplanGeometry | null + overlay: FloorplanGeometry | null + selected: boolean + highlighted: boolean }[] = [] const visit = (id: AnyNodeId) => { @@ -80,12 +179,74 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const def = nodeRegistry.get(node.type) const builder = def?.floorplan if (builder) { - const ctx = buildContext(node, nodes) + const selected = selectedIdSet.has(id) + const highlighted = highlightedIdSet.has(id) + const hovered = hoveredId === id + const moving = movingNode?.id === id + // Live-transform override — when a mover is publishing per-frame + // position/rotation, render that here instead of the committed + // scene state. Without this the 2D floor plan would only update + // after commit, making the drag look frozen. + // + // The live-transform contract varies per kind (see + // wiki/architecture/tools.md "useLiveTransforms contract is + // per-kind, not generic"); we narrow per kind here: + // - item: world-plan position frame. Override `position` + + // `rotation` and force `parentId: null` so the resolver + // treats them as world coords directly. + // - slab / ceiling: position is a translation **delta** + // (`[Δx, 0, Δz]`). Translate the polygon + holes by the + // delta — the floor-plan builder draws the polygon at its + // new location, mirroring the 3D `` + // visual without forcing per-tick CSG scene writes. + const live = liveTransforms.get(id) + let effectiveNode: AnyNode = node + if (live) { + if (node.type === 'item' || node.type === 'shelf') { + // World-plan position kinds: the live transform carries the + // node's intended position/rotation in level-local coords. + // Override both and force `parentId: null` so the floor-plan + // resolver treats `position` as world plan coords directly + // (skipping the parent-chain transform composition). + effectiveNode = { + ...node, + position: live.position, + rotation: [0, live.rotation, 0] as [number, number, number], + parentId: null, + } as AnyNode + } else if (node.type === 'slab' || node.type === 'ceiling') { + const dx = live.position[0] + const dz = live.position[2] + if (dx !== 0 || dz !== 0) { + const surface = node as { + polygon: Array<[number, number]> + holes?: Array> + } + effectiveNode = { + ...node, + polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), + holes: (surface.holes ?? []).map((h) => + h.map(([x, z]) => [x + dx, z + dz] as [number, number]), + ), + } as AnyNode + } + } + } + const ctx = buildContext(effectiveNode, nodes, { + selected, + highlighted, + hovered, + moving, + palette: renderCtx?.palette, + }) const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)( - node, + effectiveNode, ctx, ) - if (geometry) out.push({ id, node, geometry }) + if (geometry) { + const { base, overlay } = splitFloorplanOverlay(geometry) + out.push({ id, node: effectiveNode, base, overlay, selected, highlighted }) + } } const childIds = (node as unknown as { children?: AnyNodeId[] }).children if (Array.isArray(childIds)) { @@ -94,52 +255,897 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { } visit(levelId as AnyNodeId) + + // Stable z-order sort. SVG renders in document order — later siblings + // paint on top of earlier ones — so anything that should sit *under* + // other floor-plan geometry has to come first in the entries array. + // Zones are conceptual room/area regions; walls / slabs / furniture + // all belong on top of them. Within a layer bucket we preserve the + // DFS visit order (stable sort) so siblings keep their relative + // priority. + out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type)) return out - }, [levelId, nodes]) + }, [ + levelId, + nodes, + liveTransforms, + liveOverrides, + interactiveElevators, + selectedIdSet, + highlightedIdSet, + hoveredId, + movingNode?.id, + renderCtx?.palette, + ]) + + // ── Generic 2D affordance dispatch ───────────────────────────────── + // + // Pointer-down on an interactive handle resolves the kind's + // `def.floorplanAffordances?.[affordance]` and starts a session. The + // dispatcher then owns: history pause/resume, snapshot capture, + // pointer-move/up/cancel routing, and the single-undo dance on + // commit. Each kind owns the actual mutation logic inside `apply`. + const startAffordanceDrag = useCallback( + ( + nodeId: AnyNodeId, + handleId: string, + affordance: string, + payload: unknown, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) return + if (movingNode) return + + const sceneNodes = useScene.getState().nodes + const node = sceneNodes[nodeId] + if (!node) return + + const def = nodeRegistry.get(node.type) + const handler = def?.floorplanAffordances?.[affordance] + if (!handler) return + + const initialPlanPoint = clientToPlan(event.clientX, event.clientY) + if (!initialPlanPoint) return + + event.preventDefault() + event.stopPropagation() + + const session = handler.start({ + node, + payload, + nodes: sceneNodes, + initialPlanPoint, + }) + + const snapshots: NodeSnapshot[] = [] + for (const id of session.affectedIds) { + const n = sceneNodes[id] + if (n) snapshots.push(snapshotNode(n)) + } + + pauseSceneHistory(useScene) + + dragRef.current = { + pointerId: event.pointerId, + handleId, + session, + snapshots, + historyPaused: true, + } + setActiveDragId(handleId) + setSelection({ selectedIds: [nodeId] }) + ;(event.currentTarget as Element).setPointerCapture?.(event.pointerId) + }, + [movingNode, setSelection], + ) + + useEffect(() => { + const onPointerMove = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + + const planPoint = clientToPlan(event.clientX, event.clientY) + if (!planPoint) return + + drag.session.apply({ + planPoint, + modifiers: { + shiftKey: event.shiftKey, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }, + }) + } + + const onPointerUp = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + + const commitValid = drag.session.canCommit() + + // Capture the final state BEFORE the revert so we know what to + // re-apply post-resume. + const sceneNodes = useScene.getState().nodes + const finalUpdates: Array<{ id: AnyNodeId; data: Record }> = [] + for (const snap of drag.snapshots) { + const current = sceneNodes[snap.id] + if (!current) continue + const data: Record = {} + let changed = false + for (const [key, before] of Object.entries(snap.data)) { + const after = (current as unknown as Record)[key] + if (!deepEqual(before, after)) { + data[key] = Array.isArray(after) ? [...(after as unknown[])] : after + changed = true + } + } + if (changed) finalUpdates.push({ id: snap.id, data }) + } + + if (commitValid && finalUpdates.length > 0) { + // Single-undo dance (mirrors the 3D move-endpoint-tool): + // 1. Revert to baseline while history is still paused (untracked). + // 2. Resume history. + // 3. Re-apply the final state — recorded as one tracked change. + useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots)) + if (drag.historyPaused) { + resumeSceneHistory(useScene) + drag.historyPaused = false + } + useScene.getState().updateNodes(finalUpdates) + sfxEmitter.emit('sfx:structure-build') + } else { + // Either no net change or canCommit() rejected — revert and + // resume without committing. + useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots)) + if (drag.historyPaused) { + resumeSceneHistory(useScene) + drag.historyPaused = false + } + } + + dragRef.current = null + setActiveDragId(null) + } + + const onPointerCancel = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + + // Revert untracked, then resume — no history entry is recorded. + useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots)) + if (drag.historyPaused) { + resumeSceneHistory(useScene) + drag.historyPaused = false + } + + dragRef.current = null + setActiveDragId(null) + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + // Component unmounted mid-drag — restore the baseline and unpause + // history so we don't leak a paused store across mounts. + const drag = dragRef.current + if (drag) { + useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots)) + if (drag.historyPaused) { + resumeSceneHistory(useScene) + } + dragRef.current = null + } + } + }, []) if (entries.length === 0) return null + const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1 + const palette = renderCtx?.palette + + const renderEntry = (id: AnyNodeId, geometry: FloorplanGeometry, key: string) => ( + handleSelect(id, e)} + // Mirror the sidebar tree nodes' hover wiring — `useViewer. + // hoveredId` drives the highlight halo in 3D as well as the + // wall / fence floor-plan hover stroke. Setting it on + // pointer-enter and clearing on leave keeps the two views in + // sync. Without this the registry-driven kinds had hover + // visuals defined but never reached because the entry `` + // never updated the store. + onPointerEnter={() => setHoveredId(id)} + onPointerLeave={() => { + // Only clear when this entry is the one we last set — + // avoids racing with sibling entries during fast-moving + // pointer scans. + if (useViewer.getState().hoveredId === id) setHoveredId(null) + }} + style={{ cursor: 'pointer' }} + > + + startAffordanceDrag(id, makeHandleId(id, payload), affordance, payload, event) + } + onMoveHandlePointerDown={(event) => { + if (event.button !== 0) return + const node = useScene.getState().nodes[id] + if (!node) return + event.preventDefault() + event.stopPropagation() + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node as never) + }} + palette={palette} + unitsPerPixel={unitsPerPixel} + /> + + ) + return ( - - {entries.map(({ id, geometry }) => { - const isSelected = selectedIds.includes(id) - return ( - handleSelect(id, e)} - style={{ cursor: 'pointer' }} - > - - {isSelected && } - - ) - })} + // The outer wrapper stops `click` events that escape an entry's + // `onClick={handleClickStop}`. The base+overlay split means + // pointer-down can land on the base `` and pointer-up on the + // overlay `` (selection mounts the overlay on top mid-gesture). + // When the down/up targets differ, the browser dispatches `click` + // to the lowest common ancestor — which sits ABOVE the entry-level + // handler. Without this guard the click reaches the SVG's + // `handleBackgroundClick`, which calls + // `resolveFloorplanBackgroundSelection` → `clear-elements` (because + // registry-driven items aren't in the legacy hit-test set) → + // clearing the selection that pointer-down just set, so items + // appear to "deselect themselves a fraction of a second after + // clicking." Scoped to `onClick` so hover / drag / pointer events + // still propagate normally inside the registry tree. + + {/* Base pass — rank-sorted body geometry (polygons, paths, fills, + strokes, hatches). Lower-rank kinds (zones) paint first so + higher-rank kinds (slabs, then walls / items / shelves) layer + on top in the expected document-order z-stack. */} + + {entries.map(({ id, base }) => + base ? renderEntry(id, base, `base-${id}`) : null, + )} + + {/* Overlay pass — interactive handles (vertex / midpoint / edge / + move) and labels (text / dimensions). Painted after every base + entry so polygon-editor chrome on a selected slab stays above + neighbouring walls, and a zone name stays readable above the + slab + wall geometry sitting on top of the zone. Each overlay + still routes through the same selection-handling `` so a + click on a zone's name selects the zone. */} + + {entries.map(({ id, overlay }) => + overlay ? renderEntry(id, overlay, `overlay-${id}`) : null, + )} + ) }) -function SelectionOutline({ geometry }: { geometry: FloorplanGeometry }) { - return ( - - - - ) -} +// ── Interactive geometry walker ────────────────────────────────────── -function withSelectionStyle(g: FloorplanGeometry): FloorplanGeometry { - const accent = { stroke: '#818cf8', strokeWidth: 0.04, fill: 'none', opacity: 1 } - if (g.kind === 'group') { - return { ...g, children: g.children.map(withSelectionStyle) } +function InteractiveGeometry({ + geometry, + unitsPerPixel, + palette, + hatchPatternId, + hoveredHandleId, + activeDragId, + nodeId, + onHandleHoverChange, + onHandlePointerDown, + onMoveHandlePointerDown, +}: { + geometry: FloorplanGeometry + unitsPerPixel: number + palette: FloorplanPalette | undefined + hatchPatternId: string | undefined + hoveredHandleId: string | null + activeDragId: string | null + nodeId: AnyNodeId + onHandleHoverChange: (id: string | null) => void + onHandlePointerDown: ( + affordance: string, + payload: unknown, + event: ReactPointerEvent, + ) => void + onMoveHandlePointerDown: (event: ReactPointerEvent) => void +}): React.ReactElement { + return renderInteractive(geometry, 0) + + function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement { + switch (g.kind) { + case 'group': { + const transform = formatGroupTransform(g.transform) + return ( + + {g.children.map((child, i) => renderInteractive(child, i))} + + ) + } + case 'hatch': { + if (!hatchPatternId) return <> + return ( + `${x},${y}`).join(' ')} + /> + ) + } + case 'hit-line': { + return ( + + ) + } + case 'endpoint-handle': { + if (!palette) return <> + const handleId = makeHandleId(nodeId, g.payload) + const isHovered = hoveredHandleId === handleId + const isActive = activeDragId === handleId + // Variant picks the colour-set. Endpoint dots use the orange + // legacy palette; curve sagitta dots use the teal set so users + // can tell them apart at a glance. + const isCurve = g.variant === 'curve' + const stroke = isCurve + ? palette.curveHandleStroke + : isActive + ? palette.endpointHandleActiveStroke + : palette.endpointHandleStroke + const hoverStroke = isCurve + ? palette.curveHandleHoverStroke + : isActive + ? palette.endpointHandleActiveStroke + : palette.endpointHandleHoverStroke + const fill = isCurve + ? palette.curveHandleFill + : isActive + ? palette.endpointHandleActiveFill + : palette.endpointHandleFill + const outerRadius = + (isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) * + unitsPerPixel + const dotRadius = + (isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) * + unitsPerPixel + return ( + e.stopPropagation()} + onPointerEnter={() => onHandleHoverChange(handleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + + + + + + onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent) + } + pointerEvents="all" + r={outerRadius} + stroke="transparent" + strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel} + style={{ cursor: 'pointer' }} + vectorEffect="non-scaling-stroke" + /> + + ) + } + case 'move-handle': { + if (!palette) return <> + const moveHandleId = `${nodeId}:move` + const isHovered = hoveredHandleId === moveHandleId + // Move dots are visually bigger than endpoint handles — the + // legacy prod render uses ~13px outer / ~6px dot. Endpoint + // handles top out at 8/9px because there are usually two per + // wall + linked walls + curve handle nearby; the move dot is + // a singleton centerpiece so it can afford the extra weight. + const baseRadiusPx = 13 + const hoverRadiusPx = 15 + const outerRadius = (isHovered ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel + const dotRadius = 6 * unitsPerPixel + // Same 5-circle stack as the orange endpoint dot — hover glow + + // hover ring + filled outer + inner dot + transparent hit. On + // pointer-down, the layer calls `setMovingNode(node)`, which + // FloorplanRegistryMoveOverlay picks up and routes to the + // kind's `def.floorplanMoveTarget`. + return ( + e.stopPropagation()} + onPointerEnter={() => onHandleHoverChange(moveHandleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + + + + + onMoveHandlePointerDown(e as ReactPointerEvent)} + pointerEvents="all" + r={outerRadius} + stroke="transparent" + strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel} + style={{ cursor: 'move' }} + vectorEffect="non-scaling-stroke" + /> + + ) + } + case 'edge-handle': { + if (!palette) return <> + const handleId = makeHandleId(nodeId, g.payload) + const isHovered = hoveredHandleId === handleId + const isActive = activeDragId === handleId + const showVisible = isHovered || isActive + const stroke = isActive ? palette.endpointHandleActiveStroke : palette.selectedStroke + // Stroke widths in screen pixels — non-scaling-stroke keeps the + // hit area + glow consistent at every zoom. + const glowWidthPx = 14 + const visibleWidthPx = 3 + const hitWidthPx = 18 + return ( + e.stopPropagation()} + onPointerEnter={() => onHandleHoverChange(handleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + {/* Soft glow — visible only on hover / active. */} + + {/* Solid stroke on top — slightly more opaque when active. */} + + {/* Transparent hit area along the edge. */} + + onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent) + } + pointerEvents="stroke" + stroke="transparent" + strokeLinecap="round" + strokeWidth={hitWidthPx * unitsPerPixel} + style={{ cursor: 'pointer' }} + vectorEffect="non-scaling-stroke" + x1={g.x1} + x2={g.x2} + y1={g.y1} + y2={g.y2} + /> + + ) + } + case 'midpoint-handle': { + if (!palette) return <> + const handleId = makeHandleId(nodeId, g.payload) + const isHovered = hoveredHandleId === handleId + const isActive = activeDragId === handleId + const stroke = palette.endpointHandleStroke + const hoverStroke = palette.endpointHandleHoverStroke + // Slightly smaller than endpoint dots; hover-expanded. + const baseRadiusPx = 6 + const hoverRadiusPx = 8 + const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel + const plusHalf = 3 * unitsPerPixel + return ( + e.stopPropagation()} + onPointerEnter={() => onHandleHoverChange(handleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + + + {/* `+` icon — only when the user is close enough to see it + clearly (hover or active state). Keeps the resting state + visually quiet on busy polygons. */} + + + + onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent) + } + pointerEvents="all" + r={radius + unitsPerPixel * 2} + stroke="transparent" + strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel} + style={{ cursor: 'pointer' }} + vectorEffect="non-scaling-stroke" + /> + + ) + } + case 'dimension-label': { + if (!palette) return <> + // Flip the label upright if it would otherwise be upside-down + // (legacy floorplan-panel.tsx does the same — see line ~2548). + let degrees = (g.angle * 180) / Math.PI + if (degrees > 90) degrees -= 180 + else if (degrees <= -90) degrees += 180 + + const padX = unitsPerPixel * 6 + const padY = unitsPerPixel * 3 + const fontSize = Math.max(unitsPerPixel * 10, 0.08) + // Rough text width approximation — SVG can't measure text without + // the DOM. 6.2px per char at 10px font keeps the plate visually + // balanced for the short length strings ("3.24m", "1'2\"", etc.). + const textWidth = g.text.length * unitsPerPixel * 6.2 + const plateW = textWidth + padX * 2 + const plateH = fontSize + padY * 2 + return ( + + + + {g.text} + + + ) + } + case 'dimension': { + if (!palette) return <> + const stroke = g.stroke ?? palette.measurementStroke + // Offset endpoints along the outward normal — this is where the + // dimension line sits, parallel to the edge. + const ox = g.offsetNormal[0] * g.offsetDistance + const oy = g.offsetNormal[1] * g.offsetDistance + const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy] + const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy] + + // Extension line endpoints — extend past the dimension line by + // `extensionOvershoot` so the tip clears the dimension stroke. + const eOvershoot = g.extensionOvershoot + const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot) + const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot) + const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy] + const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy] + + const dx = dEnd[0] - dStart[0] + const dy = dEnd[1] - dStart[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return <> + const dirX = dx / length + const dirY = dy / length + + // Plan-unit constants matching the legacy `floorplan- + // measurements-layer.tsx`. `strokeWidth` is intentionally a + // raw value (not multiplied by `unitsPerPixel`) because every + // stroke here uses `vectorEffect: non-scaling-stroke` — the + // browser interprets it as screen-pixel-stable. Multiplying + // by `unitsPerPixel` would shrink the strokes by ~100× and + // make them invisible. Tick length, dash pattern, font size, + // and the label gap stay in plan units (they're geometry, + // not stroke width). + const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2 + const perpX = -dirY * tickHalf + const perpY = dirX * tickHalf + + const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE + const labelGap = 0.5 // plan units — gap in the dimension line for the label + const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04) + + const midX = (dStart[0] + dEnd[0]) / 2 + const midY = (dStart[1] + dEnd[1]) / 2 + const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf] + const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf] + + let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI + if (labelDeg > 90) labelDeg -= 180 + else if (labelDeg <= -90) labelDeg += 180 + + return ( + + {/* Extension lines (dashed). */} + + + {/* Dimension line: two halves with the label in between. */} + + + {/* End ticks. */} + + + {/* Rotated label centered in the gap. */} + + {g.text} + + + ) + } + default: + return + } } - return { ...g, ...accent } } -function buildContext(node: AnyNode, nodes: Record): GeometryContext { +// ── Helpers ────────────────────────────────────────────────────────── + +function buildContext( + node: AnyNode, + nodes: Record, + viewState: { + selected: boolean + highlighted: boolean + hovered: boolean + moving: boolean + palette: FloorplanPalette | undefined + }, +): GeometryContext { const resolve = (id: AnyNodeId): N | undefined => nodes[id] as N | undefined const childIds = (node as unknown as { children?: AnyNodeId[] }).children @@ -166,5 +1172,175 @@ function buildContext(node: AnyNode, nodes: Record): GeometryCo } } - return { resolve, children, siblings, parent } + return { + resolve, + children, + siblings, + parent, + viewState: viewState.palette + ? { + selected: viewState.selected, + highlighted: viewState.highlighted, + hovered: viewState.hovered, + moving: viewState.moving, + palette: viewState.palette, + } + : undefined, + } +} + +/** + * Stable id for a handle on a node, derived from the node id + opaque + * payload. Used to track hover / active visual state when multiple + * handles belong to the same node (start vs end endpoint, multiple + * vertices of a polygon, etc.). + */ +function makeHandleId(nodeId: AnyNodeId, payload: unknown): string { + if (payload == null) return `${nodeId}` + if (typeof payload === 'object') { + // Stable JSON serialisation of common shapes — endpoint discriminator, + // vertex index, etc. Don't try to handle arbitrarily-deep payloads. + try { + return `${nodeId}:${JSON.stringify(payload)}` + } catch { + return `${nodeId}` + } + } + return `${nodeId}:${String(payload)}` +} + +/** + * Geometry kinds that always render in the overlay pass — interactive + * handles and node labels. These need to sit above every kind's base + * geometry regardless of the owning node's z-bucket so that: + * - polygon edit handles on a selected slab don't get hidden by the + * walls / items resting on top of the slab, + * - a zone's name stays legible above the slab covering the zone, and + * - measurement labels never get clipped by structural fills. + */ +const OVERLAY_KINDS = new Set([ + 'text', + 'endpoint-handle', + 'midpoint-handle', + 'edge-handle', + 'move-handle', + 'dimension', + 'dimension-label', +]) + +/** + * Walk a `FloorplanGeometry` tree and split it into two trees: one with + * only "base" primitives (polygons, paths, fills, strokes) and one with + * only "overlay" primitives (handles, labels — see `OVERLAY_KINDS`). + * + * Groups recurse: a `kind: 'group'` is split into a base group and an + * overlay group, both carrying the same `transform` so nested rotations + * / translations apply in both passes. Empty groups collapse to `null` + * so the caller can skip emitting an `` when there's nothing to draw. + */ +function splitFloorplanOverlay(g: FloorplanGeometry): { + base: FloorplanGeometry | null + overlay: FloorplanGeometry | null +} { + if (OVERLAY_KINDS.has(g.kind)) { + return { base: null, overlay: g } + } + if (g.kind === 'group') { + const baseChildren: FloorplanGeometry[] = [] + const overlayChildren: FloorplanGeometry[] = [] + for (const child of g.children) { + const split = splitFloorplanOverlay(child) + if (split.base) baseChildren.push(split.base) + if (split.overlay) overlayChildren.push(split.overlay) + } + const base: FloorplanGeometry | null = + baseChildren.length > 0 + ? { kind: 'group', children: baseChildren, transform: g.transform } + : null + const overlay: FloorplanGeometry | null = + overlayChildren.length > 0 + ? { kind: 'group', children: overlayChildren, transform: g.transform } + : null + return { base, overlay } + } + return { base: g, overlay: null } +} + +/** + * Z-order bucket for floor-plan rendering. Lower rank = painted first = + * sits under everything with a higher rank. SVG renders in document + * order, so an earlier entry in the array ends up beneath a later one. + * + * Three buckets today: + * 0 — `zone`: conceptual area regions, always under everything else. + * 1 — `slab` / `ceiling`: the floor / ceiling surface; sits over the + * zone but under any structural / furniture geometry placed on it. + * 2 — every other kind (walls, items, shelves, columns, stairs, …): + * structure + furniture, painted on top. + * + * Sort is stable in modern JS engines, so siblings within the same + * bucket keep their DFS order (= scene tree order). + */ +function floorplanLayerRank(type: string): number { + switch (type) { + case 'zone': + return 0 + case 'slab': + case 'ceiling': + return 1 + default: + return 2 + } +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false + } + return true + } + if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) { + const aKeys = Object.keys(a as Record) + const bKeys = Object.keys(b as Record) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!deepEqual((a as Record)[key], (b as Record)[key])) { + return false + } + } + return true + } + return false +} + +function formatGroupTransform(t?: { + translate?: readonly [number, number] + rotate?: number +}): string | undefined { + if (!t) return undefined + const parts: string[] = [] + if (t.translate) parts.push(`translate(${t.translate[0]} ${t.translate[1]})`) + if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`) + return parts.length > 0 ? parts.join(' ') : undefined +} + +function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null { + // The registry layer lives under the floor-plan scene ``. The + // legacy panel computes the same conversion via floorplanSceneRef + + // getScreenCTM; we replicate it by walking up to the SVG owner. + const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null + const svg = target?.ownerSVGElement + if (!(svg && target)) return null + const ctm = target.getScreenCTM() + if (!ctm) return null + const point = svg.createSVGPoint() + point.x = clientX + point.y = clientY + const transformed = point.matrixTransform(ctm.inverse()) + // The floor-plan `` maps plan X/Z directly to SVG x/y (Z stored as + // the Y axis on screen — same convention as `toSvgPlanPoint`). + return [transformed.x, transformed.y] }