diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index e4419937..ff1e7268 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -54,5 +54,6 @@ export type { SurfaceQuery, SurfacesConfig, SystemContribution, + ToolHint, Vec2, } from './types' diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index bcf84039..25051af0 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -47,6 +47,20 @@ export type FloorplanStyle = { opacity?: number } +// ─── ToolHint ──────────────────────────────────────────────────────── +// +// A single key + label entry in the contextual shortcut hint panel. +// `HelperManager` consults `def.toolHints` when the active tool matches +// a registered kind; matches the existing per-tool helper components +// today (e.g. WallHelper renders three of these entries). + +export type ToolHint = { + /** Key combo or input label, e.g. 'Left click', 'Shift', 'Esc'. */ + key: string + /** Description of what the input does. Sentence case. */ + label: string +} + export type FloorplanGeometry = | ({ kind: 'path'; d: string } & FloorplanStyle) | ({ kind: 'polygon'; points: readonly FloorplanPoint[] } & FloorplanStyle) @@ -146,6 +160,17 @@ export type NodeDefinition> = { system?: SystemContribution tool?: LazyComponent affordances?: Affordance>[] + /** + * Contextual shortcut hints shown by `HelperManager` when this kind's + * tool is active. Pure data — `HelperManager` renders these via a + * generic . Drops the need for a hand-written + * `` component per kind. + * + * Static array for now (covers ~all current uses). If a kind needs + * state-dependent hints (e.g. different keys during a drag), it keeps + * its bespoke helper component instead. + */ + toolHints?: ToolHint[] /** * Optional translucent preview of the node — used by the move tool to 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 02cd7e42..cc99e06a 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 @@ -9,30 +9,133 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { memo, useMemo } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' /** * Registry-driven floor-plan layer. * - * Iterates registered kinds with `def.floorplan`, finds the matching nodes - * in the active level, calls each kind's builder, and emits the resulting - * SVG via ``. Coexists with the legacy - * `floorplan-panel.tsx` inline rendering — the panel's hand-written - * dispatch keeps running for unmigrated kinds, this layer adds the - * registry-driven path for kinds that opt in via `def.floorplan`. + * 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: * - * Phase 5 batch migration: as each kind ports its `floorplan` field, its - * inline rendering inside `floorplan-panel.tsx` becomes redundant and - * gets deleted in the same PR. + * - **Click → select**. Sets `useViewer.selection.selectedIds = [id]`. + * - **Drag → move**. Pure imperative translation via the wrapping ``'s + * transform attribute during drag; one `updateNode(id, { position })` + * call on pointerup. Same pattern as `MoveRegistryNodeTool` (the + * validated "smooth move" from Phase 2/3): no per-tick store update, + * no re-render storm, no zundo bloat. Only the dragged node mutates. * - * Coordinates are level-local meters; the parent SVG handles the - * world→pixel transform via its viewBox. + * 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. */ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const levelId = useViewer((s) => s.selection.levelId) + const selectedIds = useViewer((s) => s.selection.selectedIds) + const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) + // Drag state — tracks the active pointer drag across global pointermove + // / pointerup so the pointer can leave the dragged element without + // breaking the gesture. Imperative DOM updates avoid React re-renders; + // store update happens once on commit. + const dragRef = useRef<{ + id: AnyNodeId + pointerId: number + startSvgX: number + startSvgY: number + originalPosition: [number, number, number] + element: SVGGElement + moved: boolean + } | null>(null) + + const handlePointerDown = useCallback( + (id: AnyNodeId, event: React.PointerEvent) => { + if (event.button !== 0) return + event.stopPropagation() + + const node = useScene.getState().nodes[id] + if (!node || typeof (node as { position?: unknown }).position === 'undefined') return + const position = (node as unknown as { position: [number, number, number] }).position + if (!Array.isArray(position) || position.length < 3) return + + const svg = event.currentTarget.ownerSVGElement + if (!svg) return + const pt = svgPoint(svg, event.clientX, event.clientY) + + setSelection({ selectedIds: [id] }) + + dragRef.current = { + id, + pointerId: event.pointerId, + startSvgX: pt.x, + startSvgY: pt.y, + originalPosition: [position[0], position[1], position[2]], + element: event.currentTarget, + moved: false, + } + // Pause undo while we drag so the commit on pointerup lands as a + // single history step. Resume in the pointerup handler. + useScene.temporal.getState().pause() + }, + [setSelection], + ) + + // Global pointermove / pointerup so the drag survives the cursor + // leaving the entry's bounding box. + useEffect(() => { + const onMove = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + const svg = drag.element.ownerSVGElement + if (!svg) return + const pt = svgPoint(svg, event.clientX, event.clientY) + const dx = pt.x - drag.startSvgX + const dy = pt.y - drag.startSvgY + if (!drag.moved && (dx !== 0 || dy !== 0)) drag.moved = true + drag.element.setAttribute('transform', `translate(${dx} ${dy})`) + } + + const onUp = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + + // Clear the imperative override before committing; the store update + // will re-render the entry with the new position baked in via the + // builder, so the temporary transform is no longer needed. + drag.element.removeAttribute('transform') + + if (drag.moved) { + const svg = drag.element.ownerSVGElement + if (svg) { + const pt = svgPoint(svg, event.clientX, event.clientY) + const dx = pt.x - drag.startSvgX + const dy = pt.y - drag.startSvgY + const [ox, oy, oz] = drag.originalPosition + useScene + .getState() + .updateNode(drag.id, { position: [ox + dx, oy, oz + dy] } as Partial) + } + } + + useScene.temporal.getState().resume() + dragRef.current = null + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + return () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + } + }, []) + const entries = useMemo(() => { if (!levelId) return [] const out: { @@ -41,10 +144,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { geometry: FloorplanGeometry }[] = [] - // Walk the level's subtree once. Most kinds live as direct or indirect - // children of the level node. For shelf today the parent is the level; - // future container kinds (slab, ceiling, wall hosting items) will - // require nested traversal — handled by the same walk below. const visit = (id: AnyNodeId) => { const node = nodes[id] if (!node) return @@ -52,10 +151,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const builder = def?.floorplan if (builder) { const ctx = buildContext(node, nodes) - // Builder is typed against the kind's specific node; at dispatch - // level we lose that refinement. Cast contained here. - const geometry = (builder as (n: AnyNode, c: GeometryContext) => unknown)(node, ctx) - if (geometry) out.push({ id, node, geometry: geometry as never }) + const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)( + node, + ctx, + ) + if (geometry) out.push({ id, node, geometry }) } const childIds = (node as unknown as { children?: AnyNodeId[] }).children if (Array.isArray(childIds)) { @@ -70,14 +170,54 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (entries.length === 0) return null return ( - - {entries.map(({ id, geometry }) => - geometry ? : null, - )} + + {entries.map(({ id, geometry }) => { + const isSelected = selectedIds.includes(id) + return ( + handlePointerDown(id, e)} + style={{ cursor: 'grab' }} + > + + {isSelected && } + + ) + })} ) }) +function SelectionOutline({ geometry }: { geometry: FloorplanGeometry }) { + return ( + + + + ) +} + +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) } + } + return { ...g, ...accent } +} + +function svgPoint(svg: SVGSVGElement, clientX: number, clientY: number): { x: number; y: number } { + const pt = svg.createSVGPoint() + pt.x = clientX + pt.y = clientY + const ctm = svg.getScreenCTM() + if (!ctm) return { x: 0, y: 0 } + const transformed = pt.matrixTransform(ctm.inverse()) + return { x: transformed.x, y: transformed.y } +} + function buildContext(node: AnyNode, nodes: Record): GeometryContext { const resolve = (id: AnyNodeId): N | undefined => nodes[id] as N | undefined diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 7998e427..7625b97b 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -1,10 +1,12 @@ 'use client' +import { nodeRegistry } from '@pascal-app/core' import { useIsMobile } from '../../../hooks/use-mobile' import useEditor from '../../../store/use-editor' import { BuildingHelper } from './building-helper' import { CeilingHelper } from './ceiling-helper' import { ItemHelper } from './item-helper' +import { RegisteredToolHelper } from './registered-tool-helper' import { RoofHelper } from './roof-helper' import { SlabHelper } from './slab-helper' import { WallHelper } from './wall-helper' @@ -27,6 +29,17 @@ export function HelperManager() { return null } + // Registry-first: if the active tool matches a registered kind whose + // definition supplies `toolHints`, render via the generic helper. + // Otherwise fall through to the hand-written per-tool helpers below. + // Legacy helpers get deleted as their kind migrates `toolHints` in. + if (tool) { + const def = nodeRegistry.get(tool) + if (def?.toolHints && def.toolHints.length > 0) { + return + } + } + // Show appropriate helper based on current tool switch (tool) { case 'wall': diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx new file mode 100644 index 00000000..cc48a3fb --- /dev/null +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -0,0 +1,25 @@ +import type { ToolHint } from '@pascal-app/core' +import { ShortcutToken } from '../primitives/shortcut-token' + +/** + * Generic helper panel rendered from `def.toolHints` data. Matches the + * visual styling of the hand-written `` / `` / + * etc. so registry-driven kinds get a consistent look without each kind + * writing its own component. + * + * Drops the need for per-kind helper files entirely — kinds declare + * their hints as static data in their `NodeDefinition`. + */ +export function RegisteredToolHelper({ hints }: { hints: ToolHint[] }) { + if (hints.length === 0) return null + return ( +
+ {hints.map((hint) => ( +
+ + {hint.label} +
+ ))} +
+ ) +} diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index 427b976e..be1fb1e3 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -56,6 +56,10 @@ export const shelfDefinition: NodeDefinition = { preview: () => import('./preview'), tool: () => import('./tool'), + toolHints: [ + { key: 'Left click', label: 'Place shelf' }, + { key: 'Esc', label: 'Cancel' }, + ], presentation: { label: 'Shelf', diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index a30d4ba7..17982020 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -34,7 +34,17 @@ export const spawnDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + // `floorplan: buildSpawnFloorplan` deferred — spawn already renders in + // the legacy floorplan-panel.tsx via `floorplanSpawnEntries`. Adding it + // here would double-render. The pure builder lives in + // ./floorplan.ts ready to wire when the legacy inline branch is + // removed (Phase 5 spawn-floorplan migration PR — same shape as the + // wall feature flag, but per kind in the legacy panel itself). tool: () => import('./tool'), + toolHints: [ + { key: 'Left click', label: 'Place spawn point' }, + { key: 'Esc', label: 'Cancel' }, + ], presentation: { label: 'Spawn Point', diff --git a/packages/nodes/src/spawn/floorplan.ts b/packages/nodes/src/spawn/floorplan.ts new file mode 100644 index 00000000..4e219462 --- /dev/null +++ b/packages/nodes/src/spawn/floorplan.ts @@ -0,0 +1,48 @@ +import type { FloorplanGeometry } from '@pascal-app/core' +import type { SpawnNode } from './schema' + +/** + * 2D floor-plan marker for a spawn point. A small filled circle at the + * spawn's position, with a triangular arrow indicating the facing + * direction (rotation around Y, looking down at the X-Z plane). + * + * Color matches the 3D renderer's `SPAWN_COLOR = '#22c55e'` so the user + * sees the same visual identity in both views. + * + * Coordinates are level-local meters; rotation is radians. + */ +export function buildSpawnFloorplan(node: SpawnNode): FloorplanGeometry { + const [px, , pz] = node.position + const ry = node.rotation + + return { + kind: 'group', + transform: { translate: [px, pz], rotate: ry }, + children: [ + // Direction-pointing triangle, base centered at origin, tip in -Z + // (forward). Matches the 3D arrow's orientation. + { + kind: 'polygon', + points: [ + [0, -0.28], + [-0.18, 0.12], + [0.18, 0.12], + ], + fill: '#22c55e', + opacity: 0.85, + }, + // Spawn body marker — circle outline so the spawn is legible at + // small zoom levels where the triangle would shrink past visibility. + { + kind: 'circle', + cx: 0, + cy: 0, + r: 0.34, + stroke: '#22c55e', + strokeWidth: 0.025, + fill: '#22c55e', + opacity: 0.18, + }, + ], + } +}