Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.
Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
(boundary editors, hole editors, placement tools, move tools,
endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`
Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
`roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
`stair-segment/` packages with definition + schema + renderer / system
/ floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
(ceiling, door, item, shelf, slab, window) — single registry-driven
dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
drags (ceiling, fence, slab, wall) — using the shared
`polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
(door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
(door, item, slab, window).
Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
`floorplan-render-context.tsx` updated for the registry layer's new
contract (props, affordance dispatch, render context).
Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
`wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
the registry-only world.
Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.
Wiki: new `plugin-authoring.md` page, README index updated.
Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
450 lines
14 KiB
TypeScript
450 lines
14 KiB
TypeScript
import {
|
|
type AnyNodeId,
|
|
emitter,
|
|
isCurvedWall,
|
|
sceneRegistry,
|
|
spatialGridManager,
|
|
useLiveTransforms,
|
|
useScene,
|
|
type WallEvent,
|
|
WindowNode,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
calculateCursorRotation,
|
|
calculateItemRotation,
|
|
EDITOR_LAYER,
|
|
getSideFromNormal,
|
|
isValidWallSideFace,
|
|
snapToHalf,
|
|
triggerSFX,
|
|
useEditor,
|
|
} from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
|
|
|
const edgeMaterial = new LineBasicNodeMaterial({
|
|
color: 0xef_44_44,
|
|
linewidth: 3,
|
|
depthTest: false,
|
|
depthWrite: false,
|
|
})
|
|
|
|
/**
|
|
* Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.
|
|
*
|
|
* Move mode (metadata.isNew falsy):
|
|
* Adopts the existing window, pauses temporal. On commit: restores original state
|
|
* (clean undo baseline) then resumes + updateNode (undo reverts to original position).
|
|
* On cancel: restores original state.
|
|
*
|
|
* Duplicate mode (metadata.isNew = true):
|
|
* The node is a freshly created transient copy. On commit: deletes transient + resumes
|
|
* + createNode (undo removes the new window entirely). On cancel: deletes the node.
|
|
*/
|
|
const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
|
|
const cursorGroupRef = useRef<Group>(null!)
|
|
|
|
const exitMoveMode = useCallback(() => {
|
|
useEditor.getState().setMovingNode(null)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
useScene.temporal.getState().pause()
|
|
|
|
const meta =
|
|
typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null
|
|
? (movingWindowNode.metadata as Record<string, unknown>)
|
|
: {}
|
|
const isNew = !!meta.isNew
|
|
|
|
// Save original state (only used in move mode)
|
|
const original = {
|
|
position: [...movingWindowNode.position] as [number, number, number],
|
|
rotation: [...movingWindowNode.rotation] as [number, number, number],
|
|
side: movingWindowNode.side,
|
|
parentId: movingWindowNode.parentId,
|
|
wallId: movingWindowNode.wallId,
|
|
metadata: movingWindowNode.metadata,
|
|
}
|
|
|
|
if (!isNew) {
|
|
// Move mode: mark the existing window as transient so it hides while being repositioned
|
|
useScene.getState().updateNode(movingWindowNode.id, {
|
|
metadata: { ...meta, isTransient: true },
|
|
})
|
|
}
|
|
|
|
let currentWallId: string | null = movingWindowNode.parentId
|
|
|
|
const markWallDirty = (wallId: string | null) => {
|
|
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
|
}
|
|
|
|
const getLevelId = () => useViewer.getState().selection.levelId
|
|
const getLevelYOffset = () => {
|
|
const id = getLevelId()
|
|
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
|
}
|
|
const getSlabElevation = (wallEvent: WallEvent) =>
|
|
spatialGridManager.getSlabElevationForWall(
|
|
wallEvent.node.parentId ?? '',
|
|
wallEvent.node.start,
|
|
wallEvent.node.end,
|
|
)
|
|
|
|
const hideCursor = () => {
|
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
|
}
|
|
|
|
const updateCursor = (
|
|
worldPosition: [number, number, number],
|
|
cursorRotationY: number,
|
|
valid: boolean,
|
|
) => {
|
|
const group = cursorGroupRef.current
|
|
if (!group) return
|
|
group.visible = true
|
|
group.position.set(...worldPosition)
|
|
group.rotation.y = cursorRotationY
|
|
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
|
}
|
|
|
|
const onWallEnter = (event: WallEvent) => {
|
|
if (!isValidWallSideFace(event.normal)) return
|
|
if (isCurvedWall(event.node)) {
|
|
hideCursor()
|
|
return
|
|
}
|
|
// Only interact with walls on the current level
|
|
if (event.node.parentId !== getLevelId()) return
|
|
|
|
const side = getSideFromNormal(event.normal)
|
|
const itemRotation = calculateItemRotation(event.normal)
|
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
|
|
const localX = snapToHalf(event.localPosition[0])
|
|
const localY = snapToHalf(event.localPosition[1])
|
|
const { clampedX, clampedY } = clampToWall(
|
|
event.node,
|
|
localX,
|
|
localY,
|
|
movingWindowNode.width,
|
|
movingWindowNode.height,
|
|
)
|
|
|
|
const prevWallId = currentWallId
|
|
currentWallId = event.node.id
|
|
|
|
useScene.getState().updateNode(movingWindowNode.id, {
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
parentId: event.node.id,
|
|
wallId: event.node.id,
|
|
})
|
|
useLiveTransforms.getState().set(movingWindowNode.id, {
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: itemRotation,
|
|
})
|
|
|
|
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
|
markWallDirty(event.node.id)
|
|
|
|
const valid = !hasWallChildOverlap(
|
|
event.node.id,
|
|
clampedX,
|
|
clampedY,
|
|
movingWindowNode.width,
|
|
movingWindowNode.height,
|
|
movingWindowNode.id,
|
|
)
|
|
|
|
updateCursor(
|
|
wallLocalToWorld(
|
|
event.node,
|
|
clampedX,
|
|
clampedY,
|
|
getLevelYOffset(),
|
|
getSlabElevation(event),
|
|
),
|
|
cursorRotation,
|
|
valid,
|
|
)
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onWallMove = (event: WallEvent) => {
|
|
if (!isValidWallSideFace(event.normal)) return
|
|
if (isCurvedWall(event.node)) {
|
|
hideCursor()
|
|
return
|
|
}
|
|
// Only interact with walls on the current level
|
|
if (event.node.parentId !== getLevelId()) return
|
|
|
|
const side = getSideFromNormal(event.normal)
|
|
const itemRotation = calculateItemRotation(event.normal)
|
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
|
|
const localX = snapToHalf(event.localPosition[0])
|
|
const localY = snapToHalf(event.localPosition[1])
|
|
const { clampedX, clampedY } = clampToWall(
|
|
event.node,
|
|
localX,
|
|
localY,
|
|
movingWindowNode.width,
|
|
movingWindowNode.height,
|
|
)
|
|
|
|
if (currentWallId !== event.node.id) {
|
|
// Wall changed mid-move: must updateNode to reparent
|
|
useScene.getState().updateNode(movingWindowNode.id, {
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
parentId: event.node.id,
|
|
wallId: event.node.id,
|
|
})
|
|
markWallDirty(currentWallId)
|
|
currentWallId = event.node.id
|
|
} else {
|
|
// Same wall: update Three.js mesh directly to avoid store churn
|
|
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
|
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
|
if (windowMesh) {
|
|
windowMesh.position.set(clampedX, clampedY, 0)
|
|
windowMesh.rotation.set(0, itemRotation, 0)
|
|
windowMesh.updateMatrixWorld(true)
|
|
}
|
|
}
|
|
useLiveTransforms.getState().set(movingWindowNode.id, {
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: itemRotation,
|
|
})
|
|
markWallDirty(event.node.id)
|
|
|
|
const valid = !hasWallChildOverlap(
|
|
event.node.id,
|
|
clampedX,
|
|
clampedY,
|
|
movingWindowNode.width,
|
|
movingWindowNode.height,
|
|
movingWindowNode.id,
|
|
)
|
|
|
|
updateCursor(
|
|
wallLocalToWorld(
|
|
event.node,
|
|
clampedX,
|
|
clampedY,
|
|
getLevelYOffset(),
|
|
getSlabElevation(event),
|
|
),
|
|
cursorRotation,
|
|
valid,
|
|
)
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onWallClick = (event: WallEvent) => {
|
|
if (!isValidWallSideFace(event.normal)) return
|
|
if (isCurvedWall(event.node)) return
|
|
// Only interact with walls on the current level
|
|
if (event.node.parentId !== getLevelId()) return
|
|
|
|
const side = getSideFromNormal(event.normal)
|
|
const itemRotation = calculateItemRotation(event.normal)
|
|
|
|
const localX = snapToHalf(event.localPosition[0])
|
|
const localY = snapToHalf(event.localPosition[1])
|
|
const { clampedX, clampedY } = clampToWall(
|
|
event.node,
|
|
localX,
|
|
localY,
|
|
movingWindowNode.width,
|
|
movingWindowNode.height,
|
|
)
|
|
|
|
const valid = !hasWallChildOverlap(
|
|
event.node.id,
|
|
clampedX,
|
|
clampedY,
|
|
movingWindowNode.width,
|
|
movingWindowNode.height,
|
|
movingWindowNode.id,
|
|
)
|
|
if (!valid) return
|
|
|
|
let placedId: string
|
|
|
|
if (isNew) {
|
|
// Duplicate mode: delete transient + resume + createNode
|
|
// Undo will remove the newly created node entirely
|
|
useScene.getState().deleteNode(movingWindowNode.id)
|
|
useScene.temporal.getState().resume()
|
|
|
|
const node = WindowNode.parse({
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
wallId: event.node.id,
|
|
parentId: event.node.id,
|
|
width: movingWindowNode.width,
|
|
height: movingWindowNode.height,
|
|
windowType: movingWindowNode.windowType,
|
|
operationState: movingWindowNode.operationState,
|
|
awningDirection: movingWindowNode.awningDirection,
|
|
casementStyle: movingWindowNode.casementStyle,
|
|
hingesSide: movingWindowNode.hingesSide,
|
|
frameThickness: movingWindowNode.frameThickness,
|
|
frameDepth: movingWindowNode.frameDepth,
|
|
columnRatios: movingWindowNode.columnRatios,
|
|
rowRatios: movingWindowNode.rowRatios,
|
|
columnDividerThickness: movingWindowNode.columnDividerThickness,
|
|
rowDividerThickness: movingWindowNode.rowDividerThickness,
|
|
sill: movingWindowNode.sill,
|
|
sillDepth: movingWindowNode.sillDepth,
|
|
sillThickness: movingWindowNode.sillThickness,
|
|
})
|
|
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
|
placedId = node.id
|
|
} else {
|
|
// Move mode: restore original (clean baseline) + resume + updateNode
|
|
// Undo will revert to the original position
|
|
useScene.getState().updateNode(movingWindowNode.id, {
|
|
position: original.position,
|
|
rotation: original.rotation,
|
|
side: original.side,
|
|
parentId: original.parentId,
|
|
wallId: original.wallId,
|
|
metadata: original.metadata,
|
|
})
|
|
useScene.temporal.getState().resume()
|
|
|
|
useScene.getState().updateNode(movingWindowNode.id, {
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
parentId: event.node.id,
|
|
wallId: event.node.id,
|
|
metadata: {},
|
|
})
|
|
|
|
if (original.parentId && original.parentId !== event.node.id) {
|
|
markWallDirty(original.parentId)
|
|
}
|
|
placedId = movingWindowNode.id
|
|
}
|
|
|
|
markWallDirty(event.node.id)
|
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
useScene.temporal.getState().pause()
|
|
|
|
triggerSFX('sfx:item-place')
|
|
hideCursor()
|
|
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
|
exitMoveMode()
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onWallLeave = () => {
|
|
hideCursor()
|
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
if (isNew) return // No original to restore for duplicates
|
|
// Move mode: restore to original position while off-wall
|
|
if (currentWallId && currentWallId !== original.parentId) {
|
|
markWallDirty(currentWallId)
|
|
}
|
|
currentWallId = original.parentId
|
|
useScene.getState().updateNode(movingWindowNode.id, {
|
|
position: original.position,
|
|
rotation: original.rotation,
|
|
side: original.side,
|
|
parentId: original.parentId,
|
|
wallId: original.wallId,
|
|
})
|
|
if (original.parentId) markWallDirty(original.parentId)
|
|
}
|
|
|
|
const onCancel = () => {
|
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
if (isNew) {
|
|
useScene.getState().deleteNode(movingWindowNode.id)
|
|
if (currentWallId) markWallDirty(currentWallId)
|
|
} else {
|
|
useScene.getState().updateNode(movingWindowNode.id, {
|
|
position: original.position,
|
|
rotation: original.rotation,
|
|
side: original.side,
|
|
parentId: original.parentId,
|
|
wallId: original.wallId,
|
|
metadata: original.metadata,
|
|
})
|
|
if (original.parentId) markWallDirty(original.parentId)
|
|
}
|
|
useScene.temporal.getState().resume()
|
|
hideCursor()
|
|
exitMoveMode()
|
|
}
|
|
|
|
emitter.on('wall:enter', onWallEnter)
|
|
emitter.on('wall:move', onWallMove)
|
|
emitter.on('wall:click', onWallClick)
|
|
emitter.on('wall:leave', onWallLeave)
|
|
emitter.on('tool:cancel', onCancel)
|
|
|
|
return () => {
|
|
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
|
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
|
|
| WindowNode
|
|
| undefined
|
|
const currentMeta = current?.metadata as Record<string, unknown> | undefined
|
|
if (currentMeta?.isTransient) {
|
|
if (isNew) {
|
|
useScene.getState().deleteNode(movingWindowNode.id)
|
|
if (currentWallId) markWallDirty(currentWallId)
|
|
} else {
|
|
useScene.getState().updateNode(movingWindowNode.id, {
|
|
position: original.position,
|
|
rotation: original.rotation,
|
|
side: original.side,
|
|
parentId: original.parentId,
|
|
wallId: original.wallId,
|
|
metadata: original.metadata,
|
|
})
|
|
if (original.parentId) markWallDirty(original.parentId)
|
|
}
|
|
}
|
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
useScene.temporal.getState().resume()
|
|
emitter.off('wall:enter', onWallEnter)
|
|
emitter.off('wall:move', onWallMove)
|
|
emitter.off('wall:click', onWallClick)
|
|
emitter.off('wall:leave', onWallLeave)
|
|
emitter.off('tool:cancel', onCancel)
|
|
}
|
|
}, [movingWindowNode, exitMoveMode])
|
|
|
|
const edgesGeo = useMemo(() => {
|
|
const boxGeo = new BoxGeometry(
|
|
movingWindowNode.width,
|
|
movingWindowNode.height,
|
|
movingWindowNode.frameDepth ?? 0.07,
|
|
)
|
|
const geo = new EdgesGeometry(boxGeo)
|
|
boxGeo.dispose()
|
|
return geo
|
|
}, [movingWindowNode])
|
|
|
|
return (
|
|
<group ref={cursorGroupRef} visible={false}>
|
|
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
|
</group>
|
|
)
|
|
}
|
|
|
|
export default MoveWindowTool
|