From 76794ceb7d0cfbc1dd39ae10d8cf711643665177 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 14 May 2026 14:55:03 -0400 Subject: [PATCH] Wire shelf + spawn placement polish: SFX, cursor, sidebar, selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-visible follow-ups after first running the Phase 2 spike. Spawn tool now matches legacy UX: - CursorSphere from @pascal-app/editor for the placement indicator (ring + line + tool-icon tooltip) — was a plain sphere mesh. - Emits sfx:structure-build on commit + setTool(null) + setMode ('select') to exit build mode, matching legacy spawn-tool. Shelf tool placement: - Emits sfx:structure-build on commit. - Cursor preview now shows top board + brackets (was just the top), matching what gets placed. Shelf selectable from the 3D canvas: - ShelfEvent type added to @pascal-app/core/events/bus. - 'shelf' added to NodeConfig in useNodeEvents. - ShelfRenderer wires `useNodeEvents(node, 'shelf')` handlers onto every mesh. Clicks/hovers now bubble through the editor's selection manager and update useViewer.selection. Shelf appears in the sidebar: - ShelfTreeNode component (mirrors spawn-tree-node's shape + selection/hover/rename wiring; lucide Layers icon). - TreeNode dispatcher adds a `case 'shelf':` arm. Framework changes: - @pascal-app/editor exports CursorSphere alongside triggerSFX. - @pascal-app/nodes now declares @pascal-app/editor as peer/dev dep. Pre-existing typecheck errors in @pascal-app/editor (ceiling-tree-node, fence-tree-node, slab-tree-node, scene.ts) are unchanged — present on main and not introduced by this commit. 630 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/events/bus.ts | 3 + packages/core/src/index.ts | 1 + .../panels/site-panel/shelf-tree-node.tsx | 80 +++++++++++++++++++ .../sidebar/panels/site-panel/tree-node.tsx | 3 + packages/editor/src/index.tsx | 1 + packages/nodes/package.json | 2 + packages/nodes/src/shelf/renderer.tsx | 17 ++-- packages/nodes/src/shelf/tool.tsx | 45 ++++++++--- packages/nodes/src/spawn/tool.tsx | 62 +++++--------- packages/viewer/src/hooks/use-node-events.ts | 3 + 10 files changed, 154 insertions(+), 63 deletions(-) create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 02fcf1e6..75ca575d 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -13,6 +13,7 @@ import type { LevelNode, RoofNode, RoofSegmentNode, + ShelfNode, SiteNode, SlabNode, SpawnNode, @@ -57,6 +58,7 @@ export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent export type LevelEvent = NodeEvent export type ZoneEvent = NodeEvent +export type ShelfEvent = NodeEvent export type SlabEvent = NodeEvent export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent @@ -189,6 +191,7 @@ type EditorEvents = GridEvents & NodeEvents<'level', LevelEvent> & NodeEvents<'zone', ZoneEvent> & NodeEvents<'slab', SlabEvent> & + NodeEvents<'shelf', ShelfEvent> & NodeEvents<'spawn', SpawnEvent> & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'column', ColumnEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f3c73ea7..34c288d1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export type { NodeEvent, RoofEvent, RoofSegmentEvent, + ShelfEvent, SiteEvent, SlabEvent, SpawnEvent, diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx new file mode 100644 index 00000000..6196d0ce --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx @@ -0,0 +1,80 @@ +'use client' + +import { type ShelfNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Layers } from 'lucide-react' +import { memo, useCallback, useState } from 'react' +import useEditor from './../../../../../store/use-editor' +import { InlineRenameInput } from './inline-rename-input' +import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' +import { TreeNodeActions } from './tree-node-actions' + +interface ShelfTreeNodeProps { + nodeId: ShelfNode['id'] + depth: number + isLast?: boolean +} + +/** + * Sidebar tree entry for shelf. Mirrors spawn-tree-node's shape so the + * existing tree-node-wrapper / selection / hover / rename plumbing all work + * unchanged. Phase 4 derives this row generically from + * `definition.presentation` — until then, one file per kind. + */ +export const ShelfTreeNode = memo(function ShelfTreeNode({ + nodeId, + depth, + isLast, +}: ShelfTreeNodeProps) { + const [isEditing, setIsEditing] = useState(false) + const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) + const isHovered = useViewer((state) => state.hoveredId === nodeId) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const handled = handleTreeSelection( + e, + nodeId, + useViewer.getState().selection.selectedIds, + setSelection, + ) + if (!handled && useEditor.getState().phase === 'furnish') { + useEditor.getState().setPhase('structure') + } + }, + [nodeId, setSelection], + ) + + return ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={} + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 2a310749..a8fe186e 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -63,6 +63,7 @@ import { FenceTreeNode } from './fence-tree-node' import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' import { RoofTreeNode } from './roof-tree-node' +import { ShelfTreeNode } from './shelf-tree-node' import { SlabTreeNode } from './slab-tree-node' import { SpawnTreeNode } from './spawn-tree-node' import { StairTreeNode } from './stair-tree-node' @@ -94,6 +95,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr return case 'level': return + case 'shelf': + return case 'slab': return case 'spawn': diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index baffb3c7..2586e7aa 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -4,6 +4,7 @@ export { type SnapshotCameraData, ThumbnailGenerator, } from './components/editor/thumbnail-generator' +export { CursorSphere } from './components/tools/shared/cursor-sphere' export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions' export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' export { useCommandPalette } from './components/ui/command-palette' diff --git a/packages/nodes/package.json b/packages/nodes/package.json index 01b7c66a..dbd4d42a 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -24,6 +24,7 @@ }, "peerDependencies": { "@pascal-app/core": "^0.8.0", + "@pascal-app/editor": "^0.8.0", "@pascal-app/viewer": "^0.8.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", @@ -32,6 +33,7 @@ }, "devDependencies": { "@pascal-app/core": "^0.8.0", + "@pascal-app/editor": "^0.8.0", "@pascal-app/viewer": "^0.8.0", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", diff --git a/packages/nodes/src/shelf/renderer.tsx b/packages/nodes/src/shelf/renderer.tsx index 773090d9..7083a5cb 100644 --- a/packages/nodes/src/shelf/renderer.tsx +++ b/packages/nodes/src/shelf/renderer.tsx @@ -1,15 +1,11 @@ 'use client' import { useLiveTransforms, useRegistry } from '@pascal-app/core' +import { useNodeEvents } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import { Color, type Group } from 'three' import type { ShelfNode } from './schema' -// Note: useNodeEvents from @pascal-app/viewer has a hardcoded kind list and -// doesn't yet know about 'shelf'. Phase 4 generalizes it via the registry — -// until then, shelf selection works via R3F's default raycast (clicks bubble -// through; the editor's selection manager hit-tests the registered Object3D). - /** * Registry-driven shelf renderer. Renders top board + brackets as inline R3F * primitives so React owns the scene graph end-to-end — no imperative @@ -19,9 +15,14 @@ import type { ShelfNode } from './schema' * shape outside of React (used by tests + reachable by AI-authored consumers * that want a Three.js Group). Keeping both costs nothing because the shape * primitives are tiny. + * + * `useNodeEvents(node, 'shelf')` wires pointer events on each mesh into the + * editor's emitter — the selection manager subscribes to `shelf:click` etc. + * and updates `useViewer.selection`. Required for selection from the canvas. */ const ShelfRenderer = ({ node }: { node: ShelfNode }) => { const ref = useRef(null!) + const handlers = useNodeEvents(node, 'shelf') const liveTransform = useLiveTransforms((state) => state.get(node.id)) useRegistry(node.id, 'shelf', ref) @@ -29,7 +30,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => { const color = useMemo(() => new Color(node.color), [node.color]) const topY = node.height + node.thickness / 2 - // Bracket dimensions mirror buildShelfGeometry — keep these in sync if the + // Bracket dimensions mirror buildShelfGeometry — keep in sync if the // geometry function evolves. Phase 4 may consolidate. const inset = Math.min(0.12, node.width / 6) const bracketHeight = Math.max(0.01, node.height) @@ -52,7 +53,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => { visible={node.visible} > {/* Top board */} - + @@ -63,6 +64,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => { @@ -70,6 +72,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => { diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index 48934624..f79bcf8e 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -8,6 +8,7 @@ import { snapPointToGrid, useScene, } from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { type Group, Vector3 } from 'three' @@ -16,13 +17,11 @@ const worldVector = new Vector3() const GRID_STEP = 0.5 /** - * Convert a click event into the shelf's commit position (level-local). The - * shelf node's `position` field is stored relative to its level parent, so - * we project the click point into the level's local frame before storing. + * Convert a click into the shelf's commit position (level-local). The shelf + * node's `position` field is stored relative to its level parent, so we + * project the click point into the level's local frame before storing. * - * Different from the cursor preview path: the cursor lives inside the - * ToolManager's building-local group and snaps to `event.localPosition` - * directly. This conversion only applies to the *committed* data. + * Cursor display uses event.localPosition (building-local) — see onGridMove. */ function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] { const levelObject = sceneRegistry.nodes.get(levelId) @@ -37,6 +36,17 @@ 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) @@ -45,10 +55,6 @@ const ShelfTool = () => { if (!activeLevelId) return const onGridMove = (event: GridEvent) => { - // Cursor lives in the ToolManager's building-local group. Use - // `event.localPosition` (already building-local) so the visual cursor - // sits where the mouse hits the floor. Legacy spawn-tool does the - // same — don't apply worldToLocal here. const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) cursorRef.current?.position.set(sx, event.localPosition[1], sz) } @@ -62,6 +68,7 @@ const ShelfTool = () => { }) useScene.getState().createNode(shelf, activeLevelId) useViewer.getState().setSelection({ selectedIds: [shelf.id] }) + triggerSFX('sfx:structure-build') // biome-ignore lint/suspicious/noConsole: dev-only verification log console.info('[shelf] placed', shelf.id, 'level-local', position, 'parent', activeLevelId) } @@ -77,10 +84,24 @@ 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. return ( - - + {/* Top board */} + + + + + {/* Left bracket */} + + + + + {/* Right bracket */} + + diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index 8fcd5fa9..e441750b 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -1,26 +1,11 @@ 'use client' import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core' +import { CursorSphere, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { type Group, Vector3 } from 'three' -/** - * Registry-driven spawn placement tool. No props — reads `activeLevelId` from - * `useViewer` directly and broadcasts placement events through the store. - * - * Behavior parity with the legacy tool in - * `@pascal-app/editor/components/tools/spawn/spawn-tool.tsx`: - * - Grid-snap to half-meter increments on X/Z - * - Project click position into the active level's local frame - * - Singleton: if a spawn already exists for this level, reuse it and clean - * up any duplicates - * - On commit: select the placed spawn and exit build mode - * - * Mounted by `ToolManager`'s registry-first dispatch (Phase 0 shim) when - * `nodeRegistry.has('spawn')` and the active tool is 'spawn'. - */ - const roundToHalf = (value: number) => Math.round(value * 2) / 2 const worldVector = new Vector3() @@ -47,22 +32,26 @@ function getLevelLocalPosition(levelId: string, event: GridEvent): [number, numb return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] } +/** + * Registry-driven spawn placement tool. Reads `activeLevelId` from useViewer + * directly (no props), broadcasts placement via store updates + SFX, and + * uses the shared CursorSphere from @pascal-app/editor for visual parity + * with legacy placement tools. + */ const SpawnTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) - const [, setCursor] = useState<[number, number, number] | null>(null) const cursorRef = useRef(null) useEffect(() => { if (!activeLevelId) return const onGridMove = (event: GridEvent) => { - const next: [number, number, number] = [ - roundToHalf(event.localPosition[0]), - event.localPosition[1], - roundToHalf(event.localPosition[2]), - ] - setCursor(next) - cursorRef.current?.position.set(next[0], next[1], next[2]) + // Cursor lives in the ToolManager's building-local group. Use + // event.localPosition directly (already building-local) with the + // same half-meter snap the legacy tool uses. + const nextX = roundToHalf(event.localPosition[0]) + const nextZ = roundToHalf(event.localPosition[2]) + cursorRef.current?.position.set(nextX, event.localPosition[1], nextZ) } const onGridClick = (event: GridEvent) => { @@ -91,11 +80,9 @@ const SpawnTool = () => { } useViewer.getState().setSelection({ selectedIds: [placedId] }) - // Note: legacy tool also emits sfx:structure-build and resets the editor - // tool/mode. We rely on the legacy ToolManager to do the latter via the - // build-tool exit path; this commit doesn't replicate the SFX since the - // registry doesn't yet bridge to the editor's sfx-emitter. Phase 4's - // command surface adds a clean path. + triggerSFX('sfx:structure-build') + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') } emitter.on('grid:move', onGridMove) @@ -109,20 +96,7 @@ const SpawnTool = () => { if (!activeLevelId) return null - // Visible marker for the cursor — using a simple group + box. The legacy - // tool used a CursorSphere component from @pascal-app/editor; here we keep - // the dependency arrow flowing nodes→editor (which is allowed by the layer - // rules) but use a minimal inline mesh to avoid the dependency entirely for - // the spike. Phase 4 ports CursorSphere to the editor framework so node - // tools can reuse it. - return ( - - - - - - - ) + return } export default SpawnTool diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index e1e10503..9803cbb2 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -21,6 +21,8 @@ import { type RoofNode, type RoofSegmentEvent, type RoofSegmentNode, + type ShelfEvent, + type ShelfNode, type SiteEvent, type SiteNode, type SlabEvent, @@ -49,6 +51,7 @@ type NodeConfig = { building: { node: BuildingNode; event: BuildingEvent } level: { node: LevelNode; event: LevelEvent } zone: { node: ZoneNode; event: ZoneEvent } + shelf: { node: ShelfNode; event: ShelfEvent } slab: { node: SlabNode; event: SlabEvent } spawn: { node: SpawnNode; event: SpawnEvent } ceiling: { node: CeilingNode; event: CeilingEvent }