diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 07738843..4b4ebb97 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -32,6 +32,15 @@ export type NodeDefinition> = { tool?: LazyComponent affordances?: Affordance>[] + /** + * Optional translucent preview of the node — used by the move tool to + * show where the node will land, and by the placement tool's cursor. + * Receives the partially-resolved node (or a default-shaped stub during + * placement before any commit has happened). Phase 4 may merge this with + * the renderer behind an `opacity` prop. + */ + preview?: () => Promise<{ default: ComponentType<{ node: z.infer }> }> + presentation?: Presentation mcp?: McpOverrides } diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index dc8f438a..7c853104 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -12,7 +12,15 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useCallback, useEffect, useState } from 'react' +import { + type ComponentType, + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' @@ -20,21 +28,44 @@ import { CursorSphere } from '../shared/cursor-sphere' const roundToHalf = (value: number) => Math.round(value * 2) / 2 +// Cache lazy preview components keyed by their module loader so React.lazy +// isn't re-invoked across renders. +const previewCache = new WeakMap<() => Promise, ComponentType<{ node: AnyNode }>>() + +function loadPreview(node: AnyNode): ComponentType<{ node: AnyNode }> | null { + const def = nodeRegistry.get(node.type) + if (!def?.preview) return null + const cached = previewCache.get(def.preview) + if (cached) return cached + const Comp = lazy(def.preview as () => Promise<{ default: ComponentType<{ node: AnyNode }> }>) + previewCache.set(def.preview, Comp) + return Comp +} + /** - * Generic move tool for any registry-backed kind. Mirrors MoveColumnTool's - * shape but parses re-creation through `nodeRegistry.get(kind).schema` - * instead of a hardcoded schema reference. Used as the fallback in - * `` for kinds without a bespoke mover. + * Generic move tool for any registry-backed kind. * - * Phase 4 may consolidate this with the per-kind movers if they all - * collapse to the same position+rotation shape — until then they live - * side by side. + * Behavior mirrors MoveColumnTool's shape: + * - Pauses scene history on activation, resumes on commit / cancel / unmount. + * - On each `grid:move`, applies a live transform to the original node so it + * visibly follows the cursor (no second copy of the node). + * - On `grid:click`, commits the position to the scene store. + * - If the kind exposes a `preview` component on its NodeDefinition, render + * it as a translucent ghost at the cursor too — the user sees the shape + * they're moving (better UX than just CursorSphere's line). + * + * Phase 4 may merge the preview slot with the renderer behind an `opacity` + * prop. Until then, defining `preview` on a NodeDefinition gives nice move + * + placement UX for free. */ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { - const initialPosition: [number, number, number] = - 'position' in node && Array.isArray((node as { position?: unknown }).position) - ? ((node as { position: [number, number, number] }).position ?? [0, 0, 0]) - : [0, 0, 0] + const initialPosition: [number, number, number] = useMemo( + () => + 'position' in node && Array.isArray((node as { position?: unknown }).position) + ? ((node as { position: [number, number, number] }).position ?? [0, 0, 0]) + : [0, 0, 0], + [node], + ) const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(initialPosition) const exitMoveMode = useCallback(() => { @@ -67,15 +98,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const nodeId = node.id if (nodeId && useScene.getState().nodes[nodeId]) { - // Existing node — just update its position. committed = true useLiveTransforms.getState().clear(nodeId) useScene.temporal.getState().resume() useScene.getState().updateNode(nodeId, { position } as Partial) } else if (node.parentId) { - // Orphan re-create path — re-parse the node fresh via the kind's - // schema in the registry. Mirrors MoveColumnTool's behavior for - // registry-supplied kinds. const def = nodeRegistry.get(node.type) if (def) { const reparsed = def.schema.parse({ @@ -124,9 +151,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } }, [exitMoveMode, initialPosition, node]) - // Cursor color from the def's presentation if available, else a neutral fallback. - const def = nodeRegistry.get(node.type) - const cursorColor = def?.presentation?.icon.kind === 'iconify' ? '#a78bfa' : '#a78bfa' + const Preview = loadPreview(node) - return + return ( + <> + + {Preview && ( + + + + + + )} + + ) } diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index d586e57b..2cd79116 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -46,6 +46,7 @@ export const shelfDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + preview: () => import('./preview'), tool: () => import('./tool'), presentation: { diff --git a/packages/nodes/src/shelf/preview.tsx b/packages/nodes/src/shelf/preview.tsx new file mode 100644 index 00000000..14d76ed1 --- /dev/null +++ b/packages/nodes/src/shelf/preview.tsx @@ -0,0 +1,50 @@ +'use client' + +import { useMemo } from 'react' +import { Color } from 'three' +import type { ShelfNode } from './schema' + +/** + * Translucent preview of a shelf. Used by: + * - The placement tool's cursor (ShelfTool) — at the cursor position + * - The move tool (MoveRegistryNodeTool) — at the drag target position + * + * Renders the same primitives as the actual ShelfRenderer, but with + * `transparent: true, opacity: 0.5` so the user can see what they're + * placing/moving without it being a hard solid. + */ +const ShelfPreview = ({ node }: { node: ShelfNode }) => { + const color = useMemo(() => new Color(node.color), [node.color]) + const topY = node.height + node.thickness / 2 + + const inset = Math.min(0.12, node.width / 6) + const bracketHeight = Math.max(0.01, node.height) + const bracketWidth = + node.bracketStyle === 'industrial' + ? Math.max(0.04, node.depth * 0.2) + : Math.max(0.02, node.depth * 0.12) + const bracketDepth = node.bracketStyle === 'industrial' ? node.depth * 0.95 : node.depth * 0.7 + + return ( + + + + + + {node.bracketStyle !== 'hidden' && ( + <> + + + + + + + + + + )} + + ) +} + +export default ShelfPreview diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index 769c5045..358bed59 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -10,8 +10,9 @@ import { } from '@pascal-app/core' import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { type Group, Vector3 } from 'three' +import ShelfPreview from './preview' const worldVector = new Vector3() const GRID_STEP = 0.5 @@ -36,22 +37,19 @@ function getLevelLocalPosition(levelId: string, event: GridEvent): [number, numb return [sx, worldVector.y, sz] } -// Cursor preview dimensions — match the shelf's default schema dimensions. -// Once the shelf has user-tunable defaults in the inspector, this can pull -// from the active draft. -const PREVIEW_WIDTH = 1.2 -const PREVIEW_DEPTH = 0.3 -const PREVIEW_THICKNESS = 0.04 -const PREVIEW_HEIGHT = 0.9 -const PREVIEW_INSET = Math.min(0.12, PREVIEW_WIDTH / 6) -const PREVIEW_BRACKET_WIDTH = Math.max(0.02, PREVIEW_DEPTH * 0.12) -const PREVIEW_BRACKET_DEPTH = PREVIEW_DEPTH * 0.7 - const ShelfTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const cursorRef = useRef(null) const previousSnapRef = useRef<[number, number] | null>(null) + // Default-shaped shelf for the placement preview. Same shape the move tool + // uses (both reach for `shelfDefinition.preview`) so placement and move + // look identical. + const previewNode = useMemo( + () => ShelfNode.parse({ name: 'Shelf', position: [0, 0, 0], rotation: [0, 0, 0] }), + [], + ) + useEffect(() => { if (!activeLevelId) return previousSnapRef.current = null @@ -60,8 +58,6 @@ const ShelfTool = () => { const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) cursorRef.current?.position.set(sx, event.localPosition[1], sz) - // Fire grid-snap SFX only when the snapped position crosses a cell, - // matching the wall / slab / curve tools. const prev = previousSnapRef.current if (!prev || prev[0] !== sx || prev[1] !== sz) { triggerSFX('sfx:grid-snap') @@ -94,26 +90,12 @@ const ShelfTool = () => { if (!activeLevelId) return null - // Cursor preview: ghostly version of the full shelf (top board + brackets) - // so the user sees the same shape they're placing. Position is updated - // imperatively via the ref; no React state, no re-render cycles. + // Cursor preview: defers to the shared ShelfPreview component used by the + // move tool too. Position is updated imperatively via the ref; no React + // state, no re-render cycles. return ( - {/* Top board */} - - - - - {/* Left bracket */} - - - - - {/* Right bracket */} - - - - + ) }