* feat(editor): preset placement polish — params, move tool, shadows - wall/slab/ceiling/roof create paths consume `toolDefaults` so template presets build with their saved params; cleared on tool unmount - wall draw preview reflects the preset's height/thickness (+ HUD labels) - box-select picks up registry-selectable kinds (shelf) via bbox - registry + column move tools: snap to the active grid step, R/T rotation, and ignore the stray trailing click that armed the move (no double-place) - shelf geometry casts + receives shadows like fence/slab Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(viewer): expose isIsolationActive() for isolation-aware consumers Tracks whether an isolation filter is currently applied and exposes it so hosts can avoid acting on the partial view — e.g. skipping project-thumbnail autosave while a single subtree is isolated (preset capture). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
173 lines
5.8 KiB
TypeScript
173 lines
5.8 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNodeId,
|
|
type ColumnNode,
|
|
ColumnNode as ColumnNodeSchema,
|
|
emitter,
|
|
type GridEvent,
|
|
sceneRegistry,
|
|
useLiveTransforms,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
|
|
/**
|
|
* Phase 5 Stage D — column's registry-driven 3D move affordance.
|
|
*
|
|
* Replaces the legacy `MoveColumnTool` in `editor/src/components/tools/
|
|
* column/move-column-tool.tsx`. Behaviour is identical: grid:move
|
|
* snaps the cursor to a 0.5m grid and previews the column at that
|
|
* position via `useLiveTransforms` + a direct `sceneRegistry.nodes.get
|
|
* (id).position.set(...)` (the live-drag exception documented in
|
|
* `wiki/architecture/tools.md`); grid:click commits via `useScene.
|
|
* updateNode`. Cancel restores the pre-drag position.
|
|
*
|
|
* Wired via `def.affordanceTools.move`. The editor's `MoveTool`
|
|
* dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup
|
|
* picks this up before its legacy chain reaches `<MoveColumnTool>`.
|
|
*/
|
|
/** Snap to the editor's active grid step (0.5 / 0.25 / 0.1 / 0.05), read live. */
|
|
const snapToGridStep = (value: number) => {
|
|
const step = useEditor.getState().gridSnapStep
|
|
return Math.round(value / step) * step
|
|
}
|
|
|
|
/** 90° steps, matching the GLB item / shelf placement rotation. */
|
|
const ROTATION_STEP = Math.PI / 2
|
|
|
|
function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
|
|
|
const exitMoveMode = useCallback(() => {
|
|
useEditor.getState().setMovingNode(null)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
useScene.temporal.getState().pause()
|
|
let committed = false
|
|
// Ignore a commit before the cursor has moved into place: it's the stray
|
|
// trailing click of whatever armed this move (e.g. a preset re-arming the
|
|
// next copy right after a placement click), not a deliberate drop.
|
|
let hasMoved = false
|
|
// Live Y-rotation, seeded from the column and bumped by R/T.
|
|
let rotationY = node.rotation
|
|
// Latest previewed position, so an R/T press can re-apply at the spot.
|
|
let lastPosition: [number, number, number] = node.position
|
|
const meta =
|
|
typeof node.metadata === 'object' && node.metadata !== null
|
|
? (node.metadata as Record<string, unknown>)
|
|
: {}
|
|
const isNew = !!meta.isNew
|
|
|
|
const applyPreview = (position: [number, number, number]) => {
|
|
lastPosition = position
|
|
setPreviewPosition(position)
|
|
useLiveTransforms.getState().set(node.id, {
|
|
position,
|
|
rotation: rotationY,
|
|
})
|
|
const m = sceneRegistry.nodes.get(node.id)
|
|
if (m) {
|
|
m.position.set(position[0], position[1], position[2])
|
|
m.rotation.y = rotationY
|
|
}
|
|
}
|
|
|
|
const onGridMove = (event: GridEvent) => {
|
|
hasMoved = true
|
|
applyPreview([
|
|
snapToGridStep(event.localPosition[0]),
|
|
0,
|
|
snapToGridStep(event.localPosition[2]),
|
|
])
|
|
}
|
|
|
|
// R / T rotate the dragged column about Y in 90° steps (matches the move
|
|
// HUD's "Rotate" hints), committed on drop.
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return
|
|
let delta = 0
|
|
if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP
|
|
else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
|
|
else return
|
|
e.preventDefault()
|
|
rotationY += delta
|
|
applyPreview(lastPosition)
|
|
}
|
|
|
|
const onGridClick = (event: GridEvent) => {
|
|
if (!hasMoved) return
|
|
const position: [number, number, number] = [
|
|
snapToGridStep(event.localPosition[0]),
|
|
0,
|
|
snapToGridStep(event.localPosition[2]),
|
|
]
|
|
const nodeId = (node as { id?: ColumnNode['id'] }).id
|
|
|
|
if (nodeId && useScene.getState().nodes[nodeId]) {
|
|
committed = true
|
|
useLiveTransforms.getState().clear(nodeId)
|
|
useScene.temporal.getState().resume()
|
|
useScene
|
|
.getState()
|
|
.updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) })
|
|
} else if (node.parentId) {
|
|
const column = ColumnNodeSchema.parse({
|
|
...node,
|
|
id: undefined,
|
|
metadata: {},
|
|
position,
|
|
rotation: rotationY,
|
|
})
|
|
committed = true
|
|
useScene.temporal.getState().resume()
|
|
useScene.getState().createNode(column, node.parentId as AnyNodeId)
|
|
}
|
|
|
|
useLiveTransforms.getState().clear(node.id)
|
|
triggerSFX('sfx:item-place')
|
|
exitMoveMode()
|
|
event.nativeEvent?.stopPropagation?.()
|
|
}
|
|
|
|
const onCancel = () => {
|
|
useLiveTransforms.getState().clear(node.id)
|
|
const m = sceneRegistry.nodes.get(node.id)
|
|
if (m) {
|
|
m.position.set(node.position[0], node.position[1], node.position[2])
|
|
m.rotation.y = node.rotation
|
|
}
|
|
useScene.temporal.getState().resume()
|
|
markToolCancelConsumed()
|
|
exitMoveMode()
|
|
}
|
|
|
|
window.addEventListener('keydown', onKeyDown)
|
|
emitter.on('grid:move', onGridMove)
|
|
emitter.on('grid:click', onGridClick)
|
|
emitter.on('tool:cancel', onCancel)
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', onKeyDown)
|
|
emitter.off('grid:move', onGridMove)
|
|
emitter.off('grid:click', onGridClick)
|
|
emitter.off('tool:cancel', onCancel)
|
|
useLiveTransforms.getState().clear(node.id)
|
|
if (!committed) {
|
|
const m = sceneRegistry.nodes.get(node.id)
|
|
if (m) {
|
|
m.position.set(node.position[0], node.position[1], node.position[2])
|
|
m.rotation.y = node.rotation
|
|
}
|
|
useScene.temporal.getState().resume()
|
|
}
|
|
}
|
|
}, [exitMoveMode, node])
|
|
|
|
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
|
|
}
|
|
|
|
export default MoveColumnTool
|