Wire shelf + spawn placement polish: SFX, cursor, sidebar, selection
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a89a1efccf
commit
76794ceb7d
@@ -13,6 +13,7 @@ import type {
|
|||||||
LevelNode,
|
LevelNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
|
ShelfNode,
|
||||||
SiteNode,
|
SiteNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
SpawnNode,
|
SpawnNode,
|
||||||
@@ -57,6 +58,7 @@ export type SiteEvent = NodeEvent<SiteNode>
|
|||||||
export type BuildingEvent = NodeEvent<BuildingNode>
|
export type BuildingEvent = NodeEvent<BuildingNode>
|
||||||
export type LevelEvent = NodeEvent<LevelNode>
|
export type LevelEvent = NodeEvent<LevelNode>
|
||||||
export type ZoneEvent = NodeEvent<ZoneNode>
|
export type ZoneEvent = NodeEvent<ZoneNode>
|
||||||
|
export type ShelfEvent = NodeEvent<ShelfNode>
|
||||||
export type SlabEvent = NodeEvent<SlabNode>
|
export type SlabEvent = NodeEvent<SlabNode>
|
||||||
export type SpawnEvent = NodeEvent<SpawnNode>
|
export type SpawnEvent = NodeEvent<SpawnNode>
|
||||||
export type CeilingEvent = NodeEvent<CeilingNode>
|
export type CeilingEvent = NodeEvent<CeilingNode>
|
||||||
@@ -189,6 +191,7 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'level', LevelEvent> &
|
NodeEvents<'level', LevelEvent> &
|
||||||
NodeEvents<'zone', ZoneEvent> &
|
NodeEvents<'zone', ZoneEvent> &
|
||||||
NodeEvents<'slab', SlabEvent> &
|
NodeEvents<'slab', SlabEvent> &
|
||||||
|
NodeEvents<'shelf', ShelfEvent> &
|
||||||
NodeEvents<'spawn', SpawnEvent> &
|
NodeEvents<'spawn', SpawnEvent> &
|
||||||
NodeEvents<'ceiling', CeilingEvent> &
|
NodeEvents<'ceiling', CeilingEvent> &
|
||||||
NodeEvents<'column', ColumnEvent> &
|
NodeEvents<'column', ColumnEvent> &
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type {
|
|||||||
NodeEvent,
|
NodeEvent,
|
||||||
RoofEvent,
|
RoofEvent,
|
||||||
RoofSegmentEvent,
|
RoofSegmentEvent,
|
||||||
|
ShelfEvent,
|
||||||
SiteEvent,
|
SiteEvent,
|
||||||
SlabEvent,
|
SlabEvent,
|
||||||
SpawnEvent,
|
SpawnEvent,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<TreeNodeWrapper
|
||||||
|
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||||
|
depth={depth}
|
||||||
|
expanded={false}
|
||||||
|
hasChildren={false}
|
||||||
|
icon={<Layers size={14} />}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isLast={isLast}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isVisible={isVisible}
|
||||||
|
label={
|
||||||
|
<InlineRenameInput
|
||||||
|
defaultName="Shelf"
|
||||||
|
isEditing={isEditing}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onStartEditing={() => setIsEditing(true)}
|
||||||
|
onStopEditing={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||||
|
onMouseEnter={() => setHoveredId(nodeId)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -63,6 +63,7 @@ import { FenceTreeNode } from './fence-tree-node'
|
|||||||
import { ItemTreeNode } from './item-tree-node'
|
import { ItemTreeNode } from './item-tree-node'
|
||||||
import { LevelTreeNode } from './level-tree-node'
|
import { LevelTreeNode } from './level-tree-node'
|
||||||
import { RoofTreeNode } from './roof-tree-node'
|
import { RoofTreeNode } from './roof-tree-node'
|
||||||
|
import { ShelfTreeNode } from './shelf-tree-node'
|
||||||
import { SlabTreeNode } from './slab-tree-node'
|
import { SlabTreeNode } from './slab-tree-node'
|
||||||
import { SpawnTreeNode } from './spawn-tree-node'
|
import { SpawnTreeNode } from './spawn-tree-node'
|
||||||
import { StairTreeNode } from './stair-tree-node'
|
import { StairTreeNode } from './stair-tree-node'
|
||||||
@@ -94,6 +95,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|||||||
return <ElevatorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <ElevatorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'level':
|
case 'level':
|
||||||
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
||||||
|
case 'shelf':
|
||||||
|
return <ShelfTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `shelf_${string}`} />
|
||||||
case 'slab':
|
case 'slab':
|
||||||
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'spawn':
|
case 'spawn':
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export {
|
|||||||
type SnapshotCameraData,
|
type SnapshotCameraData,
|
||||||
ThumbnailGenerator,
|
ThumbnailGenerator,
|
||||||
} from './components/editor/thumbnail-generator'
|
} 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 { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
|
||||||
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
|
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
|
||||||
export { useCommandPalette } from './components/ui/command-palette'
|
export { useCommandPalette } from './components/ui/command-palette'
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@pascal-app/core": "^0.8.0",
|
"@pascal-app/core": "^0.8.0",
|
||||||
|
"@pascal-app/editor": "^0.8.0",
|
||||||
"@pascal-app/viewer": "^0.8.0",
|
"@pascal-app/viewer": "^0.8.0",
|
||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@pascal-app/core": "^0.8.0",
|
"@pascal-app/core": "^0.8.0",
|
||||||
|
"@pascal-app/editor": "^0.8.0",
|
||||||
"@pascal-app/viewer": "^0.8.0",
|
"@pascal-app/viewer": "^0.8.0",
|
||||||
"@pascal/typescript-config": "*",
|
"@pascal/typescript-config": "*",
|
||||||
"@types/bun": "^1.3.0",
|
"@types/bun": "^1.3.0",
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useLiveTransforms, useRegistry } from '@pascal-app/core'
|
import { useLiveTransforms, useRegistry } from '@pascal-app/core'
|
||||||
|
import { useNodeEvents } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { Color, type Group } from 'three'
|
import { Color, type Group } from 'three'
|
||||||
import type { ShelfNode } from './schema'
|
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
|
* Registry-driven shelf renderer. Renders top board + brackets as inline R3F
|
||||||
* primitives so React owns the scene graph end-to-end — no imperative
|
* 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
|
* 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
|
* that want a Three.js Group). Keeping both costs nothing because the shape
|
||||||
* primitives are tiny.
|
* 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 ShelfRenderer = ({ node }: { node: ShelfNode }) => {
|
||||||
const ref = useRef<Group>(null!)
|
const ref = useRef<Group>(null!)
|
||||||
|
const handlers = useNodeEvents(node, 'shelf')
|
||||||
const liveTransform = useLiveTransforms((state) => state.get(node.id))
|
const liveTransform = useLiveTransforms((state) => state.get(node.id))
|
||||||
|
|
||||||
useRegistry(node.id, 'shelf', ref)
|
useRegistry(node.id, 'shelf', ref)
|
||||||
@@ -29,7 +30,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
|
|||||||
const color = useMemo(() => new Color(node.color), [node.color])
|
const color = useMemo(() => new Color(node.color), [node.color])
|
||||||
const topY = node.height + node.thickness / 2
|
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.
|
// geometry function evolves. Phase 4 may consolidate.
|
||||||
const inset = Math.min(0.12, node.width / 6)
|
const inset = Math.min(0.12, node.width / 6)
|
||||||
const bracketHeight = Math.max(0.01, node.height)
|
const bracketHeight = Math.max(0.01, node.height)
|
||||||
@@ -52,7 +53,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
|
|||||||
visible={node.visible}
|
visible={node.visible}
|
||||||
>
|
>
|
||||||
{/* Top board */}
|
{/* Top board */}
|
||||||
<mesh position={[0, topY, 0]} name="shelf-top">
|
<mesh position={[0, topY, 0]} name="shelf-top" {...handlers}>
|
||||||
<boxGeometry args={[node.width, node.thickness, node.depth]} />
|
<boxGeometry args={[node.width, node.thickness, node.depth]} />
|
||||||
<meshStandardMaterial color={color} roughness={0.65} metalness={0.05} />
|
<meshStandardMaterial color={color} roughness={0.65} metalness={0.05} />
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -63,6 +64,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
|
|||||||
<mesh
|
<mesh
|
||||||
position={[-(node.width / 2 - inset), bracketHeight / 2, 0]}
|
position={[-(node.width / 2 - inset), bracketHeight / 2, 0]}
|
||||||
name="shelf-bracket-left"
|
name="shelf-bracket-left"
|
||||||
|
{...handlers}
|
||||||
>
|
>
|
||||||
<boxGeometry args={[bracketWidth, bracketHeight, bracketDepth]} />
|
<boxGeometry args={[bracketWidth, bracketHeight, bracketDepth]} />
|
||||||
<meshStandardMaterial color={color} roughness={0.65} metalness={0.05} />
|
<meshStandardMaterial color={color} roughness={0.65} metalness={0.05} />
|
||||||
@@ -70,6 +72,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
|
|||||||
<mesh
|
<mesh
|
||||||
position={[node.width / 2 - inset, bracketHeight / 2, 0]}
|
position={[node.width / 2 - inset, bracketHeight / 2, 0]}
|
||||||
name="shelf-bracket-right"
|
name="shelf-bracket-right"
|
||||||
|
{...handlers}
|
||||||
>
|
>
|
||||||
<boxGeometry args={[bracketWidth, bracketHeight, bracketDepth]} />
|
<boxGeometry args={[bracketWidth, bracketHeight, bracketDepth]} />
|
||||||
<meshStandardMaterial color={color} roughness={0.65} metalness={0.05} />
|
<meshStandardMaterial color={color} roughness={0.65} metalness={0.05} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
snapPointToGrid,
|
snapPointToGrid,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { triggerSFX } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { type Group, Vector3 } from 'three'
|
import { type Group, Vector3 } from 'three'
|
||||||
@@ -16,13 +17,11 @@ const worldVector = new Vector3()
|
|||||||
const GRID_STEP = 0.5
|
const GRID_STEP = 0.5
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a click event into the shelf's commit position (level-local). The
|
* Convert a click into the shelf's commit position (level-local). The shelf
|
||||||
* shelf node's `position` field is stored relative to its level parent, so
|
* node's `position` field is stored relative to its level parent, so we
|
||||||
* we project the click point into the level's local frame before storing.
|
* project the click point into the level's local frame before storing.
|
||||||
*
|
*
|
||||||
* Different from the cursor preview path: the cursor lives inside the
|
* Cursor display uses event.localPosition (building-local) — see onGridMove.
|
||||||
* ToolManager's building-local group and snaps to `event.localPosition`
|
|
||||||
* directly. This conversion only applies to the *committed* data.
|
|
||||||
*/
|
*/
|
||||||
function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] {
|
function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] {
|
||||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||||
@@ -37,6 +36,17 @@ function getLevelLocalPosition(levelId: string, event: GridEvent): [number, numb
|
|||||||
return [sx, worldVector.y, sz]
|
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 ShelfTool = () => {
|
||||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||||
const cursorRef = useRef<Group>(null)
|
const cursorRef = useRef<Group>(null)
|
||||||
@@ -45,10 +55,6 @@ const ShelfTool = () => {
|
|||||||
if (!activeLevelId) return
|
if (!activeLevelId) return
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
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)
|
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
|
||||||
cursorRef.current?.position.set(sx, event.localPosition[1], sz)
|
cursorRef.current?.position.set(sx, event.localPosition[1], sz)
|
||||||
}
|
}
|
||||||
@@ -62,6 +68,7 @@ const ShelfTool = () => {
|
|||||||
})
|
})
|
||||||
useScene.getState().createNode(shelf, activeLevelId)
|
useScene.getState().createNode(shelf, activeLevelId)
|
||||||
useViewer.getState().setSelection({ selectedIds: [shelf.id] })
|
useViewer.getState().setSelection({ selectedIds: [shelf.id] })
|
||||||
|
triggerSFX('sfx:structure-build')
|
||||||
// biome-ignore lint/suspicious/noConsole: dev-only verification log
|
// biome-ignore lint/suspicious/noConsole: dev-only verification log
|
||||||
console.info('[shelf] placed', shelf.id, 'level-local', position, 'parent', activeLevelId)
|
console.info('[shelf] placed', shelf.id, 'level-local', position, 'parent', activeLevelId)
|
||||||
}
|
}
|
||||||
@@ -77,10 +84,24 @@ const ShelfTool = () => {
|
|||||||
|
|
||||||
if (!activeLevelId) return null
|
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 (
|
return (
|
||||||
<group ref={cursorRef}>
|
<group ref={cursorRef}>
|
||||||
<mesh position={[0, 0.9, 0]}>
|
{/* Top board */}
|
||||||
<boxGeometry args={[1.2, 0.04, 0.3]} />
|
<mesh position={[0, PREVIEW_HEIGHT + PREVIEW_THICKNESS / 2, 0]}>
|
||||||
|
<boxGeometry args={[PREVIEW_WIDTH, PREVIEW_THICKNESS, PREVIEW_DEPTH]} />
|
||||||
|
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
{/* Left bracket */}
|
||||||
|
<mesh position={[-(PREVIEW_WIDTH / 2 - PREVIEW_INSET), PREVIEW_HEIGHT / 2, 0]}>
|
||||||
|
<boxGeometry args={[PREVIEW_BRACKET_WIDTH, PREVIEW_HEIGHT, PREVIEW_BRACKET_DEPTH]} />
|
||||||
|
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
{/* Right bracket */}
|
||||||
|
<mesh position={[PREVIEW_WIDTH / 2 - PREVIEW_INSET, PREVIEW_HEIGHT / 2, 0]}>
|
||||||
|
<boxGeometry args={[PREVIEW_BRACKET_WIDTH, PREVIEW_HEIGHT, PREVIEW_BRACKET_DEPTH]} />
|
||||||
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
|
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -1,26 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core'
|
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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { type Group, Vector3 } from 'three'
|
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 roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||||
const worldVector = new Vector3()
|
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)]
|
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 SpawnTool = () => {
|
||||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||||
const [, setCursor] = useState<[number, number, number] | null>(null)
|
|
||||||
const cursorRef = useRef<Group>(null)
|
const cursorRef = useRef<Group>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeLevelId) return
|
if (!activeLevelId) return
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
const next: [number, number, number] = [
|
// Cursor lives in the ToolManager's building-local group. Use
|
||||||
roundToHalf(event.localPosition[0]),
|
// event.localPosition directly (already building-local) with the
|
||||||
event.localPosition[1],
|
// same half-meter snap the legacy tool uses.
|
||||||
roundToHalf(event.localPosition[2]),
|
const nextX = roundToHalf(event.localPosition[0])
|
||||||
]
|
const nextZ = roundToHalf(event.localPosition[2])
|
||||||
setCursor(next)
|
cursorRef.current?.position.set(nextX, event.localPosition[1], nextZ)
|
||||||
cursorRef.current?.position.set(next[0], next[1], next[2])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
@@ -91,11 +80,9 @@ const SpawnTool = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||||
// Note: legacy tool also emits sfx:structure-build and resets the editor
|
triggerSFX('sfx:structure-build')
|
||||||
// tool/mode. We rely on the legacy ToolManager to do the latter via the
|
useEditor.getState().setTool(null)
|
||||||
// build-tool exit path; this commit doesn't replicate the SFX since the
|
useEditor.getState().setMode('select')
|
||||||
// registry doesn't yet bridge to the editor's sfx-emitter. Phase 4's
|
|
||||||
// command surface adds a clean path.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
@@ -109,20 +96,7 @@ const SpawnTool = () => {
|
|||||||
|
|
||||||
if (!activeLevelId) return null
|
if (!activeLevelId) return null
|
||||||
|
|
||||||
// Visible marker for the cursor — using a simple group + box. The legacy
|
return <CursorSphere color="#60a5fa" height={2.2} ref={cursorRef} />
|
||||||
// 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 (
|
|
||||||
<group ref={cursorRef}>
|
|
||||||
<mesh position={[0, 1.1, 0]}>
|
|
||||||
<sphereGeometry args={[0.18, 16, 12]} />
|
|
||||||
<meshStandardMaterial color="#60a5fa" transparent opacity={0.6} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SpawnTool
|
export default SpawnTool
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentEvent,
|
type RoofSegmentEvent,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
|
type ShelfEvent,
|
||||||
|
type ShelfNode,
|
||||||
type SiteEvent,
|
type SiteEvent,
|
||||||
type SiteNode,
|
type SiteNode,
|
||||||
type SlabEvent,
|
type SlabEvent,
|
||||||
@@ -49,6 +51,7 @@ type NodeConfig = {
|
|||||||
building: { node: BuildingNode; event: BuildingEvent }
|
building: { node: BuildingNode; event: BuildingEvent }
|
||||||
level: { node: LevelNode; event: LevelEvent }
|
level: { node: LevelNode; event: LevelEvent }
|
||||||
zone: { node: ZoneNode; event: ZoneEvent }
|
zone: { node: ZoneNode; event: ZoneEvent }
|
||||||
|
shelf: { node: ShelfNode; event: ShelfEvent }
|
||||||
slab: { node: SlabNode; event: SlabEvent }
|
slab: { node: SlabNode; event: SlabEvent }
|
||||||
spawn: { node: SpawnNode; event: SpawnEvent }
|
spawn: { node: SpawnNode; event: SpawnEvent }
|
||||||
ceiling: { node: CeilingNode; event: CeilingEvent }
|
ceiling: { node: CeilingNode; event: CeilingEvent }
|
||||||
|
|||||||
Reference in New Issue
Block a user