Phase 5 Stage E: full kind migration into packages/nodes

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>
This commit is contained in:
Wassim SAMAD
2026-05-19 15:14:12 -04:00
co-authored by Claude Opus 4.7
parent 11015ea1ed
commit d747d2f0ea
204 changed files with 6888 additions and 7877 deletions
+10 -1
View File
@@ -1,5 +1,6 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildWindowFloorplan } from './floorplan'
import { windowFloorplanMoveTarget } from './floorplan-move'
import { windowParametrics } from './parametrics'
import { WindowNode } from './schema'
@@ -47,6 +48,14 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
// Stage C: floor-plan polygon. ctx.parent gives the wall for direction
// + thickness — same shape as door.
floorplan: buildWindowFloorplan,
// Stage D — placement + move-on-wall. Same recipe as door. See
// `nodes/src/window/{tool,move-tool,window-math}.ts`.
tool: () => import('./tool'),
affordanceTools: {
move: () => import('./move-tool'),
},
// 2D move-on-floorplan handler — same shape as door.
floorplanMoveTarget: windowFloorplanMoveTarget,
toolHints: [
{ key: 'Left click', label: 'Place window on wall' },
@@ -56,7 +65,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
presentation: {
label: 'Window',
description: 'A window cut into a wall. Animated open/close for opening windows.',
icon: { kind: 'iconify', name: 'lucide:rectangle-horizontal' },
icon: { kind: 'url', src: '/icons/window.png' },
paletteSection: 'structure',
paletteOrder: 60,
},
@@ -0,0 +1,80 @@
import {
type AnyNodeId,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
useScene,
type WindowNode,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { findClosestWallInPlan } from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './window-math'
/**
* 2D floor-plan move handler for window. Same shape as door (see
* `nodes/src/door/floorplan-move.ts`) — pointer in plan space → snap
* to nearest wall → project onto wall axis → snap local-X to 0.5m →
* clamp inside wall bounds → commit.
*
* Window-specific: local Y (vertical position on the wall) is preserved
* from the source node — we don't try to reposition the sill from a 2D
* pointer (there's no Y signal in plan view). The 3D move tool handles
* vertical motion; the 2D move is a horizontal-only re-anchor.
*/
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
const startLevelId = (() => {
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null
})()
// Preserve the source window's local Y — 2D move doesn't have a way
// to express vertical motion, so we keep whatever vertical position
// the window had when the move started.
const startLocalY = node.position[1]
const session: FloorplanMoveTargetSession = {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
if (!hit) return
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
const { clampedX, clampedY } = clampToWall(
hit.wall,
snappedLocalX,
startLocalY,
node.width,
node.height,
)
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
data: {
position: [clampedX, clampedY, 0],
rotation: [0, hit.itemRotation, 0],
side: hit.side,
parentId: hit.wall.id,
wallId: hit.wall.id,
},
},
])
},
canCommit() {
const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined
if (!live || live.type !== 'window') return false
const overlapping = hasWallChildOverlap(
live.parentId as string,
live.position[0],
live.position[1],
live.width,
live.height,
live.id,
)
return !overlapping
},
}
return session
}
+103 -10
View File
@@ -5,11 +5,20 @@ import type {
WallNode,
WindowNode,
} from '@pascal-app/core'
import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions'
/**
* Stage C floor-plan builder for window. Mirrors door's shape — window
* polygon sits in the wall's cutout, width along wall, depth = wall
* thickness. Visually distinct via a glass-blue tint.
* Stage C floor-plan builder for window. Mirrors the legacy
* floorplan-panel window rendering:
*
* 1. Window footprint rectangle in the wall cutout (themed accent
* stroke when selected).
* 2. Inset inner outline — the "glass pane" frame inside the cutout.
* 3. Center mullion line down the middle of the opening, along the
* wall direction — the legacy's standard glass divider.
*
* Skipped vs the full legacy for now: arched / rounded opening shape
* variants, multi-pane mullion grids.
*/
export function buildWindowFloorplan(
node: WindowNode,
@@ -45,12 +54,96 @@ export function buildWindowFloorplan(
[cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth],
]
return {
kind: 'polygon',
points,
fill: '#bae6fd',
stroke: '#0c4a6e',
strokeWidth: 0.015,
opacity: 0.8,
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
// Same selection treatment as door — selected windows get a full
// orange body + outline so they read as the active target.
const accentColor = showSelectedChrome ? '#f97316' : 'rgba(31, 41, 55, 0.92)'
const fillColor = showSelectedChrome ? '#fed7aa' : 'rgba(255, 255, 255, 0.96)'
// Inner inset rectangle (the glass pane outline). Tangent inset
// pulls the long sides in slightly; normal inset reduces the depth.
const tangentInset = Math.min(width * 0.08, 0.12)
const normalInset = Math.min(depth * 0.22, 0.07)
const innerStartA: FloorplanPoint = [
cx - dirX * (halfWidth - tangentInset) + perpX * (halfDepth - normalInset),
cz - dirZ * (halfWidth - tangentInset) + perpZ * (halfDepth - normalInset),
]
const innerEndA: FloorplanPoint = [
cx + dirX * (halfWidth - tangentInset) + perpX * (halfDepth - normalInset),
cz + dirZ * (halfWidth - tangentInset) + perpZ * (halfDepth - normalInset),
]
const innerEndB: FloorplanPoint = [
cx + dirX * (halfWidth - tangentInset) - perpX * (halfDepth - normalInset),
cz + dirZ * (halfWidth - tangentInset) - perpZ * (halfDepth - normalInset),
]
const innerStartB: FloorplanPoint = [
cx - dirX * (halfWidth - tangentInset) - perpX * (halfDepth - normalInset),
cz - dirZ * (halfWidth - tangentInset) - perpZ * (halfDepth - normalInset),
]
// Center mullion — from the midpoint of the left edge to the
// midpoint of the right edge of the cutout.
const mullionStart: FloorplanPoint = [cx - dirX * halfWidth, cz - dirZ * halfWidth]
const mullionEnd: FloorplanPoint = [cx + dirX * halfWidth, cz + dirZ * halfWidth]
const children: FloorplanGeometry[] = [
// Outer footprint — white fill so the wall hatch underneath
// doesn't bleed through.
{
kind: 'polygon',
points,
fill: fillColor,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.9 : 1.25,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
},
// Inset glass-pane outline.
{
kind: 'polygon',
points: [innerStartA, innerEndA, innerEndB, innerStartB],
fill: 'none',
stroke: accentColor,
strokeOpacity: 0.6,
strokeWidth: showSelectedChrome ? 1.3 : 0.9,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
},
// Center mullion.
{
kind: 'line',
x1: mullionStart[0],
y1: mullionStart[1],
x2: mullionEnd[0],
y2: mullionEnd[1],
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.6 : 1.1,
strokeOpacity: 0.85,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
]
// Move handle — orange dot at the window center. Only when selected.
if (isSelected) {
children.push({
kind: 'move-handle',
point: [cx, cz],
})
}
// Placement-measurement dimensions when actively moving — same
// contract as door (see `nodes/src/door/floorplan.ts`).
if (view?.moving) {
for (const dim of buildOpeningPlacementDimensions(node, ctx)) {
children.push(dim)
}
}
return { kind: 'group', children }
}
+449
View File
@@ -0,0 +1,449 @@
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
+990
View File
@@ -0,0 +1,990 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
emitter,
useInteractive,
useScene,
WindowNode,
} from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
cn,
PanelSection,
PanelWrapper,
PresetsPopover,
SegmentedControl,
SliderControl,
ToggleControl,
triggerSFX,
useEditor,
usePresetsAdapter,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
function isSameWindowValue(current: unknown, next: unknown): boolean {
if (typeof current === 'number' && typeof next === 'number') {
return Math.abs(current - next) < 1e-6
}
if (Array.isArray(current) && Array.isArray(next)) {
return (
current.length === next.length &&
current.every((value, index) => isSameWindowValue(value, next[index]))
)
}
return Object.is(current, next)
}
function getMaxSharedWindowRadius(width: number, height: number) {
return Math.max(0, Math.min(width / 2, height / 2))
}
function normalizeWindowCornerRadii(
radii: [number, number, number, number],
width: number,
height: number,
): [number, number, number, number] {
const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number]
const scale = Math.min(
1,
Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6),
Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6),
Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6),
Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6),
)
if (scale >= 1) return next
return next.map((radius) => radius * scale) as [number, number, number, number]
}
function isSameRadiusTuple(
current: [number, number, number, number],
next: [number, number, number, number],
) {
return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6)
}
const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] }> = [
{ label: 'Fixed', value: 'fixed' },
{ label: 'Sliding', value: 'sliding' },
{ label: 'Casement', value: 'casement' },
{ label: 'Awning', value: 'awning' },
{ label: 'Single Hung', value: 'single-hung' },
{ label: 'Double Hung', value: 'double-hung' },
{ label: 'Bay', value: 'bay' },
{ label: 'Bow', value: 'bow' },
{ label: 'Louvered', value: 'louvered' },
]
const shapedWindowTypes = new Set<WindowNode['windowType']>([
'fixed',
'casement',
'awning',
'hopper',
'louvered',
])
const silllessWindowTypes = new Set<WindowNode['windowType']>(['bay', 'bow'])
export default function WindowPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{
id: AnyNodeId
key: keyof WindowNode
value: unknown
} | null>(null)
const adapter = usePresetsAdapter()
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
)
// Panel slider-drag fix recipe (plans/editor-node-registry.md). Without
// it, the 15+ SliderControls in this panel would loop on drag.
const handleUpdate = useCallback(
(updates: Partial<WindowNode>) => {
if (!selectedId) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'window') return
const hasChange = Object.entries(updates).some(([key, value]) => {
const currentValue = liveNode[key as keyof WindowNode]
return !isSameWindowValue(currentValue, value)
})
if (!hasChange) return
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
const scene = useScene.getState()
scene.dirtyNodes.add(selectedId as AnyNodeId)
if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
},
[selectedId],
)
const previewWindowUpdate = useCallback(
<K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
if (!selectedId) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'window') return
if (
!(
previewRef.current &&
previewRef.current.id === selectedId &&
previewRef.current.key === key
)
) {
previewRef.current = {
id: selectedId as AnyNodeId,
key,
value: liveNode[key],
}
}
if (isSameWindowValue(liveNode[key], value)) return
;(liveNode as WindowNode)[key] = value
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId],
)
const commitWindowPreview = useCallback(
<K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
if (!selectedId) return
const scene = useScene.getState()
const liveNode = scene.nodes[selectedId as AnyNodeId]
const preview = previewRef.current
if (liveNode?.type === 'window' && preview?.id === selectedId && preview.key === key) {
;(liveNode as WindowNode)[key] = preview.value as WindowNode[K]
scene.dirtyNodes.add(selectedId as AnyNodeId)
}
previewRef.current = null
useScene.getState().updateNode(
selectedId as AnyNode['id'],
{
[key]: value,
} as Partial<WindowNode>,
)
scene.dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleFlip = useCallback(() => {
if (!node) return
handleUpdate({
side: node.side === 'front' ? 'back' : 'front',
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
})
}, [node, handleUpdate])
const handleMove = useCallback(() => {
if (!node) return
triggerSFX('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
triggerSFX('sfx:item-delete')
deleteNode(selectedId as AnyNode['id'])
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [selectedId, node, deleteNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
triggerSFX('sfx:item-pick')
useScene.temporal.getState().pause()
const duplicate = WindowNode.parse({
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
side: node.side,
wallId: node.wallId,
parentId: node.parentId,
width: node.width,
height: node.height,
windowType: node.windowType,
operationState: node.operationState,
awningDirection: node.awningDirection,
casementStyle: node.casementStyle,
hingesSide: node.hingesSide,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
openingKind: node.openingKind,
openingShape: node.openingShape,
openingRadiusMode: node.openingRadiusMode ?? 'all',
openingCornerRadii: [...(node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15])],
cornerRadius: node.cornerRadius,
archHeight: node.archHeight,
openingRevealRadius: node.openingRevealRadius,
columnRatios: [...node.columnRatios],
rowRatios: [...node.rowRatios],
columnDividerThickness: node.columnDividerThickness,
rowDividerThickness: node.rowDividerThickness,
sill: node.sill,
sillDepth: node.sillDepth,
sillThickness: node.sillThickness,
metadata: { isNew: true },
})
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const getWindowPresetData = useCallback(() => {
if (!node) return null
return {
width: node.width,
height: node.height,
windowType: node.windowType,
operationState: node.operationState,
awningDirection: node.awningDirection,
casementStyle: node.casementStyle,
hingesSide: node.hingesSide,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
openingKind: node.openingKind,
openingShape: node.openingShape,
openingRadiusMode: node.openingRadiusMode ?? 'all',
openingCornerRadii: node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15],
cornerRadius: node.cornerRadius,
archHeight: node.archHeight,
openingRevealRadius: node.openingRevealRadius,
columnRatios: node.columnRatios,
rowRatios: node.rowRatios,
columnDividerThickness: node.columnDividerThickness,
rowDividerThickness: node.rowDividerThickness,
sill: node.sill,
sillDepth: node.sillDepth,
sillThickness: node.sillThickness,
}
}, [node])
const handleSavePreset = useCallback(
async (name: string) => {
const data = getWindowPresetData()
if (!(data && selectedId)) return
const presetId = await adapter.savePreset('window', name, data)
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
},
[getWindowPresetData, selectedId, adapter],
)
const handleOverwritePreset = useCallback(
async (id: string) => {
const data = getWindowPresetData()
if (!(data && selectedId)) return
await adapter.overwritePreset('window', id, data)
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
},
[getWindowPresetData, selectedId, adapter],
)
const handleApplyPreset = useCallback(
(data: Record<string, unknown>) => {
handleUpdate(data as Partial<WindowNode>)
},
[handleUpdate],
)
if (!(node && node.type === 'window' && selectedId)) return null
const numCols = node.columnRatios.length
const numRows = node.rowRatios.length
const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
const normCols = node.columnRatios.map((r) => r / colSum)
const normRows = node.rowRatios.map((r) => r / rowSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const windowShape =
openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15
const archHeight = node.archHeight ?? 0.35
const openingRevealRadius = node.openingRevealRadius ?? 0.025
const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height))
const displayedWindowType = node.windowType === 'hopper' ? 'awning' : (node.windowType ?? 'fixed')
const awningDirection = node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up')
const isOperableWindow =
node.windowType === 'sliding' ||
node.windowType === 'casement' ||
node.windowType === 'awning' ||
node.windowType === 'hopper' ||
node.windowType === 'single-hung' ||
node.windowType === 'double-hung' ||
node.windowType === 'louvered'
const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed')
const supportsGrid = node.windowType === 'fixed'
const supportsSill = !silllessWindowTypes.has(node.windowType)
const setOperationState = (value: number) => {
useInteractive.getState().cancelWindowAnimation(node.id)
useInteractive.getState().removeWindowOpenState(node.id)
handleUpdate({ operationState: Math.max(0, Math.min(1, value)) })
}
const getDimensionUpdates = (updates: Partial<Pick<WindowNode, 'width' | 'height'>>) => {
const nextWidth = updates.width ?? node.width
const nextHeight = updates.height ?? node.height
const nextUpdates: Partial<WindowNode> = { ...updates }
if (openingShape === 'rounded') {
if (openingRadiusMode === 'individual') {
const currentRadii = openingCornerRadii as [number, number, number, number]
const nextRadii = normalizeWindowCornerRadii(
openingCornerRadii as [number, number, number, number],
nextWidth,
nextHeight,
)
if (!isSameRadiusTuple(currentRadii, nextRadii)) {
nextUpdates.openingCornerRadii = nextRadii
}
} else {
const nextRadius = Math.min(
Math.max(cornerRadius, 0),
getMaxSharedWindowRadius(nextWidth, nextHeight),
)
if (Math.abs(nextRadius - cornerRadius) > 1e-6) {
nextUpdates.cornerRadius = nextRadius
}
}
}
if (openingShape === 'arch') {
const nextArchHeight = Math.min(Math.max(archHeight, 0.05), Math.max(nextHeight, 0.05))
if (Math.abs(nextArchHeight - archHeight) > 1e-6) {
nextUpdates.archHeight = nextArchHeight
}
}
return nextUpdates
}
const setOpeningCornerRadius = (index: number, value: number, commit = false) => {
const next = [...openingCornerRadii] as [number, number, number, number]
next[index] = value
if (commit) {
commitWindowPreview('openingCornerRadii', next)
} else {
previewWindowUpdate('openingCornerRadii', next)
}
}
const setColumnRatio = (index: number, newVal: number) => {
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = index < numCols - 1 ? index + 1 : index - 1
const delta = clamped - normCols[index]!
const neighborVal = Math.max(0.05, normCols[neighborIdx]! - delta)
const newRatios = normCols.map((v, i) => {
if (i === index) return clamped
if (i === neighborIdx) return neighborVal
return v
})
handleUpdate({ columnRatios: newRatios })
}
const setRowRatio = (index: number, newVal: number) => {
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = index < numRows - 1 ? index + 1 : index - 1
const delta = clamped - normRows[index]!
const neighborVal = Math.max(0.05, normRows[neighborIdx]! - delta)
const newRatios = normRows.map((v, i) => {
if (i === index) return clamped
if (i === neighborIdx) return neighborVal
return v
})
handleUpdate({ rowRatios: newRatios })
}
return (
<PanelWrapper
icon="/icons/window.png"
onClose={handleClose}
title={node.name || 'Window'}
width={320}
>
{/* Presets strip */}
<div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
<PresetsPopover
isAuthenticated={adapter.isAuthenticated}
onApply={handleApplyPreset}
onDelete={(id) => adapter.deletePreset(id)}
onFetchPresets={(tab) => adapter.fetchPresets('window', tab)}
onOverwrite={handleOverwritePreset}
onRename={(id, name) => adapter.renamePreset(id, name)}
onSave={handleSavePreset}
onToggleCommunity={adapter.togglePresetCommunity}
tabs={adapter.tabs}
type="window"
>
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
<BookMarked className="h-3.5 w-3.5 shrink-0" />
<span>Presets</span>
</button>
</PresetsPopover>
</div>
<PanelSection title="Type">
<SegmentedControl
onChange={(value) =>
handleUpdate({
openingKind: value as WindowNode['openingKind'],
...(value === 'opening'
? {
openingShape,
openingRadiusMode,
openingCornerRadii,
cornerRadius,
archHeight,
openingRevealRadius,
}
: {}),
})
}
options={[
{ value: 'window', label: 'Window' },
{ value: 'opening', label: 'Opening' },
]}
value={node.openingKind ?? 'window'}
/>
</PanelSection>
{!isOpening && (
<PanelSection title="Window Type">
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
{windowTypeOptions.map((option) => {
const isSelected = displayedWindowType === option.value
return (
<button
className={cn(
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
)}
key={option.value}
onClick={() =>
handleUpdate({
windowType: option.value,
...(option.value === 'awning' ? { awningDirection } : {}),
...(!shapedWindowTypes.has(option.value)
? { openingShape: 'rectangle' }
: {}),
...(silllessWindowTypes.has(option.value) ? { sill: false } : {}),
})
}
type="button"
>
<span className="truncate font-medium">{option.label}</span>
</button>
)
})}
</div>
{displayedWindowType === 'awning' && (
<div className="mt-2">
<SegmentedControl
onChange={(value) =>
handleUpdate({
windowType: 'awning',
awningDirection: value as WindowNode['awningDirection'],
})
}
options={[
{ value: 'up', label: 'Up' },
{ value: 'down', label: 'Down' },
]}
value={awningDirection}
/>
</div>
)}
{node.windowType === 'casement' && (
<div className="mt-2 space-y-2">
<SegmentedControl
onChange={(value) =>
handleUpdate({ casementStyle: value as WindowNode['casementStyle'] })
}
options={[
{ value: 'single', label: 'Single' },
{ value: 'french', label: 'French' },
]}
value={node.casementStyle ?? 'single'}
/>
{(node.casementStyle ?? 'single') === 'single' && (
<SegmentedControl
onChange={(value) =>
handleUpdate({ hingesSide: value as WindowNode['hingesSide'] })
}
options={[
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
]}
value={node.hingesSide ?? 'left'}
/>
)}
</div>
)}
{isOperableWindow && (
<div className="mt-2">
<SliderControl
label="Open"
max={1}
min={0}
onChange={setOperationState}
precision={2}
restoreOnCommit={false}
step={0.05}
value={Math.round((node.operationState ?? 0) * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
<PanelSection title="Position">
<SliderControl
label={
<>
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
</>
}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<SliderControl
label={
<>
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
</>
}
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
{!isOpening && (
<div className="px-1 pt-2 pb-1">
<ActionButton
className="w-full"
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
/>
</div>
)}
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Width"
min={0}
onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))}
precision={2}
restoreOnCommit={false}
step={0.1}
unit="m"
value={Math.round(node.width * 100) / 100}
/>
<SliderControl
label="Height"
min={0}
onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))}
precision={2}
restoreOnCommit={false}
step={0.1}
unit="m"
value={Math.round(node.height * 100) / 100}
/>
</PanelSection>
{!isOpening && supportsWindowShape && (
<PanelSection title="Corner Shape">
<SegmentedControl
onChange={(value) =>
handleUpdate({
openingShape: value as WindowNode['openingShape'],
...(value === 'rounded'
? {
openingRadiusMode,
openingCornerRadii,
cornerRadius: Math.min(cornerRadius, maxRoundedRadius),
openingRevealRadius,
sill: false,
}
: {}),
...(value === 'arch' ? { archHeight } : {}),
})
}
options={[
{ value: 'rectangle', label: 'Rect' },
{ value: 'rounded', label: 'Rounded' },
{ value: 'arch', label: 'Arch' },
]}
value={windowShape}
/>
{windowShape === 'rounded' && (
<div className="mt-2 flex flex-col gap-1">
<SegmentedControl
onChange={(value) =>
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
}
options={[
{ value: 'all', label: 'All' },
{ value: 'individual', label: 'Individual' },
]}
value={openingRadiusMode}
/>
{openingRadiusMode === 'all' ? (
<SliderControl
label="Corner Radius"
max={maxRoundedRadius}
min={0}
onChange={(value) => previewWindowUpdate('cornerRadius', value)}
onCommit={(value) => commitWindowPreview('cornerRadius', value)}
precision={2}
step={0.05}
unit="m"
value={Math.round(cornerRadius * 100) / 100}
/>
) : (
<>
{[
['Top Left', 0],
['Top Right', 1],
['Bottom Right', 2],
['Bottom Left', 3],
].map(([label, index]) => (
<SliderControl
key={label}
label={label}
max={maxRoundedRadius}
min={0}
onChange={(value) => setOpeningCornerRadius(index as number, value)}
onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
precision={2}
step={0.05}
unit="m"
value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
/>
))}
</>
)}
<SliderControl
label="Reveal Radius"
max={0.08}
min={0}
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
precision={3}
step={0.005}
unit="m"
value={Math.round(openingRevealRadius * 1000) / 1000}
/>
</div>
)}
{windowShape === 'arch' && (
<div className="mt-2 flex flex-col gap-1">
<SliderControl
label="Arch Height"
max={Math.max(0.05, node.height)}
min={0.05}
onChange={(value) => handleUpdate({ archHeight: value })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(archHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{isOpening && (
<PanelSection title="Opening Shape">
<SegmentedControl
onChange={(value) =>
handleUpdate({ openingShape: value as WindowNode['openingShape'] })
}
options={[
{ value: 'rectangle', label: 'Rect' },
{ value: 'rounded', label: 'Rounded' },
{ value: 'arch', label: 'Arch' },
]}
value={openingShape}
/>
{openingShape === 'rounded' && (
<div className="mt-2 flex flex-col gap-1">
<SegmentedControl
onChange={(value) =>
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
}
options={[
{ value: 'all', label: 'All' },
{ value: 'individual', label: 'Individual' },
]}
value={openingRadiusMode}
/>
{openingRadiusMode === 'all' ? (
<SliderControl
label="Corner Radius"
max={maxRoundedRadius}
min={0}
onChange={(value) => previewWindowUpdate('cornerRadius', value)}
onCommit={(value) => commitWindowPreview('cornerRadius', value)}
precision={2}
step={0.05}
unit="m"
value={Math.round(cornerRadius * 100) / 100}
/>
) : (
<>
{[
['Top Left', 0],
['Top Right', 1],
['Bottom Right', 2],
['Bottom Left', 3],
].map(([label, index]) => (
<SliderControl
key={label}
label={label}
max={maxRoundedRadius}
min={0}
onChange={(value) => setOpeningCornerRadius(index as number, value)}
onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
precision={2}
step={0.05}
unit="m"
value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
/>
))}
</>
)}
<SliderControl
label="Reveal Radius"
max={0.08}
min={0}
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
precision={3}
step={0.005}
unit="m"
value={Math.round(openingRevealRadius * 1000) / 1000}
/>
</div>
)}
{openingShape === 'arch' && (
<div className="mt-2 flex flex-col gap-1">
<SliderControl
label="Arch Height"
max={Math.max(0.05, node.height)}
min={0.05}
onChange={(value) => handleUpdate({ archHeight: value })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(archHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{!isOpening && (
<>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
min={0}
onChange={(v) => handleUpdate({ frameThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.frameThickness * 1000) / 1000}
/>
<SliderControl
label="Depth"
min={0}
onChange={(v) => handleUpdate({ frameDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.frameDepth * 1000) / 1000}
/>
</PanelSection>
{supportsGrid && (
<PanelSection title="Grid">
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
}}
precision={0}
step={1}
value={numCols}
/>
<SliderControl
label="Rows"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
}}
precision={0}
step={1}
value={numRows}
/>
{numCols > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Col Widths
</div>
{normCols.map((ratio, i) => (
<SliderControl
key={`c-${i}`}
label={`C${i + 1}`}
max={95}
min={5}
onChange={(v) => setColumnRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
/>
</div>
</div>
)}
{numRows > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Row Heights
</div>
{normRows.map((ratio, i) => (
<SliderControl
key={`r-${i}`}
label={`R${i + 1}`}
max={95}
min={5}
onChange={(v) => setRowRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
/>
</div>
</div>
)}
</PanelSection>
)}
{supportsSill && (
<PanelSection title="Sill">
<ToggleControl
checked={node.sill}
label="Enable Sill"
onChange={(checked) => handleUpdate({ sill: checked })}
/>
{node.sill && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Depth"
min={0}
onChange={(v) => handleUpdate({ sillDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.sillDepth * 1000) / 1000}
/>
<SliderControl
label="Thickness"
min={0}
onChange={(v) => handleUpdate({ sillThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.sillThickness * 1000) / 1000}
/>
</div>
)}
</PanelSection>
)}
</>
)}
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={handleDuplicate}
/>
<ActionButton
className="hover:bg-red-500/20"
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
+4
View File
@@ -18,4 +18,8 @@ export const windowParametrics: ParametricDescriptor<WindowNode> = {
],
},
],
// Stage E — kind-owned panel mounted by <ParametricInspector>. Window
// has 15+ controls (sashes, dividers, sill, frame, opening shape)
// that don't fit the generic auto-inspector.
customPanel: () => import('./panel'),
}
+34 -5
View File
@@ -1,9 +1,38 @@
'use client'
import { WindowRenderer } from '@pascal-app/viewer'
import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
import { createMaterial, DEFAULT_WINDOW_MATERIAL, useNodeEvents } from '@pascal-app/viewer'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'window', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'window')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
const material = useMemo(() => {
const mat = node.material
if (!mat) return DEFAULT_WINDOW_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return (
<mesh
material={material}
position={node.position}
ref={ref}
rotation={node.rotation}
visible={node.visible}
{...(isTransient ? {} : handlers)}
>
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
}
/**
* Wrap-export of the legacy `WindowRenderer`. Thin (~36 lines); Phase 5
* Stage F inlines it here and deletes the viewer-side file.
*/
export default WindowRenderer
+1 -5
View File
@@ -3,17 +3,13 @@
import { WindowAnimationSystem, WindowSystem } from '@pascal-app/viewer'
/**
* Registry-driven window system bundle. Same shape as door — two
* per-frame systems wrapped together:
* Registry-driven window system bundle.
*
* - **`WindowSystem`** — rebuilds frame / sash / divider / sill /
* muntin geometry. Cascades dirty to parent wall for the cutout.
* - **`WindowAnimationSystem`** — advances sash/panel open state at
* frame priority 2, then marks the window dirty for the geometry
* rebuild at priority 3.
*
* Both wrapped in `<LegacySystem kind="window">` legacy mounts; with
* window registered, those short-circuit and this bundle takes over.
*/
const WindowSystems = () => {
return (
+334
View File
@@ -0,0 +1,334 @@
import {
type AnyNodeId,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
WindowNode,
} from '@pascal-app/core'
import {
calculateCursorRotation,
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
triggerSFX,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
// Shared edge material — reuse across renders, just toggle color
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44, // red-500 default (invalid)
linewidth: 3,
depthTest: false,
depthWrite: false,
})
/**
* Window tool — places WindowNodes on walls only.
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
*/
const WindowTool: React.FC = () => {
const draftRef = useRef<WindowNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
useEffect(() => {
useScene.temporal.getState().pause()
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 markWallDirty = (wallId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
}
const destroyDraft = () => {
if (!draftRef.current) return
const wallId = draftRef.current.parentId
useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null
// Rebuild wall so it removes the cutout from the deleted draft
if (wallId) markWallDirty(wallId)
}
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)) {
destroyDraft()
hideCursor()
return
}
const levelId = getLevelId()
if (!levelId) return
// Only interact with walls on the current level
if (event.node.parentId !== levelId) return
destroyDraft()
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 width = 1.5
const height = 1.5
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
const node = WindowNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.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)) {
destroyDraft()
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 width = draftRef.current?.width ?? 1.5
const height = draftRef.current?.height ?? 1.5
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
if (draftRef.current) {
if (event.node.id !== draftRef.current.parentId) {
// Wall changed without enter/leave: must updateNode to reparent
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
} else {
// Same wall: update Three.js mesh directly to avoid store churn
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
if (draftMesh) {
draftMesh.position.set(clampedX, clampedY, 0)
draftMesh.rotation.set(0, itemRotation, 0)
draftMesh.updateMatrixWorld(true)
}
markWallDirty(event.node.id)
}
}
const valid = !hasWallChildOverlap(
event.node.id,
clampedX,
clampedY,
width,
height,
draftRef.current?.id,
)
updateCursor(
wallLocalToWorld(
event.node,
clampedX,
clampedY,
getLevelYOffset(),
getSlabElevation(event),
),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
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,
draftRef.current.width,
draftRef.current.height,
)
const valid = !hasWallChildOverlap(
event.node.id,
clampedX,
clampedY,
draftRef.current.width,
draftRef.current.height,
draftRef.current.id,
)
if (!valid) return
const draft = draftRef.current
draftRef.current = null
// Delete transient draft (paused, invisible to undo)
useScene.getState().deleteNode(draft.id)
// Resume → create permanent node (single undoable action)
useScene.temporal.getState().resume()
const levelId = getLevelId()
const state = useScene.getState()
const windowCount = Object.values(state.nodes).filter((n) => {
if (n.type !== 'window') return false
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return wall?.parentId === levelId
}).length
const name = `Window ${windowCount + 1}`
const node = WindowNode.parse({
name,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
width: draft.width,
height: draft.height,
windowType: draft.windowType,
operationState: draft.operationState,
awningDirection: draft.awningDirection,
casementStyle: draft.casementStyle,
hingesSide: draft.hingesSide,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
columnRatios: draft.columnRatios,
rowRatios: draft.rowRatios,
columnDividerThickness: draft.columnDividerThickness,
rowDividerThickness: draft.rowDividerThickness,
sill: draft.sill,
sillDepth: draft.sillDepth,
sillThickness: draft.sillThickness,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:item-place')
event.stopPropagation()
}
const onWallLeave = () => {
destroyDraft()
hideCursor()
}
const onCancel = () => {
destroyDraft()
hideCursor()
}
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 () => {
destroyDraft()
hideCursor()
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)
}
}, [])
// Cursor geometry: window outline rectangle (width × height × frameDepth)
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments
geometry={edgesGeo}
layers={EDITOR_LAYER}
material={edgeMaterial}
ref={edgesRef}
/>
</group>
)
}
export default WindowTool
+117
View File
@@ -0,0 +1,117 @@
import {
type AnyNodeId,
type DoorNode,
getScaledDimensions,
type ItemNode,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
/**
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
* Wall XZ uses level-local coordinates (levels only offset in Y, not XZ).
* Pass levelYOffset (the level group's current world Y) and slabElevation (the
* wall mesh's Y within the level group) so the cursor lands at the correct world
* height — matching how WallSystem positions the wall mesh at slabElevation.
*/
export function wallLocalToWorld(
wallNode: WallNode,
localX: number,
localY: number,
levelYOffset = 0,
slabElevation = 0,
): [number, number, number] {
const wallAngle = Math.atan2(
wallNode.end[1] - wallNode.start[1],
wallNode.end[0] - wallNode.start[0],
)
return [
wallNode.start[0] + localX * Math.cos(wallAngle),
slabElevation + localY + levelYOffset,
wallNode.start[1] + localX * Math.sin(wallAngle),
]
}
/**
* Clamps window center position so it stays fully within wall bounds.
*/
export function clampToWall(
wallNode: WallNode,
localX: number,
localY: number,
width: number,
height: number,
): { clampedX: number; clampedY: number } {
const dx = wallNode.end[0] - wallNode.start[0]
const dz = wallNode.end[1] - wallNode.start[1]
const wallLength = Math.sqrt(dx * dx + dz * dz)
const wallHeight = wallNode.height ?? 2.5
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY))
return { clampedX, clampedY }
}
/**
* Directly checks the wall's children for bounding-box overlap with a proposed window.
* Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center).
* The spatial grid only tracks `item` nodes, so windows must be checked this way.
* Reads the wall's latest children from the store (not the event node) to avoid stale data.
*/
export function hasWallChildOverlap(
wallId: string,
clampedX: number,
clampedY: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode) return true // Block if wall not found
const halfW = width / 2
const halfH = height / 2
const newBottom = clampedY - halfH
const newTop = clampedY + halfH
const newLeft = clampedX - halfW
const newRight = clampedX + halfW
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child) continue
let childLeft: number, childRight: number, childBottom: number, childTop: number
if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
const [w, h] = getScaledDimensions(item)
childLeft = item.position[0] - w / 2
childRight = item.position[0] + w / 2
childBottom = item.position[1] // items store bottom Y
childTop = item.position[1] + h
} else if (child.type === 'window') {
const win = child as WindowNode
childLeft = win.position[0] - win.width / 2
childRight = win.position[0] + win.width / 2
childBottom = win.position[1] - win.height / 2 // windows store center Y
childTop = win.position[1] + win.height / 2
} else if (child.type === 'door') {
const door = child as DoorNode
childLeft = door.position[0] - door.width / 2
childRight = door.position[0] + door.width / 2
childBottom = door.position[1] - door.height / 2 // doors store center Y
childTop = door.position[1] + door.height / 2
} else {
continue
}
const xOverlap = newLeft < childRight && newRight > childLeft
const yOverlap = newBottom < childTop && newTop > childBottom
if (xOverlap && yOverlap) return true
}
return false
}