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
@@ -1,6 +1,13 @@
'use client'
import { type AnyNode, type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
type CeilingNode,
nodeRegistry,
type SlabNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
@@ -18,9 +25,13 @@ import { NodeActionMenu } from '../editor/node-action-menu'
* an HTML overlay positioned at the top of the bounding box.
*
* Buttons:
* - Move: sets `movingNode` in useEditor. The `<FloorplanRegistryMove
* Overlay>` component picks that up and lets the user click in the
* floor plan to commit the new position.
* - Move: sets `movingNode` in useEditor. Enabled when the kind has
* `capabilities.movable`, `def.floorplanMoveTarget`, OR
* `def.affordanceTools.move` (slab / ceiling). The
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
* - Add hole (slab + ceiling only): inserts a small default-square
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
* `handleAddHole` in `floating-action-menu.tsx`.
* - Duplicate: deep-clones the node, marks it new, sets it as the
* movingNode (placement cursor) — same UX pattern as 3D duplicate.
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
@@ -70,14 +81,66 @@ export function FloorplanRegistryActionMenu() {
const node = useScene.getState().nodes[selectedId]
if (!node) return null
const canMove = !!def.capabilities.movable
// Move button is enabled when any of:
// - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
// - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
// - `def.affordanceTools.move` (kind-owned 3D mover — slab / ceiling)
// From the menu's perspective all three are "this kind can move from
// the floor plan." The `MoveTool` dispatcher resolves the right path.
const canMove =
!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
const canDuplicate = def.capabilities.duplicable !== false
const canDelete = def.capabilities.deletable !== false
const canAddHole = node.type === 'slab' || node.type === 'ceiling'
const handleMove = () => {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never)
// Selection stays — the move overlay reads movingNode, not selection.
// Match the legacy 3D `floating-action-menu`: clear selection so
// selection-gated affordances unmount during the drag. Specifically
// the slab / ceiling boundary editor (`ToolManager` shows it when
// `selectedSlabId !== undefined`) would otherwise stay visible
// and render its vertex / edge handles on top of the moving mesh
// in split-view 3D. The move overlay reads `movingNode`, not the
// selection, so clearing it doesn't disturb the move itself; the
// commit path re-selects the node when it ends.
useViewer.getState().setSelection({ selectedIds: [] })
}
const handleAddHole = () => {
if (!canAddHole) return
const surfaceNode = node as SlabNode | CeilingNode
const polygon = surfaceNode.polygon
if (!polygon || polygon.length < 3) return
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
cx /= polygon.length
cz /= polygon.length
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
[cx + holeSize, cz - holeSize],
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = surfaceNode.holes ?? []
const currentMetadata = currentHoles.map(
(_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
)
sfxEmitter.emit('sfx:structure-build')
useScene.getState().updateNode(
selectedId as AnyNodeId,
{
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' as const }],
} as Partial<AnyNode>,
)
}
const handleDuplicate = () => {
@@ -113,6 +176,7 @@ export function FloorplanRegistryActionMenu() {
}}
>
<NodeActionMenu
onAddHole={canAddHole ? handleAddHole : undefined}
onDelete={canDelete ? handleDelete : undefined}
onDuplicate={canDuplicate ? handleDuplicate : undefined}
onMove={canMove ? handleMove : undefined}
@@ -0,0 +1,54 @@
'use client'
import type { FloorplanPalette } from '@pascal-app/core'
import { createContext, type ReactNode, useContext, useMemo } from 'react'
/**
* Per-frame render context shared between the legacy `floorplan-panel.tsx`
* and the registry-driven `<FloorplanRegistryLayer>`.
*
* The legacy panel is the authoritative owner of the floor-plan SVG —
* it computes `unitsPerPixel` from the viewBox / surface size, mounts the
* pan/zoom `<g>`, and knows the active theme. The registry layer is mounted
* inside the same `<g>`, so anything it draws shares the same coordinate
* system; this context plumbs through the bits it can't recompute on its
* own without re-implementing the legacy's resize / theme logic.
*
* Once `floorplan-panel.tsx` is fully migrated (Phase 6), this provider
* moves into a kind-agnostic 2D editor shell and the context loses the
* "legacy bridge" connotation.
*/
export type FloorplanRenderContextValue = {
/** SVG units per screen pixel — used to keep handle radii consistent at any zoom. */
unitsPerPixel: number
/** Themed palette mirroring the legacy `FloorplanPalette` accent slots. */
palette: FloorplanPalette
/** SVG `<pattern>` id mounted in `<defs>` by the legacy panel for selection hatch fills. */
hatchPatternId: string
}
const FloorplanRenderContext = createContext<FloorplanRenderContextValue | null>(null)
export function FloorplanRenderProvider({
children,
unitsPerPixel,
palette,
hatchPatternId,
}: FloorplanRenderContextValue & { children: ReactNode }) {
const value = useMemo<FloorplanRenderContextValue>(
() => ({ unitsPerPixel, palette, hatchPatternId }),
[unitsPerPixel, palette, hatchPatternId],
)
return <FloorplanRenderContext.Provider value={value}>{children}</FloorplanRenderContext.Provider>
}
/**
* Read the active render context. Returns `null` when called outside a
* provider — the registry layer treats this as "render statically, skip
* theme-aware chrome and interactive handles". This makes the layer
* usable in isolation tests + future editor shells without bringing the
* whole legacy panel along.
*/
export function useFloorplanRender(): FloorplanRenderContextValue | null {
return useContext(FloorplanRenderContext)
}
@@ -273,10 +273,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
fill={curvedAccent}
key={`${stair.id}:spiral-arrow`}
pointerEvents="none"
points={buildSvgArrowHeadPoints(
arrowPoint,
tangentAngle,
clamp(stair.width * 0.18, 0.12, 0.18),
points={formatSvgPolygonPoints(
buildSvgArrowHeadPoints(
arrowPoint,
tangentAngle,
clamp(stair.width * 0.18, 0.12, 0.18),
),
)}
/>
)
@@ -361,10 +363,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
fill={curvedAccent}
key={`${stair.id}:curved-arrow`}
pointerEvents="none"
points={buildSvgArrowHeadPoints(
arrowPoint,
tangentAngle,
clamp(stair.width * 0.16, 0.1, 0.16),
points={formatSvgPolygonPoints(
buildSvgArrowHeadPoints(
arrowPoint,
tangentAngle,
clamp(stair.width * 0.16, 0.1, 0.16),
),
)}
/>
)
@@ -103,7 +103,15 @@ export function formatSvgPolygonPoints(points: Point2D[]) {
return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ')
}
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
/**
* Three points defining an arrow head — tip + two trailing barbs.
* Returned as plain `Point2D` objects so consumers can either feed them
* straight into `formatSvgPolygonPoints` (for SVG `points=""`) or push
* them onto a `FloorplanGeometry.polygon.points` array. Mixing both
* downstream paths through a string-returning helper was awkward — see
* `nodes/src/stair/floorplan.ts` which needs the points as objects.
*/
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number): Point2D[] {
const left = {
x: point.x - size * Math.cos(angle - Math.PI / 6),
y: point.y - size * Math.sin(angle - Math.PI / 6),
@@ -113,7 +121,7 @@ export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: num
y: point.y - size * Math.sin(angle + Math.PI / 6),
}
return formatSvgPolygonPoints([point, left, right])
return [point, left, right]
}
export { toSvgPoint, toSvgX, toSvgY }
@@ -352,7 +352,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
const nodes = useScene.getState().nodes
const meshes: ElevatorColliderMesh[] = []
for (const elevatorId of sceneRegistry.byType.elevator) {
for (const elevatorId of sceneRegistry.byType.elevator!) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator' || node.visible === false) continue
@@ -585,7 +585,7 @@ export const FirstPersonControls = () => {
let closestDoorId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const doorId of sceneRegistry.byType.door) {
for (const doorId of sceneRegistry.byType.door!) {
const node = nodes[doorId as AnyNodeId]
if (node?.type !== 'door') continue
if (node.openingKind === 'opening') continue
@@ -683,7 +683,7 @@ export const FirstPersonControls = () => {
let closestWindowId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const windowId of sceneRegistry.byType.window) {
for (const windowId of sceneRegistry.byType.window!) {
const node = nodes[windowId as AnyNodeId]
if (node?.type !== 'window') continue
if (node.openingKind === 'opening') continue
@@ -713,7 +713,7 @@ export const FirstPersonControls = () => {
let closestTarget: FirstPersonInteractableTarget | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const elevatorId of sceneRegistry.byType.elevator) {
for (const elevatorId of sceneRegistry.byType.elevator!) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator') continue
@@ -1088,11 +1088,11 @@ export const FirstPersonControls = () => {
const elevatorIds = activeRide
? [
activeRide.elevatorId,
...Array.from(sceneRegistry.byType.elevator).filter(
...Array.from(sceneRegistry.byType.elevator!).filter(
(elevatorId) => elevatorId !== activeRide.elevatorId,
),
]
: Array.from(sceneRegistry.byType.elevator)
: Array.from(sceneRegistry.byType.elevator!)
for (const elevatorId of elevatorIds) {
const typedElevatorId = elevatorId as AnyNodeId
@@ -176,7 +176,7 @@ function buildRegisteredNodeTypeLookup() {
const nodeTypes = new Map<string, ColliderNodeType>()
for (const type of COLLIDER_NODE_TYPES) {
for (const nodeId of sceneRegistry.byType[type]) {
for (const nodeId of sceneRegistry.byType[type]!) {
nodeTypes.set(nodeId, type)
}
}
@@ -238,7 +238,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
}
for (const type of COLLIDER_NODE_TYPES) {
for (const nodeId of sceneRegistry.byType[type]) {
for (const nodeId of sceneRegistry.byType[type]!) {
if (shouldSkipColliderNode(nodeId, type)) continue
const root = sceneRegistry.nodes.get(nodeId)
@@ -16,7 +16,6 @@ import {
type GuideNode,
getRenderableSlabPolygon,
getWallChordFrame,
getWallCurveFrameAt,
getWallCurveLength,
getWallMidpointHandlePoint,
getWallPlanFootprint,
@@ -30,7 +29,6 @@ import {
type Point2D,
type RoofNode,
type RoofSegmentNode,
resolveElevatorServiceLevelIds,
type SiteNode,
SlabNode,
type SpawnNode,
@@ -87,6 +85,10 @@ import {
} from '../editor-2d/floorplan-hotkey-handlers'
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
import {
type FloorplanRenderContextValue,
FloorplanRenderProvider,
} from '../editor-2d/floorplan-render-context'
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
import {
@@ -8440,364 +8442,29 @@ export function FloorplanPanel() {
return hasPreviewWalls ? nextFloorplanWallById : floorplanWallById
}, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
const floorplanFenceEntries = useMemo(() => {
// Fence migrated to def.floorplan (Phase 5 Stage C). When registered,
// FloorplanRegistryLayer renders the fence polyline; this legacy
// path short-circuits to avoid double-render. Removed entirely in
// Phase 6 cleanup.
if (nodeRegistry.has('fence')) return []
return fences.flatMap((fence) => {
const live = useLiveTransforms.getState().get(fence.id)
const fenceCenterX = (fence.start[0] + fence.end[0]) / 2
const fenceCenterZ = (fence.start[1] + fence.end[1]) / 2
const displayFence = live
? {
...fence,
start: [
fence.start[0] + (live.position[0] - fenceCenterX),
fence.start[1] + (live.position[2] - fenceCenterZ),
] as typeof fence.start,
end: [
fence.end[0] + (live.position[0] - fenceCenterX),
fence.end[1] + (live.position[2] - fenceCenterZ),
] as typeof fence.end,
}
: fence
const centerline = isCurvedWall(displayFence)
? sampleWallCenterline(displayFence, 24)
: [
{ x: displayFence.start[0], y: displayFence.start[1] },
{ x: displayFence.end[0], y: displayFence.end[1] },
]
const path = buildSvgPolylinePath(centerline)
if (!path) {
return []
}
// Fence is fully registry-driven (`def.floorplan` + `buildFenceFloorplan`).
// The legacy entry list is permanently empty; kept as a typed stable
// reference so downstream prop sites stay typed without each having to
// declare its own `[]`.
const floorplanFenceEntries = useMemo<FloorplanFenceEntry[]>(() => [], [])
// Wall is fully registry-driven. Empty stable arrays for the legacy
// entry lists; consumers' map / iteration paths become no-ops.
const wallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
const displayWallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
const markerFrames = getFloorplanFenceMarkerTs(displayFence).map((t) => {
const frame = getWallCurveFrameAt(displayFence, t)
return {
angleDeg: (Math.atan2(frame.tangent.y, frame.tangent.x) * 180) / Math.PI,
point: frame.point,
}
})
return [{ fence: displayFence, centerline, markerFrames, path }]
})
}, [fences, movingFloorplanNodeRevision])
const wallPolygons = useMemo(() => {
// Wall migrated to def.floorplan (Phase 5 Stage C). When registered,
// FloorplanRegistryLayer renders the mitered wall polygon; this
// legacy path short-circuits. Removed entirely in Phase 6 cleanup.
if (nodeRegistry.has('wall')) return []
return walls.map((wall) => {
const floorplanWall = floorplanWallById.get(wall.id) ?? getFloorplanWall(wall)
const polygon = getWallPlanFootprint(floorplanWall, wallMiterData)
return {
points: formatPolygonPoints(polygon),
wall,
polygon,
}
})
}, [floorplanWallById, wallMiterData, walls])
const displayWallPolygons = useMemo(() => {
if (!(wallEndpointDraft || wallCurveDraft)) {
return wallPolygons
}
const previewWalls = new Map<WallNode['id'], WallNode>()
if (wallEndpointDraft) {
for (const draftUpdate of getWallEndpointDraftUpdates(wallEndpointDraft)) {
const previewWall = displayWallById.get(draftUpdate.id)
if (previewWall) {
previewWalls.set(previewWall.id, previewWall)
}
}
}
if (wallCurveDraft) {
const previewWall = displayWallById.get(wallCurveDraft.wallId)
if (previewWall) {
previewWalls.set(previewWall.id, previewWall)
}
}
if (previewWalls.size === 0) {
return wallPolygons
}
return wallPolygons.map((entry) =>
(() => {
const previewWall = previewWalls.get(entry.wall.id)
if (!previewWall) {
return entry
}
const previewPolygon = getWallPlanFootprint(
getFloorplanWall(previewWall),
EMPTY_WALL_MITER_DATA,
)
return {
wall: previewWall,
polygon: previewPolygon,
points: formatPolygonPoints(previewPolygon),
}
})(),
)
}, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
const openingsPolygons = useMemo(() => {
// Doors + windows migrated to def.floorplan (Phase 5 Stage C). When
// both registered, FloorplanRegistryLayer renders each opening's
// polygon via its kind's builder; this legacy path short-circuits
// to avoid double-render. Filter per kind so a partial migration
// would still work.
const doorRegistered = nodeRegistry.has('door')
const windowRegistered = nodeRegistry.has('window')
if (doorRegistered && windowRegistered) return []
return openings.flatMap((opening) => {
if (doorRegistered && opening.type === 'door') return []
if (windowRegistered && opening.type === 'window') return []
const wall = displayFloorplanWallById.get(opening.parentId as WallNode['id'])
if (!wall) return []
const live = useLiveTransforms.getState().get(opening.id)
const displayOpening =
live &&
(movingNode?.type === 'door' || movingNode?.type === 'window') &&
movingNode.id === opening.id
? {
...opening,
position: [
live.position[0],
opening.position[1],
live.position[2],
] as typeof opening.position,
rotation: [
opening.rotation[0],
live.rotation,
opening.rotation[2],
] as typeof opening.rotation,
}
: opening
const polygon = getOpeningFootprint(wall, displayOpening)
return [
{
opening: displayOpening,
points: formatPolygonPoints(polygon),
polygon,
},
]
})
}, [displayFloorplanWallById, movingFloorplanNodeRevision, movingNode, openings])
const slabPolygons = useMemo(() => {
// Slab migrated to def.floorplan (Phase 5 Stage C). When registered,
// FloorplanRegistryLayer renders the slab polygon; this legacy
// path short-circuits to avoid double-render. Removed entirely in
// Phase 6 cleanup.
if (nodeRegistry.has('slab')) return []
return slabs.flatMap((slab) => {
const polygon = toFloorplanPolygon(slab.polygon)
if (polygon.length < 3) {
return []
}
const holes = (slab.holes ?? [])
.map((hole) => toFloorplanPolygon(hole))
.filter((hole) => hole.length >= 3)
const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab))
const visualHoles = holes
return [
{
slab,
polygon,
holes,
visualPolygon,
visualHoles,
path: formatPolygonPath(visualPolygon, visualHoles),
},
]
})
}, [slabs])
const displaySlabPolygons = useMemo(() => {
if (!(slabBoundaryDraft || slabHoleBoundaryDraft || slabHoleMoveDraft)) {
return slabPolygons
}
return slabPolygons.map((entry) => {
let nextEntry = entry
if (slabBoundaryDraft && entry.slab.id === slabBoundaryDraft.slabId) {
nextEntry = (() => {
const draftVisualPolygon =
slabBoundaryDraft.visualOffsets?.length === slabBoundaryDraft.polygon.length
? getDraftSlabVisualPolygon(slabBoundaryDraft)
: toFloorplanPolygon(
getRenderableSlabPolygon({
...entry.slab,
polygon: slabBoundaryDraft.polygon,
}),
)
return {
...entry,
polygon: slabBoundaryDraft.polygon.map(toPoint2D),
visualPolygon: draftVisualPolygon,
path: formatPolygonPath(draftVisualPolygon, entry.visualHoles),
}
})()
}
const activeHoleDraft =
slabHoleBoundaryDraft && entry.slab.id === slabHoleBoundaryDraft.slabId
? slabHoleBoundaryDraft
: slabHoleMoveDraft && entry.slab.id === slabHoleMoveDraft.slabId
? slabHoleMoveDraft
: null
if (activeHoleDraft) {
const draftHole = activeHoleDraft.polygon.map(toPoint2D)
const draftHoles = nextEntry.holes.map((hole, index) =>
index === activeHoleDraft.holeIndex ? draftHole : hole,
)
const draftVisualHoles = nextEntry.visualHoles.map((hole, index) =>
index === activeHoleDraft.holeIndex ? draftHole : hole,
)
nextEntry = {
...nextEntry,
holes: draftHoles,
visualHoles: draftVisualHoles,
path: formatPolygonPath(nextEntry.visualPolygon, draftVisualHoles),
}
}
return nextEntry
})
}, [slabBoundaryDraft, slabHoleBoundaryDraft, slabHoleMoveDraft, slabPolygons])
const ceilingPolygons = useMemo(() => {
// Ceiling migrated to def.floorplan (Phase 5 Stage C). When registered,
// FloorplanRegistryLayer renders the ceiling polygon; this legacy
// path short-circuits. Removed entirely in Phase 6 cleanup.
if (nodeRegistry.has('ceiling')) return []
return ceilings.flatMap((ceiling) => {
const polygon = toFloorplanPolygon(ceiling.polygon)
if (polygon.length < 3) {
return []
}
const holes = (ceiling.holes ?? [])
.map((hole) => toFloorplanPolygon(hole))
.filter((hole) => hole.length >= 3)
return [
{
ceiling,
polygon,
holes,
path: formatPolygonPath(polygon, holes),
},
]
})
}, [ceilings])
const displayCeilingPolygons = useMemo(() => {
if (!(ceilingBoundaryDraft || ceilingHoleBoundaryDraft || ceilingHoleMoveDraft)) {
return ceilingPolygons
}
return ceilingPolygons.map((entry) => {
let nextEntry = entry
if (ceilingBoundaryDraft && entry.ceiling.id === ceilingBoundaryDraft.ceilingId) {
const polygon = ceilingBoundaryDraft.polygon.map(toPoint2D)
nextEntry = {
...entry,
polygon,
path: formatPolygonPath(polygon, entry.holes),
}
}
const activeHoleDraft =
ceilingHoleBoundaryDraft && entry.ceiling.id === ceilingHoleBoundaryDraft.ceilingId
? ceilingHoleBoundaryDraft
: ceilingHoleMoveDraft && entry.ceiling.id === ceilingHoleMoveDraft.ceilingId
? ceilingHoleMoveDraft
: null
if (activeHoleDraft) {
const draftHole = activeHoleDraft.polygon.map(toPoint2D)
const holes = nextEntry.holes.map((hole, index) =>
index === activeHoleDraft.holeIndex ? draftHole : hole,
)
nextEntry = {
...nextEntry,
holes,
path: formatPolygonPath(nextEntry.polygon, holes),
}
}
return nextEntry
})
}, [ceilingBoundaryDraft, ceilingHoleBoundaryDraft, ceilingHoleMoveDraft, ceilingPolygons])
const zonePolygons = useMemo(
() =>
zones.flatMap((zone) => {
const polygon = toFloorplanPolygon(zone.polygon)
if (polygon.length < 3) {
return []
}
return [
{
zone,
polygon,
points: formatPolygonPoints(polygon),
},
]
}),
[zones],
)
const displayZonePolygons = useMemo(() => {
if (!zoneBoundaryDraft) {
return zonePolygons
}
return zonePolygons.map((entry) =>
entry.zone.id === zoneBoundaryDraft.zoneId
? {
...entry,
polygon: zoneBoundaryDraft.polygon.map(toPoint2D),
points: formatPolygonPoints(zoneBoundaryDraft.polygon.map(toPoint2D)),
}
: entry,
)
}, [zoneBoundaryDraft, zonePolygons])
const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(
() =>
levelDescendantNodes.flatMap((node) => {
if (!(node.type === 'column' && node.visible !== false)) {
return []
}
const polygon = getColumnPlanFootprint(node)
if (polygon.length < 3) {
return []
}
return [
{
column: node,
points: formatPolygonPoints(polygon),
polygon,
},
]
}),
[levelDescendantNodes],
)
// Doors + windows fully registry-driven via `def.floorplan`.
const openingsPolygons = useMemo<OpeningPolygonEntry[]>(() => [], [])
// Slab + ceiling fully registry-driven via `def.floorplan`. Same
// empty-stable-array pattern.
const slabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
const displaySlabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
const ceilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
const displayCeilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
// Zone fully registry-driven via `def.floorplan`.
const zonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
const displayZonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
// Column fully registry-driven via `def.floorplan`.
const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(() => [], [])
const levelDescendantNodeById = useMemo(
() => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)),
[levelDescendantNodes],
@@ -8820,184 +8487,11 @@ export function FloorplanPanel() {
),
[levelDescendantNodes],
)
const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(() => {
// Spawn migrated to the registry-driven floor-plan layer (Phase 5
// Stage C). When registered, FloorplanRegistryLayer renders the
// spawn marker via def.floorplan; FloorplanRegistryActionMenu
// handles select / move / delete. Returning [] here skips the
// legacy rendering + action menu paths to avoid double-render.
// Removed entirely in Phase 6 cleanup.
if (nodeRegistry.has('spawn')) return []
return spawns
.filter((spawn) => spawn.visible !== false)
.map((spawn) => {
const live = useLiveTransforms.getState().get(spawn.id)
return {
spawn,
position: {
x: live?.position[0] ?? spawn.position[0],
y: live?.position[2] ?? spawn.position[2],
},
rotation: live?.rotation ?? spawn.rotation,
}
})
}, [movingFloorplanNodeRevision, spawns])
const floorplanItemEntries = useMemo(() => {
// Item migrated to def.floorplan (Phase 5 Stage C). When registered,
// FloorplanRegistryLayer renders the item rectangle via the
// parent-chain transform walker; this legacy path short-circuits.
// Removed entirely in Phase 6 cleanup.
if (nodeRegistry.has('item')) return []
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
return floorplanItems.flatMap((item) => {
const entry = buildFloorplanItemEntry(item, levelDescendantNodeById, transformCache)
if (!entry) {
return []
}
return [
{
dimensionPolygon: entry.dimensionPolygon,
item: entry.item,
points: formatPolygonPoints(entry.polygon),
polygon: entry.polygon,
usesRealMesh: entry.usesRealMesh,
center: entry.center,
rotation: entry.rotation,
width: entry.width,
depth: entry.depth,
},
]
})
}, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision])
const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => {
// These keys subscribe the memo to imperative floorplan stores read with getState().
void elevatorLiveOverrideKey
void elevatorRuntimeKey
void movingFloorplanNodeRevision
if (!levelNode) {
return []
}
const nodes = useScene.getState().nodes
const interactiveElevators = useInteractive.getState().elevators
return elevators.flatMap((elevator) => {
const liveOverrides = useLiveNodeOverrides.getState().get(elevator.id)
const displayElevator = liveOverrides
? ({ ...elevator, ...liveOverrides } as ElevatorNode)
: elevator
const serviceLevelIds = resolveElevatorServiceLevelIds(displayElevator, nodes)
if (!serviceLevelIds.includes(levelNode.id)) {
return []
}
const live = useLiveTransforms.getState().get(displayElevator.id)
const position = live?.position ?? displayElevator.position
const rotation = live?.rotation ?? displayElevator.rotation
const center = { x: position[0], y: position[2] }
const wallThickness = Math.max(displayElevator.shaftWallThickness ?? 0.09, 0.04)
const cabWidth = Math.max(displayElevator.width, 0.8)
const cabDepth = Math.max(displayElevator.depth, 0.8)
const shaftWidth = Math.max(
displayElevator.shaftWidth ?? displayElevator.width,
cabWidth,
0.8,
)
const shaftDepth = Math.max(
displayElevator.shaftDepth ?? displayElevator.depth,
cabDepth,
0.8,
)
const doorWidth = Math.min(
Math.max(displayElevator.doorWidth, 0.45),
cabWidth - 0.18,
shaftWidth - 0.18,
)
const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness)
const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness)
const footprintCorners: Array<readonly [number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
]
const polygon = footprintCorners.map(([localX, localY]) => {
const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation)
return {
x: center.x + offsetX,
y: center.y + offsetY,
}
})
const frontStart = polygon[0]
const frontEnd = polygon[1]
if (!(frontStart && frontEnd)) {
return []
}
const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation)
const runtime = interactiveElevators[displayElevator.id]
const disabledLevelIds = new Set(displayElevator.disabledLevelIds ?? [])
const serviceOnlyLevelIds = new Set(displayElevator.serviceOnlyLevelIds ?? [])
const servedLevels = serviceLevelIds.flatMap((levelId) => {
const level = nodes[levelId as AnyNodeId]
if (level?.type !== 'level') {
return []
}
return [
{
id: level.id,
isCurrent: runtime?.currentLevelId === level.id,
isDisabled: disabledLevelIds.has(level.id),
isQueued: runtime?.queue.includes(level.id) ?? false,
isServiceOnly: serviceOnlyLevelIds.has(level.id),
isTarget: runtime?.targetLevelId === level.id,
label: level.name || `L${level.level}`,
},
]
})
return [
{
cabCenterLocalY: -shaftDepth / 2 + cabDepth / 2,
cabDepth,
cabWidth,
center,
doorStyle: displayElevator.doorStyle ?? 'center-opening',
doorWidth,
elevator: displayElevator,
frontEdge: {
start: frontStart,
end: frontEnd,
},
frontNormal: {
x: frontNormalX,
y: frontNormalY,
},
isCarOnLevel: runtime?.currentLevelId === levelNode.id,
isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false,
isTargetLevel: runtime?.targetLevelId === levelNode.id,
outerHalfDepth: halfDepth,
outerHalfWidth: halfWidth,
points: formatPolygonPoints(polygon),
polygon,
rotation,
servedLevels,
shaftDepth,
shaftWallThickness: wallThickness,
shaftWidth,
},
]
})
}, [
elevatorLiveOverrideKey,
elevatorRuntimeKey,
elevators,
levelNode,
movingFloorplanNodeRevision,
])
// Spawn + item fully registry-driven.
const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(() => [], [])
const floorplanItemEntries = useMemo<FloorplanItemEntry[]>(() => [], [])
// Elevator fully registry-driven via `def.floorplan`.
const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => [], [])
const referenceFloorLevel = useMemo(() => {
if (!(showReferenceFloor && levelNode)) {
return null
@@ -9192,171 +8686,30 @@ export function FloorplanPanel() {
wallPolygons,
}
}, [referenceFloorDescendants, referenceFloorLevel])
const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh)
const floorplanStairEntries = useMemo(
() =>
floorplanStairs.flatMap((stair) => {
const displayStair =
movingNode?.type === 'stair' && movingNode.id === stair.id
? (() => {
const live = useLiveTransforms.getState().get(stair.id)
const liveX = cursorPoint?.[0] ?? live?.position[0] ?? stair.position[0]
const liveZ = cursorPoint?.[1] ?? live?.position[2] ?? stair.position[2]
const liveRotation = live?.rotation ?? stair.rotation
return {
...stair,
position: [liveX, stair.position[1], liveZ] as StairNode['position'],
rotation: liveRotation,
}
})()
: stair
const segments = (displayStair.children ?? [])
.map((childId) => levelDescendantNodeById.get(childId as AnyNodeId))
.filter(
(node): node is StairSegmentNode =>
node?.type === 'stair-segment' && node.visible !== false,
)
const entry = buildSharedFloorplanStairEntry(displayStair, segments)
if (!entry) {
return []
}
const hitPolygons =
(displayStair.stairType ?? 'straight') === 'straight'
? entry.segments.map((segmentEntry) => segmentEntry.polygon)
: [getFloorplanCurvedStairHitPolygon(displayStair)]
return [
{
...entry,
hitPolygons,
segments: entry.segments.map((segmentEntry) => ({
...segmentEntry,
innerPoints: formatPolygonPoints(segmentEntry.innerPolygon),
points: formatPolygonPoints(segmentEntry.polygon),
treadBars: segmentEntry.treadBars.map((polygon) => ({
points: formatPolygonPoints(polygon),
polygon,
})),
})),
},
]
}),
[
cursorPoint,
floorplanStairs,
levelDescendantNodeById,
movingFloorplanNodeRevision,
movingNode,
],
)
const floorplanRoofEntries = useMemo(
() =>
roofs.flatMap((roof) => {
const liveRoofTransform =
movingNode?.type === 'roof' && movingNode.id === roof.id
? useLiveTransforms.getState().get(roof.id)
: null
const liveRoofPosition = liveRoofTransform
? worldToBuildingLocalPlanPoint(
liveRoofTransform.position,
buildingPosition,
buildingRotationY,
)
: null
const displayRoof = liveRoofTransform
? {
...roof,
position: [
liveRoofPosition?.x ?? roof.position[0],
roof.position[1],
liveRoofPosition?.y ?? roof.position[2],
] as RoofNode['position'],
rotation: liveRoofTransform.rotation,
}
: roof
const segments = (displayRoof.children ?? [])
.map((childId) => levelDescendantNodeById.get(childId as AnyNodeId))
.filter(
(node): node is RoofSegmentNode =>
node?.type === 'roof-segment' && node.visible !== false,
)
.flatMap((segment) => {
const liveSegmentTransform =
movingNode?.type === 'roof-segment' && movingNode.id === segment.id
? useLiveTransforms.getState().get(segment.id)
: null
const worldPositionOverride = liveSegmentTransform
? worldToBuildingLocalPlanPoint(
liveSegmentTransform.position,
buildingPosition,
buildingRotationY,
)
: undefined
const polygon = getRoofSegmentPolygon(displayRoof, segment, {
localRotation: liveSegmentTransform?.rotation,
worldPositionOverride,
})
if (polygon.length < 3) {
return []
}
return [
{
segment,
polygon,
points: formatPolygonPoints(polygon),
ridgeLine: getRoofSegmentRidgeLine(displayRoof, segment, {
localRotation: liveSegmentTransform?.rotation,
worldPositionOverride,
}),
},
]
})
if (segments.length === 0) {
return []
}
return [
{
roof: displayRoof,
center: { x: displayRoof.position[0], y: displayRoof.position[2] },
segments,
},
]
}),
[
buildingPosition,
buildingRotationY,
levelDescendantNodeById,
movingFloorplanNodeRevision,
movingNode,
roofs,
],
)
const selectedOpeningEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
}
return openingsPolygons.find(({ opening }) => opening.id === selectedIds[0]) ?? null
}, [openingsPolygons, selectedIds])
const selectedItemEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
}
return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
}, [floorplanItemEntries, selectedIds])
const selectedSpawnEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
}
return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null
}, [floorplanSpawnEntries, selectedIds])
// Pending-mesh check was a flag the legacy active-level item entries
// raised when their polygon was the dimension fallback (waiting for
// the GLB to load to produce a tighter convex hull). Items are now
// registry-rendered, so the active-level entry list is always empty
// and this flag is permanently false.
const hasPendingItemMeshFootprints = false
// Stair fully registry-driven via `def.floorplan` (the parent walks
// its `stair-segment` children inside `buildStairFloorplan` to handle
// the cumulative-transform chain). `FloorplanRegistryLayer` renders
// the result; this legacy list stays empty.
const floorplanStairEntries = useMemo<FloorplanStairEntry[]>(() => [], [])
// Roof / roof-segment fully registry-driven via def.floorplan.
const floorplanRoofEntries = useMemo<FloorplanRoofEntry[]>(() => [], [])
// Selection lookups against the legacy entry lists. The active-level
// door / window / item / spawn paths are registry-driven now, so each
// source array is permanently empty and the lookups always return
// `null`. Selection chrome for those kinds comes from
// `FloorplanRegistryLayer` reading `viewState.selected`. Wrapping the
// `null` in `useMemo` (instead of a bare literal) preserves the
// declared type at consumer sites — bare `null` would narrow to
// `never` after `if (!entry) return`, breaking every `entry.field` read.
const selectedOpeningEntry = useMemo<OpeningPolygonEntry | null>(() => null, [])
const selectedItemEntry = useMemo<FloorplanItemEntry | null>(() => null, [])
const selectedSpawnEntry = useMemo<FloorplanSpawnEntry | null>(() => null, [])
const selectedElevatorEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
@@ -11149,6 +10502,29 @@ export function FloorplanPanel() {
[theme],
)
const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${theme}`, [theme])
// Subset of the legacy palette surfaced to registry-driven kinds via
// <FloorplanRenderProvider>. Mirrors `FloorplanPalette` in `@pascal-app/
// core` — keep slot names + meanings in sync.
const floorplanRegistryPalette = useMemo<FloorplanRenderContextValue['palette']>(
() => ({
selectedStroke: palette.selectedStroke,
selectedFill: palette.selectedFill,
selectedHatch: palette.selectedStroke,
wallHoverStroke: palette.wallHoverStroke,
endpointHandleFill: palette.endpointHandleFill,
endpointHandleStroke: palette.endpointHandleStroke,
endpointHandleHoverStroke: palette.endpointHandleHoverStroke,
endpointHandleActiveFill: palette.endpointHandleActiveFill,
endpointHandleActiveStroke: palette.endpointHandleActiveStroke,
curveHandleFill: palette.curveHandleFill,
curveHandleStroke: palette.curveHandleStroke,
curveHandleHoverStroke: palette.curveHandleHoverStroke,
measurementStroke: palette.measurementStroke,
measurementLabelBackground: theme === 'dark' ? '#0f172a' : '#ffffff',
measurementLabelText: theme === 'dark' ? '#e2e8f0' : '#171717',
}),
[palette, theme],
)
const slabSelectionHatchId = useMemo(() => `floorplan-slab-selection-hatch-${theme}`, [theme])
const gridSteps = useMemo(
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
@@ -13613,14 +12989,11 @@ export function FloorplanPanel() {
return
}
if (isFloorplanGridInteractionActive) {
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event)
setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
)
return
}
// Slab / zone polygon build — local draft state + grid emit, same
// reordering rationale as `handleBackgroundPlacementClick`: must
// run BEFORE the `isFloorplanGridInteractionActive` catch-all so
// the local polygon-draft state actually updates as the cursor
// moves (the catch-all would otherwise swallow the move event).
if (isPolygonBuildActive) {
const snappedPoint = snapPolygonDraftPoint({
point: planPoint,
@@ -13628,6 +13001,10 @@ export function FloorplanPanel() {
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
})
// Emit `grid:move` so the registry-driven slab tool also tracks
// the cursor (its 3D preview needs it).
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) => {
const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint))
if (hasChanged && activePolygonDraftPoints.length > 0) {
@@ -13638,6 +13015,19 @@ export function FloorplanPanel() {
return
}
// Wall build also needs to run before the catch-all — see the
// wall branch in `handleBackgroundPlacementClick` for the same
// restructuring. The wall branch lives further below in this
// handler (`if (!isWallBuildActive) ... setDraftEnd(...)`); the
// grid emit is inlined there.
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event)
setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
)
return
}
if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
@@ -13694,6 +13084,10 @@ export function FloorplanPanel() {
angleSnap: Boolean(draftStart) && !shiftPressed,
})
// Emit `grid:move` so the registry-driven wall tool's 3D preview
// tracks the cursor. The local draftEnd update below is what
// drives the 2D draft polygon — both views update in parallel.
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint(snappedPoint)
if (!draftStart) {
@@ -17720,8 +17114,20 @@ export function FloorplanPanel() {
their SVG via <FloorplanGeometryRenderer>. Sits above the
legacy inline content so newly-registered kinds (shelf
today) overlay on top until their inline equivalent is
removed in their Phase 5 migration PR. */}
<FloorplanRegistryLayer />
removed in their Phase 5 migration PR.
Wrapped in <FloorplanRenderProvider> so registry-driven
kinds receive the same themed palette / units-per-pixel
the legacy layers compute. The hatch pattern id is the
legacy wall hatch kinds that opt into selection hatch
fills reuse this <defs> pattern via fill="url(...)". */}
<FloorplanRenderProvider
hatchPatternId={wallSelectionHatchId}
palette={floorplanRegistryPalette}
unitsPerPixel={floorplanUnitsPerPixel}
>
<FloorplanRegistryLayer />
</FloorplanRenderProvider>
{/* Cursor-driven placement ghost for movingNode when the
active kind is registry-driven. Renders via a portal
into the floor-plan scene <g> (the data-floorplan-scene
@@ -17,6 +17,7 @@ import {
type RoofSegmentEvent,
resolveLevelId,
resolveMaterial,
type ShelfNode,
type SlabNode,
type StairEvent,
type StairNode,
@@ -341,7 +342,7 @@ function applyStairPaintPreview(
}
function applySingleSurfacePaintPreview(
node: FenceNode | ColumnNode | SlabNode | CeilingNode,
node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
if (node.type === 'ceiling') {
@@ -409,6 +410,23 @@ function applySingleSurfacePaintPreview(
}
}
if (node.type === 'shelf') {
// Shelf is a registered Group, not a Mesh. Traverse children and
// preview-swap every child mesh — same approach `column` uses.
if (!registeredObject) return null
const restores: PaintPreviewCleanup[] = []
registeredObject.traverse((object) => {
if (!(object as Mesh).isMesh) return
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
if (!mesh) return null
if (node.type === 'slab') {
@@ -934,7 +952,8 @@ export const SelectionManager = () => {
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling'
node.type === 'ceiling' ||
node.type === 'shelf'
) {
const compatible = hasActivePaintMaterial(activePaintMaterial)
@@ -949,7 +968,7 @@ export const SelectionManager = () => {
.updateNode(
node.id as AnyNodeId,
buildSingleSurfaceMaterialPatch<
FenceNode | ColumnNode | SlabNode | CeilingNode
FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
)
}
@@ -957,7 +976,7 @@ export const SelectionManager = () => {
preview: compatible
? () =>
applySingleSurfacePaintPreview(
node as FenceNode | ColumnNode | SlabNode | CeilingNode,
node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
activePaintMaterial,
)
: () => previewCursor('not-allowed'),
@@ -1193,7 +1212,10 @@ export const SelectionManager = () => {
}
if (
(node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') &&
(node.type === 'fence' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'shelf') &&
nodeToSelect.type === node.type
) {
setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
@@ -207,7 +207,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const restoreNodeVisibility = (() => {
const saved = new Map<THREE.Object3D, boolean>()
for (const type of ['scan', 'guide'] as const) {
const ids = sceneRegistry.byType[type]
const ids = sceneRegistry.byType[type]!
ids.forEach((id) => {
const node = sceneRegistry.nodes.get(id)
if (node) {
@@ -179,12 +179,11 @@ export function useFloorplanBackgroundPlacement({
return true
}
if (isFloorplanGridInteractionActive) {
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
setCursorPoint(snappedPoint)
return true
}
// Slab / zone polygon build — local draft state + grid emit.
// Must run BEFORE the `isFloorplanGridInteractionActive` catch-all
// (since slab is registry-driven, the catch-all would otherwise
// swallow the click and skip local draft state updates — leaving
// the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) {
const snappedPoint = snapPolygonDraftPoint({
point: planPoint,
@@ -192,6 +191,13 @@ export function useFloorplanBackgroundPlacement({
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
})
// Emit the grid event so the registry-driven slab tool also
// sees the click (parity with ceiling / fence / roof branches
// above). Zone has no registry tool — emit-or-not is irrelevant.
if (!isZoneBuildActive) {
emitFloorplanGridEvent('click', snappedPoint, event)
}
if (isZoneBuildActive) {
handleZonePlacementPoint(snappedPoint)
} else {
@@ -200,19 +206,34 @@ export function useFloorplanBackgroundPlacement({
return true
}
if (!isWallBuildActive) {
return false
// Wall placement — local draft state + grid emit. Same reasoning
// as slab above: wall is registry-driven, so without this branch
// the catch-all would swallow the click and the local draftStart
// / draftEnd state in the floor plan would never update, leaving
// the dashed-line draft preview invisible.
if (isWallBuildActive) {
const snappedPoint = snapWallDraftPoint({
point: planPoint,
walls,
start: draftStart ?? undefined,
angleSnap: Boolean(draftStart) && !shiftPressed,
})
emitFloorplanGridEvent('click', snappedPoint, event)
handleWallPlacementPoint(snappedPoint)
return true
}
const snappedPoint = snapWallDraftPoint({
point: planPoint,
walls,
start: draftStart ?? undefined,
angleSnap: Boolean(draftStart) && !shiftPressed,
})
// Generic catch-all — registry-driven tool whose kind has no
// local floor-plan draft handler (column / spawn / shelf / etc.).
// The tool's `grid:click` subscriber owns the placement.
if (isFloorplanGridInteractionActive) {
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
setCursorPoint(snappedPoint)
return true
}
handleWallPlacementPoint(snappedPoint)
return true
return false
},
[
activePolygonDraftPoints,
@@ -46,7 +46,7 @@ export const CeilingSystem = () => {
}
}
const ceilings = sceneRegistry.byType.ceiling
const ceilings = sceneRegistry.byType.ceiling!
ceilings.forEach((ceiling) => {
const mesh = sceneRegistry.nodes.get(ceiling)
if (mesh) {
@@ -1,43 +0,0 @@
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingBoundaryEditorProps {
ceilingId: CeilingNode['id']
}
/**
* Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ceilingId }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(ceilingId, { polygon: newPolygon })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, updateNode, setSelection],
)
if (!ceiling?.polygon || ceiling.polygon.length < 3) return null
return (
<PolygonEditor
allowEdgeMove
color="#d4d4d4"
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
minVertices={3}
onPolygonChange={handlePolygonChange}
polygon={ceiling.polygon}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -1,49 +0,0 @@
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingHoleEditorProps {
ceilingId: CeilingNode['id']
holeIndex: number
}
/**
* Ceiling hole editor - allows editing a specific hole polygon within a ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId, holeIndex }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const holes = ceiling?.holes || []
const hole = holes[holeIndex]
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
const updatedHoles = [...holes]
updatedHoles[holeIndex] = newPolygon
updateNode(ceilingId, { holes: updatedHoles })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, holeIndex, holes, updateNode, setSelection],
)
if (!(ceiling && hole) || hole.length < 3) return null
return (
<PolygonEditor
allowEdgeMove
allowPolygonMove
color="#ef4444"
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
minVertices={3}
onPolygonChange={handlePolygonChange}
polygon={hole}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -1,465 +0,0 @@
import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { mix, positionLocal } from 'three/tsl'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02
/**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
*/
const calculateSnapPoint = (
lastPoint: [number, number],
currentPoint: [number, number],
): [number, number] => {
const [x1, y1] = lastPoint
const [x, y] = currentPoint
const dx = x - x1
const dy = y - y1
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
// Calculate distances to horizontal, vertical, and diagonal lines
const horizontalDist = absDy
const verticalDist = absDx
const diagonalDist = Math.abs(absDx - absDy)
// Find the minimum distance to determine which axis to snap to
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
if (minDist === diagonalDist) {
// Snap to 45° diagonal
const diagonalLength = Math.min(absDx, absDy)
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
}
if (minDist === horizontalDist) {
// Snap to horizontal
return [x, y1]
}
// Snap to vertical
return [x1, y]
}
/**
* Creates a ceiling with the given polygon points and returns its ID
*/
const commitCeilingDrawing = (
levelId: LevelNode['id'],
points: Array<[number, number]>,
): string => {
const { createNode, nodes } = useScene.getState()
// Count existing ceilings for naming
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
const name = `Ceiling ${ceilingCount + 1}`
const ceiling = CeilingNode.parse({
name,
polygon: points,
})
createNode(ceiling, levelId)
sfxEmitter.emit('sfx:structure-build')
return ceiling.id
}
export const CeilingTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const gridCursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const groundMainLineRef = useRef<Line>(null!)
const groundClosingLineRef = useRef<Line>(null!)
const verticalLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const [points, setPoints] = useState<Array<[number, number]>>([])
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY
const verticalGeo = useMemo(
() =>
new BufferGeometry().setFromPoints([
new Vector3(0, 0, 0),
new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0),
]),
[],
)
// opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0
const gradientOpacityNode = useMemo(
() => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
[],
)
// Update cursor position and lines on grid move
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && gridCursorRef.current)) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.localPosition[1])
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
const gridY = event.localPosition[1] + GRID_OFFSET
// Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1]
const displayPoint =
shiftPressed.current || !lastPoint
? gridPosition
: calculateSnapPoint(lastPoint, gridPosition)
setSnappedCursorPosition(displayPoint)
// Play snap sound when the snapped position actually changes (only when drawing)
if (
points.length > 0 &&
previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
displayPoint[1] !== previousSnappedPointRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
if (verticalLineRef.current) {
verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
}
}
const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Use the last displayed snapped position (respects Shift state from onGridMove)
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
// Check if clicking on the first point to close the shape
const firstPoint = points[0]
if (
points.length >= 3 &&
firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
// Create the ceiling and select it
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
} else {
// Add point to polygon
setPoints([...points, clickPoint])
}
}
const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Need at least 3 points to form a polygon
if (points.length >= 3) {
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
}
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
}
}, [currentLevelId, points, cursorPosition, setSelection])
// Update line geometries when points change
useEffect(() => {
if (!(mainLineRef.current && closingLineRef.current)) return
if (points.length === 0) {
mainLineRef.current.visible = false
closingLineRef.current.visible = false
return
}
const ceilingY = levelY + CEILING_HEIGHT
const snappedCursor = snappedCursorPosition
// Build main line points
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
const gridY = levelY + GRID_OFFSET
const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))
groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))
// Update main line
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true
groundMainLineRef.current.geometry.dispose()
groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
groundMainLineRef.current.visible = true
} else {
mainLineRef.current.visible = false
groundMainLineRef.current.visible = false
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && firstPoint) {
const closingPoints = [
new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
const groundClosingPoints = [
new Vector3(snappedCursor[0], gridY, snappedCursor[1]),
new Vector3(firstPoint[0], gridY, firstPoint[1]),
]
groundClosingLineRef.current.geometry.dispose()
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(
groundClosingPoints,
)
groundClosingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
groundClosingLineRef.current.visible = false
}
}, [points, snappedCursorPosition, levelY])
// Create preview shape when we have 3+ points
const previewShape = useMemo(() => {
if (points.length < 3) return null
const snappedCursor = snappedCursorPosition
const allPoints = [...points, snappedCursor]
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
// - Shape X -> World X
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
const firstPt = allPoints[0]
if (!firstPt) return null
const shape = new Shape()
shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i]
if (pt) {
shape.lineTo(pt[0], -pt[1])
}
}
shape.closePath()
return shape
}, [points, snappedCursorPosition])
return (
<group>
{/* Cursor at ceiling height */}
<CursorSphere ref={cursorRef} />
{/* Grid-level cursor indicator */}
<mesh
layers={EDITOR_LAYER}
ref={gridCursorRef}
renderOrder={2}
rotation={[-Math.PI / 2, 0, 0]}
>
<ringGeometry args={[0.15, 0.2, 32]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
depthWrite={true}
opacity={0.5}
side={DoubleSide}
transparent
/>
</mesh>
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
{/* @ts-ignore */}
<line geometry={verticalGeo} layers={EDITOR_LAYER} ref={verticalLineRef} renderOrder={1}>
<lineBasicNodeMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
opacityNode={gradientOpacityNode}
transparent
/>
</line>
{/* Preview fill (Top) */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + CEILING_HEIGHT, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Preview fill (Ground) */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + GRID_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.1}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Main line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={mainLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
</line>
{/* Closing line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={closingLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.5}
transparent
/>
</line>
{/* Ground main line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={groundMainLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
linewidth={3}
opacity={0.3}
transparent
/>
</line>
{/* Ground closing line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={groundClosingLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.15}
transparent
/>
</line>
{/* Point markers */}
{points.map(([x, z], index) => (
<CursorSphere
color="#818cf8"
key={index}
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
showTooltip={false}
/>
))}
</group>
)
}
@@ -1,264 +0,0 @@
'use client'
import {
type AnyNodeId,
type CeilingNode,
emitter,
type GridEvent,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
deltaZ: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
}
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
const originalHolesRef = useRef(
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const previousCursorPosRef = useRef<[number, number, number] | null>(null)
const previousDeltaRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{
polygon: Array<[number, number]>
holes: Array<Array<[number, number]>>
} | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const center = getPolygonCenter(node.polygon)
return [center[0], node.height ?? 2.5, center[1]]
})
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
) => {
previewRef.current = { polygon, holes }
setPreviewPolygon(polygon)
setPreviewHoles(holes)
const center = getPolygonCenter(polygon)
const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
if (
!previousCursorPosRef.current ||
previousCursorPosRef.current[0] !== nextCursorPos[0] ||
previousCursorPosRef.current[1] !== nextCursorPos[1] ||
previousCursorPosRef.current[2] !== nextCursorPos[2]
) {
previousCursorPosRef.current = nextCursorPos
setCursorLocalPos(nextCursorPos)
}
useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId)
}
const restoreOriginal = () => {
setPreviewPolygon(originalPolygon)
setPreviewHoles(originalHoles)
useScene.getState().updateNode(node.id, {
holes: originalHoles,
polygon: originalPolygon,
})
useScene.getState().markDirty(node.id as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
if (
previousDeltaRef.current &&
previousDeltaRef.current[0] === deltaX &&
previousDeltaRef.current[1] === deltaZ
) {
return
}
previousDeltaRef.current = [deltaX, deltaZ]
applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(node.id, {
polygon: originalPolygon,
holes: originalHoles,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [node.id] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node.height, node.id])
const previewFillGeometry = useMemo(
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
[previewHoles, previewPolygon],
)
const previewOutlineGeometry = useMemo(
() => createCeilingOutlineGeometry(previewPolygon),
[previewPolygon],
)
return (
<group>
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
<meshBasicMaterial
color="#f5f5f4"
depthWrite={false}
opacity={0.3}
side={DoubleSide}
transparent
/>
</mesh>
{/* @ts-ignore */}
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
</line>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
function createCeilingPreviewGeometry(
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
): BufferGeometry {
if (polygon.length < 3) return new BufferGeometry()
const shape = new Shape()
const [firstX, firstZ] = polygon[0]!
shape.moveTo(firstX, -firstZ)
for (let i = 1; i < polygon.length; i++) {
const [x, z] = polygon[i]!
shape.lineTo(x, -z)
}
shape.closePath()
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
const hole = new Path()
const [hx, hz] = holePolygon[0]!
hole.moveTo(hx, -hz)
for (let i = 1; i < holePolygon.length; i++) {
const [x, z] = holePolygon[i]!
hole.lineTo(x, -z)
}
hole.closePath()
shape.holes.push(hole)
}
const geometry = new ShapeGeometry(shape)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
return geometry
}
function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
const geometry = new BufferGeometry()
if (polygon.length < 2) return geometry
const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
const [firstX, firstZ] = polygon[0]!
points.push(new Vector3(firstX, 0, firstZ))
geometry.setFromPoints(points)
return geometry
}
@@ -1,105 +0,0 @@
import '../../../three-types'
import {
type AnyNodeId,
ColumnNode,
type ColumnNode as ColumnNodeType,
emitter,
type GridEvent,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useCallback, useEffect, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const roundToHalf = (value: number) => Math.round(value * 2) / 2
export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
let committed = false
const applyPreview = (position: [number, number, number]) => {
setPreviewPosition(position)
useLiveTransforms.getState().set(node.id, {
position,
rotation: node.rotation,
})
sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
}
const onGridMove = (event: GridEvent) => {
applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
}
const onGridClick = (event: GridEvent) => {
const position: [number, number, number] = [
roundToHalf(event.localPosition[0]),
0,
roundToHalf(event.localPosition[2]),
]
const nodeId = (node as { id?: ColumnNodeType['id'] }).id
if (nodeId && useScene.getState().nodes[nodeId]) {
committed = true
useLiveTransforms.getState().clear(nodeId)
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { position })
} else if (node.parentId) {
const column = ColumnNode.parse({
...node,
id: undefined,
metadata: {},
position,
})
committed = true
useScene.temporal.getState().resume()
useScene.getState().createNode(column, node.parentId as AnyNodeId)
}
useLiveTransforms.getState().clear(node.id)
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
sceneRegistry.nodes
.get(node.id)
?.position.set(node.position[0], node.position[1], node.position[2])
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id)
if (!committed) {
sceneRegistry.nodes
.get(node.id)
?.position.set(node.position[0], node.position[1], node.position[2])
useScene.temporal.getState().resume()
}
}
}, [exitMoveMode, node])
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
}
@@ -1,110 +0,0 @@
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.
*/
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 door center X so it stays fully within wall bounds.
* Y is always height/2 — doors sit at floor level.
*/
export function clampToWall(
wallNode: WallNode,
localX: 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 clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
const clampedY = height / 2 // Doors always sit at floor level
return { clampedX, clampedY }
}
/**
* Checks if a proposed door position overlaps any existing wall children.
* Handles item, window, and door types.
*/
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
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]
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
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
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
}
@@ -1,324 +0,0 @@
import {
type AnyNodeId,
DoorNode,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
} from '@pascal-app/core'
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 { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
/**
* Door tool — places DoorNodes on walls only.
* Doors always sit at floor level (clampedY = height/2).
*/
export const DoorTool: React.FC = () => {
const draftRef = useRef<DoorNode | 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
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
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 width = 0.9
const height = 2.1
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
const node = DoorNode.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
}
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 width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
const { clampedX, clampedY } = clampToWall(event.node, localX, 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
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
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
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
const levelId = getLevelId()
const state = useScene.getState()
const doorCount = Object.values(state.nodes).filter((n) => {
if (n.type !== 'door') return false
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return wall?.parentId === levelId
}).length
const name = `Door ${doorCount + 1}`
const node = DoorNode.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,
doorCategory: draft.doorCategory,
doorType: draft.doorType,
leafCount: draft.leafCount,
operationState: draft.operationState,
slideDirection: draft.slideDirection,
trackStyle: draft.trackStyle,
garagePanelCount: draft.garagePanelCount,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
threshold: draft.threshold,
thresholdHeight: draft.thresholdHeight,
hingesSide: draft.hingesSide,
swingDirection: draft.swingDirection,
segments: draft.segments,
handle: draft.handle,
handleHeight: draft.handleHeight,
handleSide: draft.handleSide,
doorCloser: draft.doorCloser,
panicBar: draft.panicBar,
panicBarHeight: draft.panicBarHeight,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
sfxEmitter.emit('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: door outline (default 0.9 × 2.1 × 0.07)
const boxGeo = new BoxGeometry(0.9, 2.1, 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>
)
}
@@ -1,412 +0,0 @@
import {
type AnyNodeId,
DoorNode,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
useScene,
type WallEvent,
} from '@pascal-app/core'
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 { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
const cursorGroupRef = useRef<Group>(null!)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
const meta =
typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null
? (movingDoorNode.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const original = {
position: [...movingDoorNode.position] as [number, number, number],
rotation: [...movingDoorNode.rotation] as [number, number, number],
side: movingDoorNode.side,
parentId: movingDoorNode.parentId,
wallId: movingDoorNode.wallId,
metadata: movingDoorNode.metadata,
}
if (!isNew) {
useScene.getState().updateNode(movingDoorNode.id, {
metadata: { ...meta, isTransient: true },
})
}
let currentWallId: string | null = movingDoorNode.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 getPlacementOrientation = (event: WallEvent) => {
const faceSide = getSideFromNormal(event.normal)
const side = movingDoorNode.side ?? faceSide
const rotationOffset = side !== faceSide ? Math.PI : 0
return {
side,
itemRotation: calculateItemRotation(event.normal) + rotationOffset,
cursorRotation:
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
}
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
movingDoorNode.width,
movingDoorNode.height,
)
const prevWallId = currentWallId
currentWallId = event.node.id
useScene.getState().updateNode(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
useLiveTransforms.getState().set(movingDoorNode.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,
movingDoorNode.width,
movingDoorNode.height,
movingDoorNode.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
}
if (event.node.parentId !== getLevelId()) return
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
movingDoorNode.width,
movingDoorNode.height,
)
if (currentWallId !== event.node.id) {
// Wall changed mid-move: must updateNode to reparent
useScene.getState().updateNode(movingDoorNode.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 doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
if (doorMesh) {
doorMesh.position.set(clampedX, clampedY, 0)
doorMesh.rotation.set(0, itemRotation, 0)
doorMesh.updateMatrixWorld(true)
}
}
useLiveTransforms.getState().set(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: itemRotation,
})
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
event.node.id,
clampedX,
clampedY,
movingDoorNode.width,
movingDoorNode.height,
movingDoorNode.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
if (event.node.parentId !== getLevelId()) return
const { side, itemRotation } = getPlacementOrientation(event)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
movingDoorNode.width,
movingDoorNode.height,
)
const valid = !hasWallChildOverlap(
event.node.id,
clampedX,
clampedY,
movingDoorNode.width,
movingDoorNode.height,
movingDoorNode.id,
)
if (!valid) return
let placedId: string
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
useScene.temporal.getState().resume()
const cloned = structuredClone(movingDoorNode) as any
delete cloned.id
const node = DoorNode.parse({
...cloned,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
placedId = node.id
} else {
useScene.getState().updateNode(movingDoorNode.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(movingDoorNode.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 = movingDoorNode.id
}
markWallDirty(event.node.id)
useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode()
event.stopPropagation()
}
const onWallLeave = () => {
hideCursor()
useLiveTransforms.getState().clear(movingDoorNode.id)
if (isNew) return
if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId)
}
currentWallId = original.parentId
useScene.getState().updateNode(movingDoorNode.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(movingDoorNode.id)
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId)
} else {
useScene.getState().updateNode(movingDoorNode.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 () => {
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
| DoorNode
| undefined
const currentMeta = current?.metadata as Record<string, unknown> | undefined
if (currentMeta?.isTransient) {
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId)
} else {
useScene.getState().updateNode(movingDoorNode.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(movingDoorNode.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)
}
}, [movingDoorNode, exitMoveMode])
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(
movingDoorNode.width,
movingDoorNode.height,
movingDoorNode.frameDepth ?? 0.07,
)
const geo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return geo
}, [movingDoorNode])
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
</group>
)
}
@@ -1,178 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
pauseSceneHistory,
resumeSceneHistory,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = useCallback(() => {
useEditor.getState().setCurvingFence(null)
}, [])
useEffect(() => {
const nodeId = node.id
const originalCurveOffset = originalCurveOffsetRef.current
const chord = getWallChordFrame(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyPreview = (curveOffset: number) => {
if (previewOffsetRef.current === curveOffset) {
return
}
previewOffsetRef.current = curveOffset
const nextNode = {
...node,
curveOffset,
}
const handlePoint = getWallMidpointHandlePoint(nextNode)
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const restoreOriginal = () => {
if (previewOffsetRef.current === originalCurveOffset) {
return
}
previewOffsetRef.current = originalCurveOffset
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const snapStep = getWallGridStep()
const localX = shiftPressedRef.current
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if (
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousCurveOffsetRef.current = nextCurveOffset
applyPreview(nextCurveOffset)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const curveOffset = previewOffsetRef.current
wasCommitted = true
if (curveOffset !== originalCurveOffset) {
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
resumeSceneHistory(useScene)
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
pauseSceneHistory(useScene)
}
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitCurveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitCurveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitCurveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,346 +0,0 @@
import {
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import {
createFenceOnCurrentLevel,
type FencePlanPoint,
snapFenceDraftPoint,
} from './fence-drafting'
const FENCE_PREVIEW_HEIGHT = 1.8
const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22
const DRAFT_ANGLE_LABEL_Y = 0.28
type DraftAngleLabel = {
id: string
label: string
position: [number, number, number]
}
type DraftMeasurementState = {
lengthLabel: string
lengthPosition: [number, number, number]
angleLabels: DraftAngleLabel[]
} | null
type SegmentLike = {
id: string
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
function getDraftAngleLabels(
start: FencePlanPoint,
end: FencePlanPoint,
segments: SegmentLike[],
): DraftAngleLabel[] {
const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
const endpoints = [
{ id: 'start', point: start, draftVector: draftFromStart },
{ id: 'end', point: end, draftVector: draftFromEnd },
]
const labels: DraftAngleLabel[] = []
for (const endpoint of endpoints) {
const connectedSegment = segments.find((segment) =>
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
)
if (!connectedSegment) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
if (angle === null) continue
labels.push({
id: endpoint.id,
label: formatAngleRadians(angle),
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
})
}
return labels
}
function getDraftMeasurementState(
start: FencePlanPoint,
end: FencePlanPoint,
segments: SegmentLike[],
unit: 'metric' | 'imperial',
): DraftMeasurementState {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 0.01) return null
return {
lengthLabel: formatMeasurement(length, unit),
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
angleLabels: getDraftAngleLabels(start, end, segments),
}
}
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
return [
...walls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...fences.map((fence) => ({
id: fence.id,
start: fence.start,
end: fence.end,
curveOffset: fence.curveOffset,
})),
]
}
const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < 0.01) {
mesh.visible = false
return
}
mesh.visible = true
direction.normalize()
const shape = new Shape()
shape.moveTo(0, 0)
shape.lineTo(length, 0)
shape.lineTo(length, FENCE_PREVIEW_HEIGHT)
shape.lineTo(0, FENCE_PREVIEW_HEIGHT)
shape.closePath()
const geometry = new ShapeGeometry(shape)
const angle = -Math.atan2(direction.z, direction.x)
mesh.position.set(start.x, start.y, start.z)
mesh.rotation.y = angle
if (mesh.geometry) {
mesh.geometry.dispose()
}
mesh.geometry = geometry
}
const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } => {
const currentLevelId = useViewer.getState().selection.levelId
const { nodes } = useScene.getState()
if (!currentLevelId) return { walls: [], fences: [] }
const levelNode = nodes[currentLevelId]
if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] }
const children = (levelNode as LevelNode).children.map((childId) => nodes[childId])
return {
walls: children.filter((node): node is WallNode => node?.type === 'wall'),
fences: children.filter((node): node is FenceNode => node?.type === 'fence'),
}
}
export const FenceTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const cursorRef = useRef<Group>(null)
const previewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
useEffect(() => {
let previousFenceEnd: [number, number] | null = null
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && previewRef.current)) return
const { walls, fences } = getCurrentLevelElements()
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
if (buildingState.current === 1) {
const snappedLocal = snapFenceDraftPoint({
point: localPoint,
walls,
fences,
start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current,
})
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current)
const currentFenceEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if (
previousFenceEnd &&
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousFenceEnd = currentFenceEnd
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
setDraftMeasurement(
getDraftMeasurementState(
[startingPoint.current.x, startingPoint.current.z],
snappedLocal,
getReferenceSegments(walls, fences),
unit,
),
)
} else {
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
setDraftMeasurement(null)
}
}
const onGridClick = (event: GridEvent) => {
const { walls, fences } = getCurrentLevelElements()
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
if (buildingState.current === 0) {
const snappedStart = snapFenceDraftPoint({ point: localClick, walls, fences })
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
previewRef.current.visible = true
setDraftMeasurement(null)
} else {
const snappedEnd = snapFenceDraftPoint({
point: localClick,
walls,
fences,
start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current,
})
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
previewRef.current.visible = false
buildingState.current = 0
setDraftMeasurement(null)
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
const onCancel = () => {
if (buildingState.current === 1) {
markToolCancelConsumed()
buildingState.current = 0
previewRef.current.visible = false
setDraftMeasurement(null)
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [unit])
return (
<group>
<CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
<shapeGeometry />
<meshBasicMaterial
color="#ffffff"
depthTest={false}
depthWrite={false}
opacity={0.45}
side={DoubleSide}
transparent
/>
</mesh>
{draftMeasurement && (
<>
<DraftMeasurementLabel
label={draftMeasurement.lengthLabel}
position={draftMeasurement.lengthPosition}
/>
{draftMeasurement.angleLabels.map((angleLabel) => (
<DraftMeasurementLabel
key={angleLabel.id}
label={angleLabel.label}
position={angleLabel.position}
/>
))}
</>
)}
</group>
)
}
function DraftMeasurementLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -1,425 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import { isWallLongEnough } from '../wall/wall-drafting'
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return (
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
)
}
type SegmentLike = {
id: string
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
type AngleLabelState = {
label: string
position: [number, number, number]
} | null
function getEndpointAngleLabel(args: {
preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number }
segments: SegmentLike[]
nodeId: FenceNode['id']
}): AngleLabelState {
const { preview, segments, nodeId } = args
const endpoints = [
{
point: preview.start,
},
{
point: preview.end,
},
]
const targetSegment: SegmentLike = {
id: nodeId,
start: preview.start,
end: preview.end,
curveOffset: preview.curveOffset,
}
for (const endpoint of endpoints) {
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
if (!targetReference) continue
const connectedSegment = segments.find(
(segment) =>
segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
)
if (!connectedSegment) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
if (angle === null) continue
return {
label: formatAngleRadians(angle),
position: [endpoint.point[0], 0.34, endpoint.point[1]],
}
}
return null
}
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
return [
...walls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...fences.map((fence) => ({
id: fence.id,
start: fence.start,
end: fence.end,
curveOffset: fence.curveOffset,
})),
]
}
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id']
fenceParentId: string | null
linkedPoint: FencePlanPoint
}) {
const { fenceId, fenceParentId, linkedPoint } = args
const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'fence' && node.id !== fenceId)) {
continue
}
if ((node.parentId ?? null) !== fenceParentId) {
continue
}
if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as FencePlanPoint,
end: [...node.end] as FencePlanPoint,
curveOffset: node.curveOffset,
})
}
return snapshots
}
function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[],
linkedPoint: FencePlanPoint,
nextLinkedPoint: FencePlanPoint,
) {
return linkedFences.map((fence) => ({
id: fence.id,
curveOffset: fence.curveOffset,
start: samePoint(fence.start, linkedPoint) ? nextLinkedPoint : fence.start,
end: samePoint(fence.end, linkedPoint) ? nextLinkedPoint : fence.end,
}))
}
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.fence.id)
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
const originalMovingPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.start] as FencePlanPoint)
: ([...target.fence.end] as FencePlanPoint),
)
const fixedPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.end] as FencePlanPoint)
: ([...target.fence.start] as FencePlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({
fenceId: target.fence.id,
fenceParentId: target.fence.parentId ?? null,
linkedPoint: target.endpoint === 'start' ? target.fence.start : target.fence.end,
}),
)
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
return [point[0], 0, point[1]]
})
const [altPressed, setAltPressed] = useState(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingFenceEndpoint(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const originalMovingPoint = originalMovingPointRef.current
const fixedPoint = fixedPointRef.current
const siblings = Object.values(useScene.getState().nodes)
const levelWalls = siblings.filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
const levelFences = siblings.filter(
(node): node is FenceNode =>
node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedFences
? []
: getLinkedFenceUpdates(linkedOriginalsRef.current, originalMovingPoint, movingPoint)
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
setAngleLabel(
getEndpointAngleLabel({
preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
nodeId,
}),
)
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
}
const restoreOriginal = (clearAngleLabel = true) => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
if (clearAngleLabel) {
setAngleLabel(null)
}
}
const onGridMove = (event: GridEvent) => {
const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const snappedPoint = snapFenceDraftPoint({
point: planPoint,
walls: levelWalls,
fences: levelFences,
start: fixedPoint,
angleSnap: !shiftPressedRef.current,
ignoreFenceIds: [nodeId],
})
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
applyPreview(snappedPoint, event.nativeEvent.altKey)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged = !(
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
resumeSceneHistory(useScene)
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalMovingPoint,
target.endpoint === 'start' ? preview.start : preview.end,
)),
])
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
setAngleLabel(null)
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
setAngleLabel(null)
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
if (event.key === 'Alt') {
altPressedRef.current = true
setAltPressed(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
if (event.key === 'Alt') {
altPressedRef.current = false
setAltPressed(false)
}
}
const onWindowBlur = () => {
shiftPressedRef.current = false
altPressedRef.current = false
setAltPressed(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
return () => {
if (!wasCommitted) {
restoreOriginal(false)
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
}
}, [exitMoveMode, target])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
: 'border-border/70 bg-background/90 text-foreground/80'
}`}
>
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
</div>
</div>
</Html>
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
</group>
)
}
function EndpointAngleLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -1,302 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
sceneRegistry,
useLiveTransforms,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import type * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { snapFenceDraftPoint } from './fence-drafting'
function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: [number, number]
end: [number, number]
}
function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id']
fenceParentId: string | null
originalStart: [number, number]
originalEnd: [number, number]
}) {
const { fenceId, fenceParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'fence' && node.id !== fenceId)) {
continue
}
if ((node.parentId ?? null) !== fenceParentId) {
continue
}
if (
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as [number, number],
end: [...node.end] as [number, number],
})
}
return snapshots
}
function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[],
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
return linkedFences.map((fence) => ({
id: fence.id,
start: samePoint(fence.start, originalStart)
? nextStart
: samePoint(fence.start, originalEnd)
? nextEnd
: fence.start,
end: samePoint(fence.end, originalStart)
? nextStart
: samePoint(fence.end, originalEnd)
? nextEnd
: fence.end,
}))
}
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({
fenceId: node.id,
fenceParentId: node.parentId ?? null,
originalStart: node.start,
originalEnd: node.end,
}),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const centerX = (node.start[0] + node.end[0]) / 2
const centerZ = (node.start[1] + node.end[1]) / 2
return [centerX, 0, centerZ]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is WallNode => child?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause()
let wasCommitted = false
const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
if (!mesh) {
return
}
mesh.position.set(deltaX, 0, deltaZ)
}
const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
const originalCenterX = (fence.start[0] + fence.end[0]) / 2
const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
useLiveTransforms.getState().set(fence.id, {
position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
rotation: 0,
})
}
const clearPreviewState = () => {
setMeshOffset(nodeId, 0, 0)
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
setMeshOffset(linkedFence.id, 0, 0)
useLiveTransforms.getState().clear(linkedFence.id)
}
}
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
const deltaX = nextStart[0] - originalStart[0]
const deltaZ = nextStart[1] - originalStart[1]
setMeshOffset(nodeId, deltaX, deltaZ)
setFenceLiveTransform(node, deltaX, deltaZ)
for (const linkedFence of linkedOriginalsRef.current) {
setMeshOffset(linkedFence.id, deltaX, deltaZ)
setFenceLiveTransform(
{
...node,
id: linkedFence.id,
start: linkedFence.start,
end: linkedFence.end,
},
deltaX,
deltaZ,
)
}
}
const onGridMove = (event: GridEvent) => {
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
ignoreFenceIds: [nodeId],
})
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
applyPreview(nextStart, nextEnd)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true
useScene.temporal.getState().resume()
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
),
])
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id)
}
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
clearPreviewState()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (wasCommitted) {
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id)
}
} else {
clearPreviewState()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,32 +0,0 @@
import type { AssetInput } from '@pascal-app/core'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) {
const draftNode = useDraftNode()
const cursor = usePlacementCoordinator({
asset: selectedItem,
draftNode,
initDraft: (gridPosition) => {
if (selectedItem && !selectedItem.attachTo) {
draftNode.create(gridPosition, selectedItem)
}
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
return true
},
})
return <>{cursor}</>
}
export const ItemTool: React.FC = () => {
const selectedItem = useEditor((state) => state.selectedItem)
if (!selectedItem) return null
return <ItemPlacementContent selectedItem={selectedItem} />
}
@@ -1,132 +1,50 @@
import type {
AnyNodeId,
BuildingNode,
CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode,
FenceNode,
ItemNode,
RoofNode,
RoofSegmentNode,
SlabNode,
SpawnNode,
StairNode,
StairSegmentNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { nodeRegistry } from '@pascal-app/core'
import { Suspense } from 'react'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
import { MoveColumnTool } from '../column/move-column-tool'
import { MoveDoorTool } from '../door/move-door-tool'
import { MoveElevatorTool } from '../elevator/move-elevator-tool'
import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
import { MoveRoofTool } from '../roof/move-roof-tool'
import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
import { MoveSlabTool } from '../slab/move-slab-tool'
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
import { MoveWallTool } from '../wall/move-wall-tool'
import { MoveWindowTool } from '../window/move-window-tool'
import type { PlacementState } from './placement-types'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
function getInitialState(node: {
asset: { attachTo?: string }
parentId: string | null
}): PlacementState {
const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null }
}
if (attachTo === 'ceiling') {
return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null }
}
return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
}
function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
const draftNode = useDraftNode()
const meta =
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
? (movingNode.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const cursor = usePlacementCoordinator({
asset: movingNode.asset,
draftNode,
// Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
initialState: isNew
? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
: getInitialState(movingNode),
// Preserve the original item's scale so Y-position calculations use the correct height
defaultScale: isNew ? movingNode.scale : undefined,
initDraft: (gridPosition) => {
if (isNew) {
// Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.
// Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.
gridPosition.copy(new Vector3(...movingNode.position))
if (!movingNode.asset.attachTo) {
draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale)
}
} else {
draftNode.adopt(movingNode)
gridPosition.copy(new Vector3(...movingNode.position))
}
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
useEditor.getState().setMovingNode(null)
return false
},
onCancel: () => {
draftNode.destroy()
useEditor.getState().setMovingNode(null)
},
})
return <>{cursor}</>
}
/**
* MoveTool dispatcher. Routes to (in order):
*
* 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
* declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
* …).
* 2. `def.affordanceTools.move` — kind-owned move component
* (slab / ceiling / wall / fence / column / item / door / window).
* Lazy-loaded via `getRegistryAffordanceTool`.
* 3. The narrow set of kinds that still have legacy movers because no
* registry equivalent has been written yet (building / elevator /
* roof / stair). Each of these has bespoke move semantics that
* don't fit the generic mover and are not yet ported to a
* kind-owned affordance.
*/
export const MoveTool: React.FC<{
onNodeMoved?: (nodeId: AnyNodeId) => void
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onNodeMoved, onSpawnMoved }) => {
}> = ({ onNodeMoved }) => {
const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null
// Capability-driven dispatch. A registered kind opts INTO the generic
// mover by declaring `capabilities.movable` — that's the "I'm a simple
// translate-on-the-X/Z-plane node" signal (shelf, spawn, future
// single-position items). Kinds with bespoke move semantics (wall
// endpoint drag + linked-wall corner cascade + ALT-detach, fence
// endpoint drag + curve sagitta, slab polygon vertex edit, stair
// endpoint drag, etc.) deliberately OMIT `capabilities.movable` so
// this branch falls through to their legacy per-kind movers below.
//
// Without this guard, every registered kind would be force-routed
// through MoveRegistryNodeTool's "translate position" pattern,
// breaking wall / fence / slab / stair endpoint UX (the smart
// sims-style arrows that move the dragged endpoint while cascading
// to linked walls / re-anchoring hosted children / etc.).
const def = nodeRegistry.get(movingNode.type)
if (def?.capabilities?.movable) {
return <MoveRegistryNodeTool node={movingNode} />
}
// Phase 5 Stage D: registry-driven move affordance (kind-owned
// `DragAction` with bespoke semantics). Falls through to the legacy
// per-kind chain below when the kind hasn't ported its move tool.
const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
if (RegistryMove) {
return (
@@ -138,20 +56,11 @@ export const MoveTool: React.FC<{
if (movingNode.type === 'building')
return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'elevator')
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'spawn')
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} />
return null
}
@@ -6,12 +6,15 @@ import type {
GridEvent,
ItemEvent,
ItemNode,
ShelfEvent,
ShelfNode,
WallEvent,
WallNode,
} from '@pascal-app/core'
import {
getScaledDimensions,
isLowProfileItemSurface,
nodeRegistry,
sceneRegistry,
useScene,
} from '@pascal-app/core'
@@ -587,6 +590,156 @@ export const itemSurfaceStrategy = {
},
}
// ============================================================================
// SHELF SURFACE STRATEGY
// ============================================================================
/**
* Resolve the row Y closest to the cursor's local Y. Reads candidate row
* positions from the kind's `capabilities.surfaces.custom` — the shelf
* declaration emits one `SurfacePoint` per board's top surface. The
* strategy stays kind-agnostic at this level: any future "multi-board"
* kind that declares `surfaces.custom` with upward normals gets the
* same hit behaviour for free.
*/
function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
const def = nodeRegistry.get('shelf')
const custom = def?.capabilities?.surfaces?.custom
if (!custom) return null
const candidates = custom(shelfNode as AnyNode)
if (candidates.length === 0) return null
let best = candidates[0]
let bestDist = Math.abs(best!.position[1] - localY)
for (let i = 1; i < candidates.length; i++) {
const c = candidates[i]
if (!c) continue
const dist = Math.abs(c.position[1] - localY)
if (dist < bestDist) {
best = c
bestDist = dist
}
}
return best?.position[1] ?? null
}
export const shelfSurfaceStrategy = {
/**
* Handle shelf:enter — transition the draft onto the closest shelf
* row. Mirrors `itemSurfaceStrategy.enter` but reads candidate
* surface heights from the shelf kind's `surfaces.custom` (one Y per
* board) instead of `asset.surface.height`. Picks the row whose
* surface Y is nearest the cursor's local Y so the user can target a
* specific row by hovering near it.
*/
enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null {
if (ctx.asset.attachTo) return null
const shelfNode = event.node as ShelfNode
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
return null
}
if (!isUpwardShelfSurfaceHit(event)) return null
// Size check: draft footprint must fit on the shelf board (width × depth).
const ourDims = ctx.draftItem
? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
if (!shelfMesh) return null
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = shelfMesh.worldToLocal(worldPos)
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
const surfaceQuat = new Quaternion()
shelfMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
return {
stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
nodeUpdate: {
position: [x, rowY, z],
parentId: shelfNode.id,
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
},
cursorRotationY: ctx.currentCursorRotationY,
gridPosition: [x, rowY, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
stopPropagation: true,
}
},
/**
* Handle shelf:move — re-derive the closest row each tick so the user
* can slide between rows without leaving the shelf.
*/
move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null {
if (ctx.state.surface !== 'shelf-surface') return null
if (!(ctx.state.shelfId && ctx.draftItem)) return null
if (event.node.id !== ctx.state.shelfId) return null
const shelfNode = event.node as ShelfNode
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
if (!shelfMesh) return null
const ourDims = getScaledDimensions(ctx.draftItem)
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = shelfMesh.worldToLocal(worldPos)
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
return {
gridPosition: [x, rowY, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
cursorRotationY: ctx.currentCursorRotationY,
nodeUpdate: { position: [x, rowY, z] },
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle shelf:click — commit placement on the active row.
*/
click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null {
if (ctx.state.surface !== 'shelf-surface') return null
if (!(ctx.draftItem && ctx.state.shelfId)) return null
if (event.node.id !== ctx.state.shelfId) return null
return {
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.state.shelfId,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: null,
}
},
}
/** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
* for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
* via a tiny `ItemEvent`-shaped adapter — the function only reads
* `event.normal` + `event.object`. */
function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
}
// ============================================================================
// VALIDATION
// ============================================================================
@@ -603,6 +756,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
return ctx.state.surfaceItemId !== null
}
// Shelf surface: same — size check already happened on enter
if (ctx.state.surface === 'shelf-surface') {
return ctx.state.shelfId !== null
}
const attachTo = ctx.draftItem.asset.attachTo
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
@@ -12,7 +12,7 @@ import type { Vector3 } from 'three'
// PLACEMENT STATE
// ============================================================================
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface'
/**
* Tracks which surface the draft item is currently on.
@@ -23,6 +23,13 @@ export interface PlacementState {
wallId: string | null
ceilingId: string | null
surfaceItemId: string | null
/**
* Active shelf when `surface === 'shelf-surface'`. Items host on the
* shelf board closest to the cursor's local Y; the row index isn't
* stored separately because every move re-derives it from cursor
* position via `shelfRowSurfaceYs`.
*/
shelfId: string | null
}
// ============================================================================
@@ -179,9 +179,28 @@ export function useDraftNode(): DraftNodeHandle {
if (!draftRef.current) return
if (adoptedRef.current && originalStateRef.current) {
// Move mode: restore original state instead of deleting
// Move mode: restore original state instead of deleting — but only
// if no other system has already committed a new position for this
// node. The 2D `FloorplanRegistryMoveOverlay` commits via
// `useScene.updateNodes` before unmounting the legacy mover, and
// an unconditional restore here would wipe that commit. By
// comparing the live state to the snapshot we took in `adopt()`,
// we let an external committer's write stick.
const original = originalStateRef.current
const id = draftRef.current.id
const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined
const livePosition = live?.position
const externallyMoved =
!!livePosition &&
(livePosition[0] !== original.position[0] ||
livePosition[1] !== original.position[1] ||
livePosition[2] !== original.position[2])
if (externallyMoved) {
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
return
}
useScene.getState().updateNode(id, {
position: original.position,
@@ -7,6 +7,7 @@ import {
getScaledDimensions,
type ItemEvent,
resolveLevelId,
type ShelfEvent,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
@@ -41,6 +42,7 @@ import {
checkCanPlace,
floorStrategy,
itemSurfaceStrategy,
shelfSurfaceStrategy,
wallStrategy,
} from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types'
@@ -286,7 +288,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const gridPosition = useRef(new Vector3(0, 0, 0))
const lastRawPos = useRef(new Vector3(0, 0, 0))
const placementState = useRef<PlacementState>(
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
config.initialState ?? {
surface: 'floor',
wallId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
},
)
const shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null)
@@ -435,6 +443,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
wallId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
}
if (!asset.attachTo && placementState.current.surface === 'floor') {
gridPosition.current.y = 0
@@ -923,7 +932,67 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onItemClick = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
// Click on the draft item itself. R3F dispatches click events to
// the closest intersected mesh only — when the draft is hovering
// on a host (shelf / table / etc.) the draft's mesh is *above*
// the host's mesh, so the host's `${kind}:click` never fires.
// If we're currently hosting on a shelf-surface, treat the
// self-click as a commit on the active shelf so the user doesn't
// have to aim around the cursor preview to drop the item.
if (event.node.id === draftNode.current?.id) {
const ctx = getContext()
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
if (shelfNode && shelfNode.type === 'shelf') {
const synthetic = { ...event, node: shelfNode } as unknown as ItemEvent
const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
if (result) {
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
return
}
}
}
// Same self-click forwarding for item-surface hosts (tables,
// counters) — the draft mesh sits on top of the host mesh, so
// the host's own click event is blocked by the cursor preview.
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
if (hostNode && hostNode.type === 'item') {
const synthetic = { ...event, node: hostNode } as ItemEvent
const result = itemSurfaceStrategy.click(ctx, synthetic)
if (result) {
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
return
}
}
}
return
}
const result = itemSurfaceStrategy.click(getContext(), event)
if (!result) return
@@ -1065,6 +1134,98 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
}
// ---- Shelf Handlers ----
//
// Items can host on shelves the same way they host on tables and
// counters (item-surface). The shelf's `surfaces.custom` exposes one
// candidate Y per row; `shelfSurfaceStrategy` picks the closest one
// to the cursor's local-Y so the user can target a specific row.
const onShelfEnter = (event: ShelfEvent) => {
const result = shelfSurfaceStrategy.enter(getContext(), event)
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
}
}
const onShelfMove = (event: ShelfEvent) => {
const ctx = getContext()
if (ctx.state.surface !== 'shelf-surface') {
// Cursor entered via a move event without an enter — try
// transitioning in so the user doesn't need to mouse out + back
// in to start hosting.
const enterResult = shelfSurfaceStrategy.enter(ctx, event)
if (!enterResult) return
event.stopPropagation()
applyTransition(enterResult)
if (!draftNode.current) {
ensureDraft(enterResult)
} else if (enterResult.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
}
return
}
const result = shelfSurfaceStrategy.move(ctx, event)
if (!result) return
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
const ic = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.set(...result.gridPosition)
useLiveTransforms.getState().set(draft.id, {
position: result.cursorPosition,
rotation: result.cursorRotationY,
})
}
revalidate()
}
const onShelfLeave = (event: ShelfEvent) => {
if (placementState.current.surface !== 'shelf-surface') return
if (event.node.id !== placementState.current.shelfId) return
event.stopPropagation()
// Drop back to floor — same pattern as item-leave but without the
// detachItemSurfaceToFloor (no scaled rotation hand-off to deal
// with since the shelf rotation already composed cleanly).
Object.assign(placementState.current, { surface: 'floor', shelfId: null })
}
const onShelfClick = (event: ShelfEvent) => {
const result = shelfSurfaceStrategy.click(getContext(), event)
if (!result) return
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
// ---- Keyboard rotation ----
const ROTATION_STEP = Math.PI / 2
@@ -1239,6 +1400,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('ceiling:move', onCeilingMove)
emitter.on('ceiling:click', onCeilingClick)
emitter.on('ceiling:leave', onCeilingLeave)
emitter.on('shelf:enter', onShelfEnter)
emitter.on('shelf:move', onShelfMove)
emitter.on('shelf:click', onShelfClick)
emitter.on('shelf:leave', onShelfLeave)
return () => {
tearingDown = true
@@ -1263,6 +1428,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('ceiling:move', onCeilingMove)
emitter.off('ceiling:click', onCeilingClick)
emitter.off('ceiling:leave', onCeilingLeave)
emitter.off('shelf:enter', onShelfEnter)
emitter.off('shelf:move', onShelfMove)
emitter.off('shelf:click', onShelfClick)
emitter.off('shelf:leave', onShelfLeave)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
@@ -1,182 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
type SlabNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere'
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
deltaZ: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
}
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
const originalHolesRef = useRef(
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{
polygon: Array<[number, number]>
holes: Array<Array<[number, number]>>
} | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const center = getPolygonCenter(node.polygon)
return [center[0], 0, center[1]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is WallNode => child?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
) => {
previewRef.current = { polygon, holes }
const center = getPolygonCenter(polygon)
setCursorLocalPos([center[0], 0, center[1]])
useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId)
}
const restoreOriginal = () => {
useScene.getState().updateNode(node.id, {
holes: originalHoles,
polygon: originalPolygon,
})
useScene.getState().markDirty(node.id as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(node.id, {
polygon: originalPolygon,
holes: originalHoles,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [node.id] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node.id])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,43 +0,0 @@
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface SlabBoundaryEditorProps {
slabId: SlabNode['id']
}
/**
* Slab boundary editor - allows editing slab polygon vertices for a specific slab
* Uses the generic PolygonEditor component
*/
export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }) => {
const slabNode = useScene((state) => state.nodes[slabId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(slabId, { polygon: newPolygon })
// Re-assert selection so the slab stays selected after the edit
setSelection({ selectedIds: [slabId] })
},
[slabId, updateNode, setSelection],
)
if (!slab?.polygon || slab.polygon.length < 3) return null
return (
<PolygonEditor
allowEdgeMove
color="#a3a3a3"
levelId={resolveLevelId(slab, useScene.getState().nodes)}
minVertices={3}
onPolygonChange={handlePolygonChange}
polygon={slab.polygon}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
}
@@ -1,49 +0,0 @@
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface SlabHoleEditorProps {
slabId: SlabNode['id']
holeIndex: number
}
/**
* Slab hole editor - allows editing a specific hole polygon within a slab
* Uses the generic PolygonEditor component
*/
export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeIndex }) => {
const slabNode = useScene((state) => state.nodes[slabId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const holes = slab?.holes || []
const hole = holes[holeIndex]
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
const updatedHoles = [...holes]
updatedHoles[holeIndex] = newPolygon
updateNode(slabId, { holes: updatedHoles })
// Re-assert selection so the slab stays selected after the edit
setSelection({ selectedIds: [slabId] })
},
[slabId, holeIndex, holes, updateNode, setSelection],
)
if (!(slab && hole) || hole.length < 3) return null
return (
<PolygonEditor
allowEdgeMove
allowPolygonMove
color="#ef4444"
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
minVertices={3}
onPolygonChange={handlePolygonChange}
polygon={hole}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
}
@@ -1,322 +0,0 @@
import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const Y_OFFSET = 0.02
/**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
*/
const calculateSnapPoint = (
lastPoint: [number, number],
currentPoint: [number, number],
): [number, number] => {
const [x1, y1] = lastPoint
const [x, y] = currentPoint
const dx = x - x1
const dy = y - y1
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
// Calculate distances to horizontal, vertical, and diagonal lines
const horizontalDist = absDy
const verticalDist = absDx
const diagonalDist = Math.abs(absDx - absDy)
// Find the minimum distance to determine which axis to snap to
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
if (minDist === diagonalDist) {
// Snap to 45° diagonal
const diagonalLength = Math.min(absDx, absDy)
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
}
if (minDist === horizontalDist) {
// Snap to horizontal
return [x, y1]
}
// Snap to vertical
return [x1, y]
}
/**
* Creates a slab with the given polygon points and returns its ID
*/
const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
const { createNode, nodes } = useScene.getState()
// Count existing slabs for naming
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
const name = `Slab ${slabCount + 1}`
const slab = SlabNode.parse({
name,
polygon: points,
})
createNode(slab, levelId)
sfxEmitter.emit('sfx:structure-build')
return slab.id
}
export const SlabTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const [points, setPoints] = useState<Array<[number, number]>>([])
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Update cursor position and lines on grid move
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.localPosition[1])
// Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1]
const displayPoint =
shiftPressed.current || !lastPoint
? gridPosition
: calculateSnapPoint(lastPoint, gridPosition)
setSnappedCursorPosition(displayPoint)
// Play snap sound when the snapped position actually changes (only when drawing)
if (
points.length > 0 &&
previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
displayPoint[1] !== previousSnappedPointRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
}
const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Use the last displayed snapped position (respects Shift state from onGridMove)
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
// Check if clicking on the first point to close the shape
const firstPoint = points[0]
if (
points.length >= 3 &&
firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
// Create the slab and select it
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
} else {
// Add point to polygon
setPoints([...points, clickPoint])
}
}
const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Need at least 3 points to form a polygon
if (points.length >= 3) {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
}
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
}
}, [currentLevelId, points, cursorPosition, setSelection])
// Update line geometries when points change
useEffect(() => {
if (!(mainLineRef.current && closingLineRef.current)) return
if (points.length === 0) {
mainLineRef.current.visible = false
closingLineRef.current.visible = false
return
}
const y = levelY + Y_OFFSET
const snappedCursor = snappedCursorPosition
// Build main line points
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1]))
// Update main line
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true
} else {
mainLineRef.current.visible = false
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && firstPoint) {
const closingPoints = [
new Vector3(snappedCursor[0], y, snappedCursor[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
}
}, [points, snappedCursorPosition, levelY])
// Create preview shape when we have 3+ points
const previewShape = useMemo(() => {
if (points.length < 3) return null
const snappedCursor = snappedCursorPosition
const allPoints = [...points, snappedCursor]
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
// - Shape X -> World X
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
const firstPt = allPoints[0]
if (!firstPt) return null
const shape = new Shape()
shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i]
if (pt) {
shape.lineTo(pt[0], -pt[1])
}
}
shape.closePath()
return shape
}, [points, snappedCursorPosition])
return (
<group>
{/* Cursor */}
<CursorSphere ref={cursorRef} />
{/* Preview fill */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Main line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={mainLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
</line>
{/* Closing line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={closingLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.5}
transparent
/>
</line>
{/* Point markers */}
{points.map(([x, z], index) => (
<CursorSphere
color="#818cf8"
height={0}
key={index}
position={[x, levelY + Y_OFFSET + 0.01, z]}
showTooltip={false}
/>
))}
</group>
)
}
@@ -1,101 +0,0 @@
import '../../../three-types'
import {
emitter,
type GridEvent,
type SpawnNode,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useCallback, useEffect, useState } from 'react'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const worldVector = new Vector3()
function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] {
const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null
if (!levelObject) {
return [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
}
export const MoveSpawnTool: React.FC<{
node: SpawnNode
onCommitted?: (nodeId: SpawnNode['id']) => void
}> = ({ node, onCommitted }) => {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
let committed = false
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
setPreviewPosition(nextPosition)
useLiveTransforms.getState().set(node.id, {
position: [...nextPosition],
rotation: node.rotation,
})
}
const onGridClick = (event: GridEvent) => {
const nextPosition = getLevelLocalSpawnPosition(node, event)
committed = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, { position: nextPosition })
onCommitted?.(node.id)
useLiveTransforms.getState().clear(node.id)
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
}
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
useScene.temporal.getState().resume()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id)
if (!committed) {
useScene.temporal.getState().resume()
}
}
}, [exitMoveMode, node, onCommitted])
return (
<CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
)
}
@@ -1,130 +0,0 @@
import '../../../three-types'
import {
emitter,
type GridEvent,
type LevelNode,
SpawnNode,
type SpawnNode as SpawnNodeType,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const SPAWN_ICON = (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Spawn Point"
src="/icons/site.png"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
)
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const worldVector = new Vector3()
function getExistingSpawnIds() {
const nodes = useScene.getState().nodes
return Object.values(nodes)
.filter((node) => node.type === 'spawn')
.map((node) => node.id)
.sort()
}
function getLevelLocalSpawnPosition(
levelId: LevelNode['id'],
event: GridEvent,
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
return [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
}
type SpawnToolProps = {
currentLevelId: LevelNode['id'] | null
onPlaced?: (spawnId: SpawnNodeType['id']) => void
}
export const SpawnTool: React.FC<SpawnToolProps> = ({ currentLevelId, onPlaced }) => {
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
const cursorRef = useRef<Group>(null)
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
setCursorPosition(nextPosition)
cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2])
}
const onGridClick = (event: GridEvent) => {
const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event)
const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds()
if (existingSpawnId) {
useScene.getState().updateNode(existingSpawnId, {
parentId: currentLevelId,
position: nextPosition,
rotation: 0,
})
if (duplicateSpawnIds.length > 0) {
useScene.getState().deleteNodes(duplicateSpawnIds)
}
onPlaced?.(existingSpawnId)
} else {
const spawn = SpawnNode.parse({
name: 'Spawn Point',
position: nextPosition,
rotation: 0,
})
useScene.getState().createNode(spawn, currentLevelId)
onPlaced?.(spawn.id)
}
sfxEmitter.emit('sfx:structure-build')
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
}
}, [currentLevelId, onPlaced])
if (!currentLevelId) return null
return (
<CursorSphere
color="#60a5fa"
height={2.2}
ref={cursorRef}
showTooltip
tooltipContent={SPAWN_ICON}
/>
)
}
@@ -9,29 +9,13 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { ColumnTool } from './column/column-tool'
import { DoorTool } from './door/door-tool'
import { ElevatorTool } from './elevator/elevator-tool'
import { CurveFenceTool } from './fence/curve-fence-tool'
import { FenceTool } from './fence/fence-tool'
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool'
import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool'
import { SpawnTool } from './spawn/spawn-tool'
import { StairTool } from './stair/stair-tool'
import { CurveWallTool } from './wall/curve-wall-tool'
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
import { ZoneTool } from './zone/zone-tool'
@@ -50,25 +34,19 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
return Comp
}
// Legacy tool fallbacks — kinds whose placement tools haven't migrated
// to `def.tool` yet. Wall / fence / slab / ceiling / door / window /
// item / shelf / spawn now go through the registry path above.
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: {
'property-line': SiteBoundaryEditor,
},
structure: {
wall: WallTool,
fence: FenceTool,
slab: SlabTool,
ceiling: CeilingTool,
roof: RoofTool,
stair: StairTool,
door: DoorTool,
item: ItemTool,
zone: ZoneTool,
window: WindowTool,
},
furnish: {
item: ItemTool,
},
furnish: {},
}
export const ToolManager: React.FC = () => {
@@ -146,9 +124,7 @@ export const ToolManager: React.FC = () => {
const showBuildTool = mode === 'build' && tool !== null
// Registry-first: if the active tool's kind has a NodeDefinition with a
// tool contribution, the registry-driven tool takes over. Otherwise fall
// through to the legacy `tools` map below. Today the registry is empty so
// RegistryToolComponent is always null — zero behavior change.
// tool contribution, the registry-driven tool takes over.
const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
const useRegistryTool = RegistryToolComponent != null
@@ -187,9 +163,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}>
<Registry slabId={selectedSlabId} />
</Suspense>
) : (
<SlabBoundaryEditor slabId={selectedSlabId} />
)
) : null
})()}
{showSlabHoleEditor &&
selectedSlabId &&
@@ -200,9 +174,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}>
<Registry holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
</Suspense>
) : (
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
)
) : null
})()}
{showCeilingBoundaryEditor &&
selectedCeilingId &&
@@ -212,9 +184,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}>
<Registry ceilingId={selectedCeilingId} />
</Suspense>
) : (
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
)
) : null
})()}
{showCeilingHoleEditor &&
selectedCeilingId &&
@@ -225,9 +195,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}>
<Registry ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
</Suspense>
) : (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)
) : null
})()}
{movingWallEndpoint &&
(() => {
@@ -239,9 +207,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}>
<RegistryAffordance target={movingWallEndpoint} />
</Suspense>
) : (
<MoveWallEndpointTool target={movingWallEndpoint} />
)
) : null
})()}
{movingFenceEndpoint &&
(() => {
@@ -253,9 +219,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}>
<RegistryAffordance target={movingFenceEndpoint} />
</Suspense>
) : (
<MoveFenceEndpointTool target={movingFenceEndpoint} />
)
) : null
})()}
{curvingWall &&
(() => {
@@ -264,9 +228,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}>
<Registry node={curvingWall} />
</Suspense>
) : (
<CurveWallTool node={curvingWall} />
)
) : null
})()}
{curvingFence &&
(() => {
@@ -275,9 +237,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}>
<RegistryAffordance node={curvingFence} />
</Suspense>
) : (
<CurveFenceTool node={curvingFence} />
)
) : null
})()}
{movingNode && movingNode.type !== 'building' && (
<MoveTool
@@ -286,16 +246,12 @@ export const ToolManager: React.FC = () => {
/>
)}
{/* Registry-first: when the active tool's kind has a registered
NodeDefinition with a tool contribution, mount it here. Today
the registry is empty so this branch never fires. */}
NodeDefinition with a tool contribution, mount it here. */}
{!movingNode && useRegistryTool && RegistryToolComponent && (
<Suspense fallback={null}>
<RegistryToolComponent />
</Suspense>
)}
{!movingNode && !useRegistryTool && showBuildTool && tool === 'spawn' && (
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && !useRegistryTool && showBuildTool && tool === 'column' && (
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
)}
@@ -306,11 +262,7 @@ export const ToolManager: React.FC = () => {
onPlaced={handlePlacedElevatorSelected}
/>
)}
{!movingNode &&
BuildToolComponent &&
tool !== 'spawn' &&
tool !== 'column' &&
tool !== 'elevator' ? (
{!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? (
<BuildToolComponent />
) : null}
</group>
@@ -1,178 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type GridEvent,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from './wall-drafting'
export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = useCallback(() => {
useEditor.getState().setCurvingWall(null)
}, [])
useEffect(() => {
const nodeId = node.id
const originalCurveOffset = originalCurveOffsetRef.current
const chord = getWallChordFrame(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (curveOffset: number) => {
if (previewOffsetRef.current === curveOffset) {
return
}
previewOffsetRef.current = curveOffset
const nextNode = {
...node,
curveOffset,
}
const handlePoint = getWallMidpointHandlePoint(nextNode)
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const restoreOriginal = () => {
if (previewOffsetRef.current === originalCurveOffset) {
return
}
previewOffsetRef.current = originalCurveOffset
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const snapStep = getWallGridStep()
const localX = shiftPressedRef.current
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if (
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousCurveOffsetRef.current = nextCurveOffset
applyPreview(nextCurveOffset)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const curveOffset = previewOffsetRef.current
wasCommitted = true
if (curveOffset !== originalCurveOffset) {
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
useScene.temporal.getState().pause()
}
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitCurveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitCurveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitCurveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,426 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
type WallSegmentLike = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
curveOffset?: number
}
type AngleLabelState = {
label: string
position: [number, number, number]
} | null
function getEndpointAngleLabel(args: {
preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number }
walls: WallSegmentLike[]
nodeId: WallNode['id']
}): AngleLabelState {
const { preview, walls, nodeId } = args
const endpoints = [
{
point: preview.start,
},
{
point: preview.end,
},
]
const targetSegment: WallSegmentLike = {
id: nodeId,
start: preview.start,
end: preview.end,
curveOffset: preview.curveOffset,
}
for (const endpoint of endpoints) {
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
if (!targetReference) continue
const connectedWall = walls.find(
(wall) =>
wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
)
if (!connectedWall) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
if (angle === null) continue
return {
label: formatAngleRadians(angle),
position: [endpoint.point[0], 0.34, endpoint.point[1]],
}
}
return null
}
type LinkedWallSnapshot = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
curveOffset?: number
}
function getLinkedWallSnapshots(args: {
wallId: WallNode['id']
wallParentId: string | null
originalStart: WallPlanPoint
originalEnd: WallPlanPoint
}) {
const { wallId, wallParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedWallSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'wall' && node.id !== wallId)) {
continue
}
if ((node.parentId ?? null) !== wallParentId) {
continue
}
if (
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as WallPlanPoint,
end: [...node.end] as WallPlanPoint,
curveOffset: node.curveOffset,
})
}
return snapshots
}
function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
) {
return linkedWalls.map((wall) => ({
id: wall.id,
curveOffset: wall.curveOffset,
start: samePoint(wall.start, originalStart)
? nextStart
: samePoint(wall.start, originalEnd)
? nextEnd
: wall.start,
end: samePoint(wall.end, originalStart)
? nextStart
: samePoint(wall.end, originalEnd)
? nextEnd
: wall.end,
}))
}
export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<WallPlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.wall.id)
const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint)
const originalEndRef = useRef<WallPlanPoint>([...target.wall.end] as WallPlanPoint)
const fixedPointRef = useRef<WallPlanPoint>(
target.endpoint === 'start'
? ([...target.wall.end] as WallPlanPoint)
: ([...target.wall.start] as WallPlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedWallSnapshots({
wallId: target.wall.id,
wallParentId: target.wall.parentId ?? null,
originalStart: target.wall.start,
originalEnd: target.wall.end,
}),
)
const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
return [point[0], 0, point[1]]
})
const [altPressed, setAltPressed] = useState(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingWallEndpoint(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const fixedPoint = fixedPointRef.current
const levelWalls = Object.values(useScene.getState().nodes).filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedWalls
? []
: getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
setAngleLabel(
getEndpointAngleLabel({
preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
walls: [
...levelWalls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...linkedUpdates,
],
nodeId,
}),
)
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
}
const restoreOriginal = (clearAngleLabel = true) => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
if (clearAngleLabel) {
setAngleLabel(null)
}
}
const onGridMove = (event: GridEvent) => {
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
const snappedPoint = snapWallDraftPoint({
point: planPoint,
walls: levelWalls,
start: fixedPoint,
angleSnap: !shiftPressedRef.current,
ignoreWallIds: [nodeId],
})
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
applyPreview(snappedPoint, event.nativeEvent.altKey)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged = !(
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
resumeSceneHistory(useScene)
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current
? []
: getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
)),
])
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
setAngleLabel(null)
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
setAngleLabel(null)
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
if (event.key === 'Alt') {
altPressedRef.current = true
setAltPressed(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
if (event.key === 'Alt') {
altPressedRef.current = false
setAltPressed(false)
}
}
const onWindowBlur = () => {
shiftPressedRef.current = false
altPressedRef.current = false
setAltPressed(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
return () => {
if (!wasCommitted) {
restoreOriginal(false)
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
}
}, [exitMoveMode, target])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
: 'border-border bg-background/95 text-muted-foreground'
}`}
>
{altPressed ? 'Detaching corner' : 'Alt to detach'}
</div>
</div>
</Html>
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
</group>
)
}
function EndpointAngleLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -1,804 +0,0 @@
'use client'
import {
type AnyNodeId,
constrainWallMoveDeltaToAxis,
DEFAULT_WALL_HEIGHT,
detectSpacesForLevel,
emitter,
type GridEvent,
getMaterialPresetByRef,
getPerpendicularWallMoveAxis,
pauseSceneHistory,
planAutoSlabsForLevel,
planWallMoveJunctions,
resolveMaterial,
resumeSceneHistory,
type SlabNode,
useScene,
type WallMoveAxis,
type WallMoveBridgePlan,
type WallMoveJunctionPlan,
type WallNode,
WallNode as WallSchema,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting'
function rotateVector([x, z]: [number, number], angle: number): [number, number] {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return [x * cos - z * sin, x * sin + z * cos]
}
function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
}
function pointKey(point: [number, number]) {
return `${point[0]}:${point[1]}`
}
function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
return meta
}
const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
delete nextMeta.isNew
return nextMeta as WallNode['metadata']
}
type LinkedWallSnapshot = WallNode
type GhostWallPreview = {
id: string
start: [number, number]
end: [number, number]
color: string
height: number
}
function getLinkedWallSnapshots(args: {
wallId: WallNode['id']
wallParentId: string | null
originalStart: [number, number]
originalEnd: [number, number]
}) {
const { wallId, wallParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const walls = Object.values(nodes).filter(
(node): node is WallNode =>
node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId,
)
const directlyLinkedWalls = walls.filter(
(wall) =>
samePoint(wall.start, originalStart) ||
samePoint(wall.start, originalEnd) ||
samePoint(wall.end, originalStart) ||
samePoint(wall.end, originalEnd),
)
const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)])
for (const wall of directlyLinkedWalls) {
contextPoints.add(pointKey(wall.start))
contextPoints.add(pointKey(wall.end))
}
const snapshots: LinkedWallSnapshot[] = []
const seenWallIds = new Set<WallNode['id']>()
for (const node of walls) {
if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) {
continue
}
if (seenWallIds.has(node.id)) {
continue
}
seenWallIds.add(node.id)
snapshots.push({
...node,
start: [...node.start] as [number, number],
end: [...node.end] as [number, number],
children: [...(node.children ?? [])],
})
}
return snapshots
}
function getLinkedWallUpdates(
linkedWalls: Array<{
wall: LinkedWallSnapshot
matchPoint?: [number, number]
targetPoint?: [number, number]
}>,
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
return linkedWalls.map(({ wall, matchPoint, targetPoint }) => {
if (matchPoint && targetPoint) {
return {
id: wall.id,
start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start,
end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end,
}
}
const targetStart = targetPoint ?? nextStart
const targetEnd = targetPoint ?? nextEnd
return {
id: wall.id,
start: samePoint(wall.start, originalStart)
? targetStart
: samePoint(wall.start, originalEnd)
? targetEnd
: wall.start,
end: samePoint(wall.end, originalStart)
? targetStart
: samePoint(wall.end, originalEnd)
? targetEnd
: wall.end,
}
})
}
function getPlannedLinkedWallUpdates(
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
const movePlans = new Map<
WallNode['id'],
{ wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] }
>()
for (const wall of plan.linkedWallsToMove) {
movePlans.set(wall.id, { wall })
}
for (const targetPlan of plan.linkedWallTargetPlans) {
movePlans.set(targetPlan.wall.id, {
wall: targetPlan.wall,
matchPoint: targetPlan.originalPoint,
targetPoint: targetPlan.targetPoint,
})
}
return getLinkedWallUpdates(
Array.from(movePlans.values()),
originalStart,
originalEnd,
nextStart,
nextEnd,
)
}
function wallSegmentExists(
walls: Array<Pick<WallNode, 'start' | 'end'>>,
start: [number, number],
end: [number, number],
) {
return walls.some(
(wall) =>
(samePoint(wall.start, start) && samePoint(wall.end, end)) ||
(samePoint(wall.start, end) && samePoint(wall.end, start)),
)
}
function getWallGhostColor(wall: WallNode) {
const presetColor =
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color
if (presetColor) {
return presetColor
}
return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color
}
function getWallsAfterUpdates(
nodes: ReturnType<typeof useScene.getState>['nodes'],
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
) {
const updateById = new Map(updates.map((update) => [update.id, update.data]))
return Object.values(nodes)
.filter((node): node is WallNode => node?.type === 'wall')
.map((wall) => {
const update = updateById.get(wall.id as AnyNodeId)
return update ? ({ ...wall, ...update } as WallNode) : wall
})
}
function cloneSlabSnapshot(slab: SlabNode): SlabNode {
return {
...slab,
polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]),
holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })),
}
}
function getLevelSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
return Object.values(nodes).filter(
(entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId,
)
}
function getLevelAutoSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls)
}
function getLevelAutoSlabSnapshots(levelId: string) {
return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot)
}
function buildBridgeWallCreates(args: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
nextStart: [number, number]
nextEnd: [number, number]
existingWalls: WallNode[]
wallCount: number
}): Array<{ node: WallNode; parentId?: AnyNodeId }> {
const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args
const wallsForDuplicateCheck = [...existingWalls]
const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = []
for (const plan of bridgePlans) {
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
continue
}
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
continue
}
const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall
const bridgeWall = WallSchema.parse({
...sourceWall,
name: `Wall ${wallCount + creates.length + 1}`,
start: plan.originalPoint,
end: nextPoint,
children: [],
metadata: stripWallIsNewMetadata(plan.wall.metadata),
})
creates.push({
node: bridgeWall,
parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined,
})
wallsForDuplicateCheck.push(bridgeWall)
}
return creates
}
function buildBridgeWallPreviews(args: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
nextStart: [number, number]
nextEnd: [number, number]
existingWalls: WallNode[]
}): Array<{ ghost: GhostWallPreview; wall: WallNode }> {
const { bridgePlans, nextStart, nextEnd, existingWalls } = args
const wallsForDuplicateCheck: Array<Pick<WallNode, 'start' | 'end'>> = [...existingWalls]
const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = []
for (const plan of bridgePlans) {
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
continue
}
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
continue
}
const { id: _id, children: _children, ...sourceWall } = plan.wall
const wall = WallSchema.parse({
...sourceWall,
name: 'Wall Preview',
start: plan.originalPoint,
end: nextPoint,
children: [],
metadata: stripWallIsNewMetadata(plan.wall.metadata),
})
const ghost = {
id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`,
start: [...plan.originalPoint] as [number, number],
end: [...nextPoint] as [number, number],
color: getWallGhostColor(plan.wall),
height: plan.wall.height ?? DEFAULT_WALL_HEIGHT,
}
previews.push({ ghost, wall })
wallsForDuplicateCheck.push(wall)
}
return previews
}
function setPreviewGeometryAttributes(
geometry: BufferGeometry,
positions: number[],
normals: number[],
uvs: number[],
) {
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
}
function createWallPreviewGeometry(length: number, height: number) {
const geometry = new BufferGeometry()
setPreviewGeometryAttributes(
geometry,
[0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0],
[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
[0, 0, 1, 0, 1, 1, 0, 1],
)
geometry.setIndex([0, 1, 2, 0, 2, 3])
geometry.computeBoundingSphere()
return geometry
}
function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) {
const dx = preview.end[0] - preview.start[0]
const dz = preview.end[1] - preview.start[1]
const length = Math.hypot(dx, dz)
const angle = -Math.atan2(dz, dx)
const geometry = useMemo(() => {
return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height)
}, [length, preview.height])
useEffect(() => () => geometry?.dispose(), [geometry])
if (!geometry) {
return null
}
return (
<group position={[preview.start[0], 0.02, preview.start[1]]} rotation={[0, angle, 0]}>
<mesh frustumCulled={false} layers={EDITOR_LAYER} renderOrder={2}>
<primitive attach="geometry" object={geometry} />
<meshBasicMaterial
color={preview.color}
depthTest={false}
depthWrite={false}
opacity={0.32}
side={DoubleSide}
transparent
/>
</mesh>
</group>
)
}
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const meta =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const originalCenterRef = useRef<[number, number]>([
(node.start[0] + node.end[0]) / 2,
(node.start[1] + node.end[1]) / 2,
])
const originalHalfVectorRef = useRef<[number, number]>([
(node.end[0] - node.start[0]) / 2,
(node.end[1] - node.start[1]) / 2,
])
const moveAxisRef = useRef<WallMoveAxis | null>(
getPerpendicularWallMoveAxis(node.start, node.end),
)
const linkedOriginalsRef = useRef<LinkedWallSnapshot[]>(
isNew
? []
: getLinkedWallSnapshots({
wallId: node.id,
wallParentId: node.parentId ?? null,
originalStart: node.start,
originalEnd: node.end,
}),
)
const originalAutoSlabsRef = useRef<SlabNode[]>(
node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [],
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const pendingRotationRef = useRef(0)
const shiftPressedRef = useRef(false)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const centerX = (node.start[0] + node.end[0]) / 2
const centerZ = (node.start[1] + node.end[1]) / 2
return [centerX, 0, centerZ]
})
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current
const levelId = node.parentId ?? null
const originalAutoSlabs = originalAutoSlabsRef.current
pauseSceneHistory(useScene)
let shouldRestoreOnCleanup = true
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyLiveAutoSlabPreview = (walls: WallNode[]) => {
if (!levelId) {
return
}
const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId)
const sceneState = useScene.getState()
const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls)
const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes))
if (
slabPlan.create.length === 0 &&
slabPlan.update.length === 0 &&
slabPlan.delete.length === 0
) {
return
}
sceneState.applyNodeChanges({
update: slabPlan.update.map((entry) => ({
id: entry.id as AnyNodeId,
data: entry.data,
})),
create: slabPlan.create.map((slab) => ({
node: slab,
parentId: levelId as AnyNodeId,
})),
delete: slabPlan.delete.map((id) => id as AnyNodeId),
})
}
const restoreAutoSlabPreview = () => {
if (!levelId) {
return
}
const sceneState = useScene.getState()
const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id))
const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes)
const update = originalAutoSlabs
.filter((slab) => sceneState.nodes[slab.id as AnyNodeId])
.map((slab) => ({
id: slab.id as AnyNodeId,
data: cloneSlabSnapshot(slab),
}))
const create = originalAutoSlabs
.filter((slab) => !sceneState.nodes[slab.id as AnyNodeId])
.map((slab) => ({
node: cloneSlabSnapshot(slab),
parentId: levelId as AnyNodeId,
}))
const deleteIds = currentAutoSlabs
.filter((slab) => !originalIds.has(slab.id))
.map((slab) => slab.id as AnyNodeId)
if (update.length === 0 && create.length === 0 && deleteIds.length === 0) {
return
}
sceneState.applyNodeChanges({
update,
create,
delete: deleteIds,
})
}
const buildWallFromCenter = (center: [number, number]) => {
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]]
return { start: nextStart, end: nextEnd }
}
const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) =>
planWallMoveJunctions(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
const getLinkedPreviewUpdates = (
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
nextStart: [number, number],
nextEnd: [number, number],
) => {
const movedUpdates = getPlannedLinkedWallUpdates(
plan,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry]))
return linkedOriginalsRef.current.map(
(wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end },
)
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
const previewPlan = getMovePlan(nextStart, nextEnd)
const previewUpdates = [
{ id: nodeId, start: nextStart, end: nextEnd },
...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd),
]
const previewCollapsedWallIds = new Set([
...previewUpdates
.filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end))
.map((entry) => entry.id as AnyNodeId),
...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
])
const previewSceneWalls = getWallsAfterUpdates(
useScene.getState().nodes,
previewUpdates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId))
const bridgePreviews = buildBridgeWallPreviews({
bridgePlans: previewPlan.bridgePlans,
nextStart,
nextEnd,
existingWalls: previewSceneWalls,
})
const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost)
const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall)
setGhostWallPreviews(nextGhostWalls)
applyNodePreview(previewUpdates)
applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls])
}
const restoreOriginal = () => {
setGhostWallPreviews([])
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
restoreAutoSlabPreview()
}
const onGridMove = (event: GridEvent) => {
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
const snapStep = getWallGridStep()
const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep)
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
localX - anchor[0],
localZ - anchor[1],
moveAxisRef.current,
)
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
if (
previousGridPosRef.current &&
(constrainedGridPos[0] !== previousGridPosRef.current[0] ||
constrainedGridPos[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = constrainedGridPos
const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
const nextWall = buildWallFromCenter(nextCenter)
applyPreview(nextWall.start, nextWall.end)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
shouldRestoreOnCleanup = false
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
setGhostWallPreviews([])
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
restoreAutoSlabPreview()
resumeSceneHistory(useScene)
const commitPlan = getMovePlan(preview.start, preview.end)
const linkedWallUpdates = getPlannedLinkedWallUpdates(
commitPlan,
originalStart,
originalEnd,
preview.start,
preview.end,
)
const collapsedLinkedWallIds = new Set([
...linkedWallUpdates
.filter((entry) => !isWallLongEnough(entry.start, entry.end))
.map((entry) => entry.id as AnyNodeId),
...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
])
const commitUpdates = [
{
id: nodeId as AnyNodeId,
data: isNew
? {
start: preview.start,
end: preview.end,
metadata: stripWallIsNewMetadata(node.metadata),
}
: { start: preview.start, end: preview.end },
},
...linkedWallUpdates
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
]
const sceneState = useScene.getState()
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
(wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId),
)
const bridgeCreates = buildBridgeWallCreates({
bridgePlans: commitPlan.bridgePlans,
nextStart: preview.start,
nextEnd: preview.end,
existingWalls,
wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length,
})
sceneState.applyNodeChanges({
update: commitUpdates,
create: bridgeCreates,
delete: Array.from(collapsedLinkedWallIds),
})
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta === 0) {
return
}
event.preventDefault()
pendingRotationRef.current += rotationDelta
sfxEmitter.emit('sfx:item-rotate')
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const currentCenter: [number, number] = [
(preview.start[0] + preview.end[0]) / 2,
(preview.start[1] + preview.end[1]) / 2,
]
const nextWall = buildWallFromCenter(currentCenter)
moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end)
applyPreview(nextWall.start, nextWall.end)
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
const onCancel = () => {
shouldRestoreOnCleanup = false
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (shouldRestoreOnCleanup) {
restoreOriginal()
}
shiftPressedRef.current = false
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitMoveMode, isNew, node.metadata, node.parentId])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
{ghostWallPreviews.map((preview) => (
<GhostWallPreviewMesh key={preview.id} preview={preview} />
))}
</group>
)
}
@@ -1,332 +0,0 @@
import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
const WALL_HEIGHT = 2.5
const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
const DRAFT_ANGLE_LABEL_Y = 0.28
type DraftAngleLabel = {
id: string
label: string
position: [number, number, number]
}
type DraftMeasurementState = {
lengthLabel: string
lengthPosition: [number, number, number]
angleLabels: DraftAngleLabel[]
} | null
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
function getDraftAngleLabels(
start: WallPlanPoint,
end: WallPlanPoint,
walls: WallNode[],
): DraftAngleLabel[] {
const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
const endpoints = [
{ id: 'start', point: start, draftVector: draftFromStart },
{ id: 'end', point: end, draftVector: draftFromEnd },
]
const labels: DraftAngleLabel[] = []
for (const endpoint of endpoints) {
const connectedWall = walls.find((wall) =>
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
)
if (!connectedWall) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
if (angle === null) continue
labels.push({
id: endpoint.id,
label: formatAngleRadians(angle),
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
})
}
return labels
}
function getDraftMeasurementState(
start: WallPlanPoint,
end: WallPlanPoint,
walls: WallNode[],
unit: 'metric' | 'imperial',
): DraftMeasurementState {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 0.01) return null
return {
lengthLabel: formatMeasurement(length, unit),
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
angleLabels: getDraftAngleLabels(start, end, walls),
}
}
/**
* Update wall preview mesh geometry to create a vertical plane between two points
*/
const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
// Calculate direction and perpendicular for wall thickness
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < 0.01) {
mesh.visible = false
return
}
mesh.visible = true
direction.normalize()
// Create wall shape (vertical rectangle in XY plane)
const shape = new Shape()
shape.moveTo(0, 0)
shape.lineTo(length, 0)
shape.lineTo(length, WALL_HEIGHT)
shape.lineTo(0, WALL_HEIGHT)
shape.closePath()
// Create geometry
const geometry = new ShapeGeometry(shape)
// Calculate rotation angle
// Negate the angle to fix the opposite direction issue
const angle = -Math.atan2(direction.z, direction.x)
// Position at start point and rotate
mesh.position.set(start.x, start.y, start.z)
mesh.rotation.y = angle
// Dispose old geometry and assign new one
if (mesh.geometry) {
mesh.geometry.dispose()
}
mesh.geometry = geometry
}
const getCurrentLevelWalls = (): WallNode[] => {
const currentLevelId = useViewer.getState().selection.levelId
const { nodes } = useScene.getState()
if (!currentLevelId) return []
const levelNode = nodes[currentLevelId]
if (!levelNode || levelNode.type !== 'level') return []
return (levelNode as LevelNode).children
.map((childId) => nodes[childId])
.filter((node): node is WallNode => node?.type === 'wall')
}
export const WallTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!)
// All positions are building-local: this tool is inside the ToolManager building group,
// so local coords are used for both data and visual positioning.
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
useEffect(() => {
let gridPosition: WallPlanPoint = [0, 0]
let previousWallEnd: [number, number] | null = null
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && wallPreviewRef.current)) return
const walls = getCurrentLevelWalls()
// event.localPosition is building-local — consistent with stored wall start/end
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
gridPosition = snapWallDraftPoint({ point: localPoint, walls })
if (buildingState.current === 1) {
const snappedLocal = snapWallDraftPoint({
point: localPoint,
walls,
start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current,
})
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current)
// Play snap sound only when the actual wall end position changes
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if (
previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousWallEnd = currentWallEnd
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
setDraftMeasurement(
getDraftMeasurementState(
[startingPoint.current.x, startingPoint.current.z],
snappedLocal,
walls,
unit,
),
)
} else {
// Not drawing a wall yet, show the snapped anchor point.
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
setDraftMeasurement(null)
}
}
const onGridClick = (event: GridEvent) => {
const walls = getCurrentLevelWalls()
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
if (buildingState.current === 0) {
const snappedStart = snapWallDraftPoint({ point: localClick, walls })
gridPosition = snappedStart
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
wallPreviewRef.current.visible = true
setDraftMeasurement(null)
} else if (buildingState.current === 1) {
const snappedEnd = snapWallDraftPoint({
point: localClick,
walls,
start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current,
})
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
// Both start and end are building-local ✓
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
wallPreviewRef.current.visible = false
buildingState.current = 0
setDraftMeasurement(null)
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = false
}
}
const onCancel = () => {
if (buildingState.current === 1) {
markToolCancelConsumed()
buildingState.current = 0
wallPreviewRef.current.visible = false
setDraftMeasurement(null)
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [unit])
return (
<group>
{/* Cursor indicator */}
<CursorSphere ref={cursorRef} />
{/* Wall preview */}
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
<shapeGeometry />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
opacity={0.5}
side={DoubleSide}
transparent
/>
</mesh>
{draftMeasurement && (
<>
<DraftMeasurementLabel
label={draftMeasurement.lengthLabel}
position={draftMeasurement.lengthPosition}
/>
{draftMeasurement.angleLabels.map((angleLabel) => (
<DraftMeasurementLabel
key={angleLabel.id}
label={angleLabel.label}
position={angleLabel.position}
/>
))}
</>
)}
</group>
)
}
function DraftMeasurementLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -1,447 +0,0 @@
import {
type AnyNodeId,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
useScene,
type WallEvent,
WindowNode,
} from '@pascal-app/core'
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 { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
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.
*/
export 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()
sfxEmitter.emit('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>
)
}
@@ -1,117 +0,0 @@
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
}
@@ -1,332 +0,0 @@
import {
type AnyNodeId,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
WindowNode,
} from '@pascal-app/core'
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 { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
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.
*/
export 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()
sfxEmitter.emit('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>
)
}
@@ -33,10 +33,7 @@ export const tools: ToolConfig[] = [
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
// Registry-driven shelf node — placed via the registry-first ToolManager
// shim from Phase 0. No icon file shipped; using a placeholder icon until
// Phase 4 derives palette entries from `definition.presentation.icon`.
{ id: 'shelf', iconSrc: '/icons/column.png', label: 'Shelf' },
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
]
export function StructureTools() {
@@ -1,20 +0,0 @@
import { ShortcutToken } from '../primitives/shortcut-token'
export function CeilingHelper() {
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Add point</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Shift" />
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -4,12 +4,9 @@ import { nodeRegistry } from '@pascal-app/core'
import { useIsMobile } from '../../../hooks/use-mobile'
import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper'
import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper'
import { RegisteredToolHelper } from './registered-tool-helper'
import { RoofHelper } from './roof-helper'
import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper'
export function HelperManager() {
const mode = useEditor((s) => s.mode)
@@ -29,10 +26,9 @@ export function HelperManager() {
return null
}
// Registry-first: if the active tool matches a registered kind whose
// definition supplies `toolHints`, render via the generic helper.
// Otherwise fall through to the hand-written per-tool helpers below.
// Legacy helpers get deleted as their kind migrates `toolHints` in.
// Registry-first: kinds with `def.toolHints` render through the generic
// `RegisteredToolHelper`. Today that covers ceiling / door / fence /
// item / shelf / slab / spawn / wall / window.
if (tool) {
const def = nodeRegistry.get(tool)
if (def?.toolHints && def.toolHints.length > 0) {
@@ -40,19 +36,9 @@ export function HelperManager() {
}
}
// Show appropriate helper based on current tool
switch (tool) {
case 'wall':
return <WallHelper />
case 'item':
return <ItemHelper />
case 'slab':
return <SlabHelper />
case 'ceiling':
return <CeilingHelper />
case 'roof':
return <RoofHelper />
default:
return null
}
// Legacy fallback — only `roof` remains because it hasn't migrated to
// `def.tool` / `def.toolHints` yet (no Stage D port). When roof
// migrates, this switch deletes outright.
if (tool === 'roof') return <RoofHelper />
return null
}
@@ -1,20 +0,0 @@
import { ShortcutToken } from '../primitives/shortcut-token'
export function SlabHelper() {
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Add point</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Shift" />
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,20 +0,0 @@
import { ShortcutToken } from '../primitives/shortcut-token'
export function WallHelper() {
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Set wall start / end</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Shift" />
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,933 +0,0 @@
'use client'
import {
type AnyNode,
COLUMN_PRESETS,
type ColumnNode,
type ColumnPresetId,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
const SELECT_CLASS =
'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border'
const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({
value: value as ColumnPresetId,
label: preset.label,
}))
const COLUMN_PROPORTION_PRESETS = {
slender: {
label: 'Slender',
height: 3.6,
width: 0.34,
baseHeight: 0.18,
capitalHeight: 0.16,
baseWidthScale: 1.18,
capitalWidthScale: 1.16,
edgeSoftness: 0.02,
},
standard: {
label: 'Standard',
height: 2.9,
width: 0.44,
baseHeight: 0.22,
capitalHeight: 0.2,
baseWidthScale: 1.24,
capitalWidthScale: 1.22,
edgeSoftness: 0.025,
},
heavy: {
label: 'Heavy',
height: 3,
width: 0.58,
baseHeight: 0.28,
capitalHeight: 0.26,
baseWidthScale: 1.34,
capitalWidthScale: 1.3,
edgeSoftness: 0.035,
},
stout: {
label: 'Short / Stout',
height: 2.2,
width: 0.62,
baseHeight: 0.3,
capitalHeight: 0.28,
baseWidthScale: 1.38,
capitalWidthScale: 1.34,
edgeSoftness: 0.04,
},
} as const
type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(
([value, preset]) => ({
value: value as ColumnProportionPresetId,
label: preset.label,
}),
)
const SUPPORT_STYLE_OPTIONS: Array<{ label: string; value: ColumnNode['supportStyle'] }> = [
{ label: 'Vertical', value: 'vertical' },
{ label: 'A-Frame', value: 'a-frame' },
{ label: 'Y Support', value: 'y-frame' },
{ label: 'V Support', value: 'v-frame' },
{ label: 'X Brace', value: 'x-brace' },
{ label: 'K Brace', value: 'k-brace' },
{ label: 'Single Strut', value: 'single-strut' },
{ label: 'Tripod', value: 'tripod' },
{ label: 'Trestle', value: 'trestle' },
{ label: 'Portal Frame', value: 'portal-frame' },
{ label: 'Box Frame', value: 'box-frame' },
]
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function presetUpdates(presetId: ColumnPresetId): Partial<ColumnNode> {
const { label, ...preset } = COLUMN_PRESETS[presetId]
return {
name: label,
supportStyle: 'supportStyle' in preset ? preset.supportStyle : 'vertical',
...preset,
}
}
function proportionUpdates(
node: ColumnNode,
presetId: ColumnProportionPresetId,
): Partial<ColumnNode> {
const preset = COLUMN_PROPORTION_PRESETS[presetId]
const depth =
node.crossSection === 'rectangular'
? clamp(preset.width * (node.depth / Math.max(node.width, 0.01)), 0.12, 1.6)
: preset.width
const shaftCornerRadius = Math.min(node.shaftCornerRadius ?? 0.035, preset.width * 0.18)
return {
height: preset.height,
width: preset.width,
depth,
radius: preset.width / 2,
baseHeight: preset.baseHeight,
capitalHeight: preset.capitalHeight,
baseWidthScale: preset.baseWidthScale,
baseDepthScale: preset.baseWidthScale,
capitalWidthScale: preset.capitalWidthScale,
capitalDepthScale: preset.capitalWidthScale,
edgeSoftness: preset.edgeSoftness,
shaftCornerRadius,
}
}
function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial<ColumnNode> {
if (shaftProfile === 'tapered') {
return {
shaftProfile,
shaftTaper: 0.14,
shaftBulge: 0,
shaftStartScale: 0.82,
shaftEndScale: 0.72,
shaftSegmentCount: 32,
}
}
if (shaftProfile === 'bulged') {
return {
shaftProfile,
shaftTaper: 0,
shaftBulge: 0.12,
shaftStartScale: 0.68,
shaftEndScale: 0.68,
shaftSegmentCount: 32,
}
}
if (shaftProfile === 'hourglass') {
return {
shaftProfile,
shaftTaper: 0,
shaftBulge: 0.12,
shaftStartScale: 0.84,
shaftEndScale: 0.84,
shaftSegmentCount: 32,
}
}
return {
shaftProfile,
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 0.72,
shaftEndScale: 0.72,
shaftSegmentCount: 1,
shaftTwistStep: 0,
}
}
export function ColumnPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ColumnNode | undefined) : undefined,
)
const handleUpdate = useCallback(
(updates: Partial<ColumnNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleDelete = useCallback(() => {
if (!selectedId) return
sfxEmitter.emit('sfx:structure-delete')
deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] })
}, [deleteNode, selectedId, setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null
const shaftProfile = node.shaftProfile ?? 'straight'
const supportStyle = node.supportStyle ?? 'vertical'
const isBraceSupport =
supportStyle === 'a-frame' ||
supportStyle === 'y-frame' ||
supportStyle === 'v-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'portal-frame' ||
supportStyle === 'box-frame'
return (
<PanelWrapper
icon="/icons/column.png"
onClose={handleClose}
title={node.name || 'Column'}
width={300}
>
<PanelSection title="Preset">
<select
className={SELECT_CLASS}
onChange={(event) => {
if (!event.target.value) return
handleUpdate(presetUpdates(event.target.value as ColumnPresetId))
}}
value=""
>
<option value="">Apply preset...</option>
{COLUMN_PRESET_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</PanelSection>
<PanelSection title="Shape">
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
{SUPPORT_STYLE_OPTIONS.map((option) => {
const isSelected = supportStyle === 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({
supportStyle: option.value,
...(option.value !== 'vertical'
? {
crossSection: 'rectangular',
width: node.braceWidth ?? node.width,
depth: node.braceDepth ?? node.depth,
baseStyle: 'none',
capitalStyle: 'none',
}
: {}),
})
}
type="button"
>
<span className="truncate font-medium">{option.label}</span>
</button>
)
})}
</div>
{isBraceSupport ? (
<>
<SliderControl
label="Brace Width"
max={0.8}
min={0.04}
onChange={(value) => handleUpdate({ braceWidth: value, width: value })}
precision={2}
step={0.01}
unit="m"
value={node.braceWidth ?? node.width}
/>
<SliderControl
label="Brace Depth"
max={0.8}
min={0.04}
onChange={(value) => handleUpdate({ braceDepth: value, depth: value })}
precision={2}
step={0.01}
unit="m"
value={node.braceDepth ?? node.depth}
/>
</>
) : (
<>
<select
className={SELECT_CLASS}
onChange={(event) =>
handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
}
value={node.crossSection}
>
<option value="round">Round</option>
<option value="square">Square</option>
<option value="rectangular">Rectangular</option>
</select>
<SliderControl
label="Edge Softness"
max={0.12}
min={0}
onChange={(value) => handleUpdate({ edgeSoftness: value })}
precision={3}
step={0.005}
unit="m"
value={node.edgeSoftness ?? 0.025}
/>
{(node.crossSection === 'square' || node.crossSection === 'rectangular') && (
<SliderControl
label="Shaft Corner Radius"
max={0.3}
min={0}
onChange={(value) => handleUpdate({ shaftCornerRadius: value })}
precision={3}
step={0.005}
unit="m"
value={node.shaftCornerRadius ?? 0.035}
/>
)}
</>
)}
</PanelSection>
<PanelSection title="Dimensions">
{!isBraceSupport && (
<select
className={SELECT_CLASS}
onChange={(event) => {
if (!event.target.value) return
handleUpdate(proportionUpdates(node, event.target.value as ColumnProportionPresetId))
}}
value=""
>
<option value="">Apply proportion...</option>
{COLUMN_PROPORTION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
<SliderControl
label="Height"
max={6}
min={0.8}
onChange={(value) => handleUpdate({ height: value })}
precision={2}
step={0.05}
unit="m"
value={node.height}
/>
{isBraceSupport ? (
<>
{(supportStyle === 'a-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'portal-frame' ||
supportStyle === 'box-frame') && (
<SliderControl
label="Bottom Spread"
max={4}
min={0.2}
onChange={(value) =>
handleUpdate({
braceBottomSpread: value,
braceTopSpread:
supportStyle === 'a-frame'
? Math.min(node.braceTopSpread ?? 0.12, value)
: (node.braceTopSpread ?? 1),
})
}
precision={2}
step={0.05}
unit="m"
value={node.braceBottomSpread ?? 1.2}
/>
)}
<SliderControl
label={supportStyle === 'y-frame' ? 'Fork Spread' : 'Top Spread'}
max={
supportStyle === 'y-frame' ||
supportStyle === 'v-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'box-frame'
? 4
: Math.max(0.2, node.braceBottomSpread ?? 1.2)
}
min={0}
onChange={(value) => handleUpdate({ braceTopSpread: value })}
precision={2}
step={0.02}
unit="m"
value={
node.braceTopSpread ??
(supportStyle === 'y-frame' ||
supportStyle === 'v-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'portal-frame' ||
supportStyle === 'box-frame'
? 1
: 0.12)
}
/>
<ToggleControl
checked={node.bracePlateEnabled ?? true}
label="Connector Plates"
onChange={(checked) => handleUpdate({ bracePlateEnabled: checked })}
/>
</>
) : (
<>
<SliderControl
label="Width"
max={1.6}
min={0.12}
onChange={(value) =>
handleUpdate({
width: value,
radius: value / 2,
...(node.crossSection === 'rectangular' ? {} : { depth: value }),
})
}
precision={2}
step={0.02}
unit="m"
value={node.width}
/>
{node.crossSection === 'rectangular' && (
<SliderControl
label="Depth"
max={1.6}
min={0.12}
onChange={(value) => handleUpdate({ depth: value })}
precision={2}
step={0.02}
unit="m"
value={node.depth}
/>
)}
</>
)}
</PanelSection>
{!isBraceSupport && (
<PanelSection title="Shaft">
<select
className={SELECT_CLASS}
onChange={(event) =>
handleUpdate(
shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']),
)
}
value={shaftProfile}
>
<option value="straight">Straight</option>
<option value="tapered">Tapered</option>
<option value="bulged">Bulged</option>
<option value="hourglass">Hourglass</option>
</select>
{shaftProfile === 'straight' && (
<SliderControl
label="Shaft Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.72}
/>
)}
{shaftProfile === 'tapered' && (
<>
<SliderControl
label="Bottom Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.82}
/>
<SliderControl
label="Top Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftEndScale ?? 0.72}
/>
<SliderControl
label="Taper"
max={0.45}
min={0}
onChange={(value) => handleUpdate({ shaftTaper: value })}
precision={2}
step={0.01}
value={node.shaftTaper ?? 0.14}
/>
</>
)}
{shaftProfile === 'bulged' && (
<>
<SliderControl
label="End Width"
max={1.2}
min={0.3}
onChange={(value) =>
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.68}
/>
<SliderControl
label="Bulge"
max={0.35}
min={0}
onChange={(value) => handleUpdate({ shaftBulge: value })}
precision={2}
step={0.01}
value={node.shaftBulge ?? 0.12}
/>
</>
)}
{shaftProfile === 'hourglass' && (
<>
<SliderControl
label="End Width"
max={1.2}
min={0.3}
onChange={(value) =>
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.84}
/>
<SliderControl
label="Waist"
max={0.35}
min={0}
onChange={(value) => handleUpdate({ shaftBulge: value })}
precision={2}
step={0.01}
value={node.shaftBulge ?? 0.12}
/>
</>
)}
<SliderControl
label="Segment Twist"
max={90}
min={-90}
onChange={(value) =>
handleUpdate({
shaftTwistStep: value,
...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8
? { shaftSegmentCount: 12 }
: {}),
})
}
precision={0}
step={5}
unit="°"
value={node.shaftTwistStep ?? 0}
/>
{Math.abs(node.shaftTwistStep ?? 0) > 0.001 && (
<SliderControl
label="Twist Segments"
max={48}
min={4}
onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })}
precision={0}
step={1}
value={node.shaftSegmentCount ?? 12}
/>
)}
<SliderControl
label="Ring Pairs"
max={4}
min={0}
onChange={(value) =>
handleUpdate({
ringCount: Math.round(value) * 2,
ringPlacement: 'ends',
ringSpread: node.ringSpread ?? 0.16,
ringThickness: node.ringThickness ?? 0.055,
})
}
precision={0}
step={1}
value={Math.ceil((node.ringCount ?? 0) / 2)}
/>
{(node.ringCount ?? 0) > 0 && (
<SliderControl
label="Ring Thickness"
max={0.14}
min={0.01}
onChange={(value) => handleUpdate({ ringThickness: value })}
precision={3}
step={0.005}
unit="m"
value={node.ringThickness ?? 0.055}
/>
)}
{(node.ringCount ?? 0) > 0 && (
<SliderControl
label="Ring Spread"
max={0.45}
min={0.04}
onChange={(value) => handleUpdate({ ringSpread: value, ringPlacement: 'ends' })}
precision={2}
step={0.01}
value={node.ringSpread ?? 0.16}
/>
)}
</PanelSection>
)}
{!isBraceSupport && (
<PanelSection title="Ends">
<select
className={SELECT_CLASS}
onChange={(event) => {
const capitalStyle = event.target.value as ColumnNode['capitalStyle']
handleUpdate({
capitalStyle,
...(capitalStyle === 'none'
? {}
: {
capitalHeight: Math.max(node.capitalHeight, 0.12),
capitalTierCount:
capitalStyle === 'stepped'
? Math.max(node.capitalTierCount ?? 3, 3)
: node.capitalTierCount,
capitalWidthScale: Math.max(
node.capitalWidthScale ?? 1.3,
capitalStyle === 'stepped' ? 1.42 : 1.28,
),
capitalDepthScale: Math.max(
node.capitalDepthScale ?? 1.3,
capitalStyle === 'stepped' ? 1.42 : 1.28,
),
capitalStepSpread:
capitalStyle === 'stepped'
? Math.max(node.capitalStepSpread ?? 0.34, 0.34)
: node.capitalStepSpread,
}),
})
}}
value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')}
>
<option value="none">No Top</option>
<option value="simple">Simple Top</option>
<option value="stepped">Stepped Top</option>
<option value="rounded">Rounded Top</option>
</select>
{node.capitalStyle !== 'none' && (
<SliderControl
label="Top Height"
max={0.8}
min={0.06}
onChange={(value) => handleUpdate({ capitalHeight: value })}
precision={2}
step={0.02}
unit="m"
value={node.capitalHeight}
/>
)}
{node.capitalStyle !== 'none' && (
<SliderControl
label="Top Width"
max={2.4}
min={0.6}
onChange={(value) =>
handleUpdate({
capitalWidthScale: value,
...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }),
})
}
precision={2}
step={0.02}
value={node.capitalWidthScale ?? 1.28}
/>
)}
{node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && (
<SliderControl
label="Top Depth"
max={2.4}
min={0.6}
onChange={(value) => handleUpdate({ capitalDepthScale: value })}
precision={2}
step={0.02}
value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28}
/>
)}
{node.capitalStyle === 'stepped' && (
<SliderControl
label="Top Tiers"
max={8}
min={3}
onChange={(value) => handleUpdate({ capitalTierCount: Math.round(value) })}
precision={0}
step={1}
value={node.capitalTierCount ?? 3}
/>
)}
{node.capitalStyle === 'stepped' && (
<SliderControl
label="Top Step Spread"
max={0.9}
min={0.05}
onChange={(value) => handleUpdate({ capitalStepSpread: value })}
precision={2}
step={0.01}
value={node.capitalStepSpread ?? 0.34}
/>
)}
<select
className={`${SELECT_CLASS} mt-2`}
onChange={(event) => {
const baseStyle = event.target.value as ColumnNode['baseStyle']
handleUpdate({
baseStyle,
...(baseStyle === 'none'
? {}
: {
baseHeight: Math.max(node.baseHeight, 0.12),
baseTierCount:
baseStyle === 'stepped-square'
? Math.max(node.baseTierCount ?? 3, 3)
: node.baseTierCount,
baseWidthScale: Math.max(
node.baseWidthScale ?? 1.24,
baseStyle === 'stepped-square' ? 1.42 : 1.24,
),
baseDepthScale: Math.max(
node.baseDepthScale ?? 1.24,
baseStyle === 'stepped-square' ? 1.42 : 1.24,
),
baseStepSpread:
baseStyle === 'stepped-square'
? Math.max(node.baseStepSpread ?? 0.34, 0.34)
: node.baseStepSpread,
basePlinthHeightRatio:
baseStyle === 'round-rings'
? (node.basePlinthHeightRatio ?? 0.44)
: node.basePlinthHeightRatio,
baseRoundBandScale:
baseStyle === 'round-rings'
? (node.baseRoundBandScale ?? 0.92)
: node.baseRoundBandScale,
baseNeckScale:
baseStyle === 'round-rings'
? (node.baseNeckScale ?? 0.72)
: node.baseNeckScale,
}),
})
}}
value={node.baseStyle ?? 'square-plinth'}
>
<option value="none">No Bottom</option>
<option value="simple-square">Simple Block Bottom</option>
<option value="square-plinth">Square Plinth Bottom</option>
<option value="stepped-square">Stepped Bottom</option>
<option value="round-rings">Rounded Bottom</option>
</select>
{node.baseStyle !== 'none' && (
<SliderControl
label="Bottom Height"
max={0.8}
min={0.06}
onChange={(value) => handleUpdate({ baseHeight: value })}
precision={2}
step={0.02}
unit="m"
value={node.baseHeight}
/>
)}
{node.baseStyle !== 'none' && (
<SliderControl
label="Bottom Width"
max={2.4}
min={0.6}
onChange={(value) =>
handleUpdate({
baseWidthScale: value,
...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }),
})
}
precision={2}
step={0.02}
value={node.baseWidthScale ?? 1.24}
/>
)}
{node.baseStyle !== 'none' && node.crossSection === 'rectangular' && (
<SliderControl
label="Bottom Depth"
max={2.4}
min={0.6}
onChange={(value) => handleUpdate({ baseDepthScale: value })}
precision={2}
step={0.02}
value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24}
/>
)}
{node.baseStyle === 'round-rings' && (
<SliderControl
label="Plinth Thickness"
max={0.7}
min={0.2}
onChange={(value) => handleUpdate({ basePlinthHeightRatio: value })}
precision={2}
step={0.01}
value={node.basePlinthHeightRatio ?? 0.44}
/>
)}
{node.baseStyle === 'round-rings' && (
<SliderControl
label="Round Band Width"
max={1.2}
min={0.5}
onChange={(value) => handleUpdate({ baseRoundBandScale: value })}
precision={2}
step={0.01}
value={node.baseRoundBandScale ?? 0.92}
/>
)}
{node.baseStyle === 'round-rings' && (
<SliderControl
label="Neck Width"
max={1}
min={0.35}
onChange={(value) => handleUpdate({ baseNeckScale: value })}
precision={2}
step={0.01}
value={node.baseNeckScale ?? 0.72}
/>
)}
{node.baseStyle === 'stepped-square' && (
<SliderControl
label="Bottom Tiers"
max={8}
min={3}
onChange={(value) => handleUpdate({ baseTierCount: Math.round(value) })}
precision={0}
step={1}
value={node.baseTierCount ?? 3}
/>
)}
{node.baseStyle === 'stepped-square' && (
<SliderControl
label="Bottom Step Spread"
max={0.9}
min={0.05}
onChange={(value) => handleUpdate({ baseStepSpread: value })}
precision={2}
step={0.01}
value={node.baseStepSpread ?? 0.34}
/>
)}
</PanelSection>
)}
<PanelSection title="Transform">
<SliderControl
label="Yaw"
max={180}
min={-180}
onChange={(value) => handleUpdate({ rotation: (value * Math.PI) / 180 })}
precision={0}
step={1}
unit="°"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
<ActionButton
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
icon={<Trash2 className="h-4 w-4" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,934 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type ElevatorNode,
ElevatorNode as ElevatorNodeSchema,
type LevelNode,
requestElevatorLevel,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Send, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { resolveElevatorNodeSupportY, resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
function findLevelId(levels: LevelNode[], levelId: string | null | undefined) {
if (!levelId) return null
return levels.some((level) => level.id === levelId) ? levelId : null
}
function getLegacyServedLevels(node: ElevatorNode | undefined, levels: LevelNode[]) {
if (!node || node.fromLevelId || node.toLevelId || !node.servedLevelIds?.length) return []
const servedIds = new Set(node.servedLevelIds)
return levels.filter((level) => servedIds.has(level.id))
}
function getResolvedFromLevelId(node: ElevatorNode | undefined, levels: LevelNode[]) {
if (!node) return levels[0]?.id ?? ''
const legacyServedLevels = getLegacyServedLevels(node, levels)
return (
findLevelId(levels, node.fromLevelId) ??
legacyServedLevels[0]?.id ??
findLevelId(levels, node.defaultLevelId) ??
levels[0]?.id ??
''
)
}
function getResolvedToLevelId(
node: ElevatorNode | undefined,
levels: LevelNode[],
fromLevelId: string,
) {
if (!node) return levels[0]?.id ?? ''
const explicitTo = findLevelId(levels, node.toLevelId)
if (explicitTo) return explicitTo
const legacyServedLevels = getLegacyServedLevels(node, levels)
const legacyTo = legacyServedLevels[legacyServedLevels.length - 1]?.id
if (legacyTo) return legacyTo
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
const fallbackIndex = fromIndex >= 0 ? Math.min(fromIndex + 1, levels.length - 1) : 0
return levels[fallbackIndex]?.id ?? fromLevelId
}
function getServiceLevels(levels: LevelNode[], fromLevelId: string, toLevelId: string) {
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
const toIndex = levels.findIndex((level) => level.id === toLevelId)
if (fromIndex < 0 && toIndex < 0) return []
const resolvedFromIndex = fromIndex >= 0 ? fromIndex : toIndex
const resolvedToIndex =
toIndex >= 0 ? toIndex : Math.min(Math.max(resolvedFromIndex, 0) + 1, levels.length - 1)
const minIndex = Math.min(resolvedFromIndex, resolvedToIndex)
const maxIndex = Math.max(resolvedFromIndex, resolvedToIndex)
return levels.slice(minIndex, maxIndex + 1)
}
function stripDuplicateFlags(metadata: ElevatorNode['metadata']) {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return metadata
}
const nextMeta = { ...(metadata as Record<string, unknown>) }
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta as ElevatorNode['metadata']
}
type ElevatorMetricKey =
| 'width'
| 'depth'
| 'shaftWidth'
| 'shaftDepth'
| 'shaftWallThickness'
| 'cabHeight'
| 'doorWidth'
| 'doorHeight'
type ElevatorAccessField = 'disabledLevelIds' | 'serviceOnlyLevelIds'
const DOOR_STYLE_OPTIONS: Array<{
label: string
value: ElevatorNode['doorStyle']
}> = [
{ label: 'Center opening', value: 'center-opening' },
{ label: 'Single left', value: 'single-left' },
{ label: 'Single right', value: 'single-right' },
]
const DOOR_PANEL_STYLE_OPTIONS: Array<{
label: string
value: ElevatorNode['doorPanelStyle']
}> = [
{ label: 'Glass frame', value: 'glass-frame' },
{ label: 'Solid panel', value: 'solid-panel' },
{ label: 'Segmented panel', value: 'segmented-panel' },
]
const SHAFT_STYLE_OPTIONS: Array<{
label: string
value: ElevatorNode['shaftStyle']
}> = [
{ label: 'Solid', value: 'solid' },
{ label: 'Glass', value: 'glass' },
]
function roundMeters(value: number) {
return Math.round(value * 100) / 100
}
function getResolvedShaftWidth(node: ElevatorNode) {
return Math.max(node.shaftWidth ?? node.width, node.width, 0.8)
}
function getResolvedShaftDepth(node: ElevatorNode) {
return Math.max(node.shaftDepth ?? node.depth, node.depth, 0.8)
}
function getResolvedShaftWallThickness(node: ElevatorNode) {
return Math.max(node.shaftWallThickness ?? 0.09, 0.04)
}
function radiansToDegrees(radians: number) {
return Math.round((radians * 180) / Math.PI)
}
function degreesToRadians(degrees: number) {
return (degrees * Math.PI) / 180
}
export function ElevatorPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const runtime = useInteractive(
useShallow((s) => {
const state = selectedId ? s.elevators[selectedId as AnyNodeId] : null
if (!state) return null
return {
currentLevelId: state.currentLevelId,
queue: state.queue,
targetLevelId: state.targetLevelId,
}
}),
)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ElevatorNode | undefined) : undefined,
)
const liveOverrides = useLiveNodeOverrides((s) =>
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
)
const liveTransform = useLiveTransforms((s) =>
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
)
useEffect(() => {
return () => {
if (!selectedId) return
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
}
}, [selectedId])
const levels = useScene(
useShallow((s) => {
if (!(node?.parentId && s.nodes[node.parentId as AnyNodeId]?.type === 'building')) return []
const building = s.nodes[node.parentId as AnyNodeId]
if (building?.type !== 'building') return []
return building.children
.map((childId) => s.nodes[childId as AnyNodeId])
.filter((entry): entry is LevelNode => entry?.type === 'level')
.sort((left, right) => left.level - right.level)
}),
)
const handleUpdate = useCallback(
(updates: Partial<ElevatorNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const clearLivePreview = useCallback(() => {
if (!selectedId) return
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
}, [selectedId])
useEffect(() => {
if (!(selectedId && node?.type === 'elevator')) return
const supportY = resolveElevatorNodeSupportY(node)
if (node.position[1] >= supportY - 1e-4) return
updateNode(selectedId as AnyNode['id'], {
position: [node.position[0], supportY, node.position[2]],
})
}, [
node?.defaultLevelId,
node?.fromLevelId,
node?.id,
node?.parentId,
node?.position[0],
node?.position[1],
node?.position[2],
node?.type,
selectedId,
updateNode,
])
const previewMetric = useCallback(
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
if (!selectedId) return
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { [key]: value })
},
[selectedId],
)
const commitMetric = useCallback(
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
if (!selectedId) return
const hasChange = !(node && Math.abs(Number(node[key]) - Number(value)) <= 1e-6)
if (hasChange) {
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<ElevatorNode>)
}
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
},
[node, selectedId, updateNode],
)
const previewTransform = useCallback(
(position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => {
if (!selectedId) return
useLiveTransforms.getState().set(selectedId as AnyNodeId, { position, rotation })
},
[selectedId],
)
const commitTransform = useCallback(
(position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => {
if (!(selectedId && node)) return
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
const positionChanged = node.position.some(
(value, index) => Math.abs(value - position[index]!) > 1e-6,
)
const rotationChanged = Math.abs(node.rotation - rotation) > 1e-6
if (positionChanged || rotationChanged) {
updateNode(selectedId as AnyNode['id'], { position, rotation })
}
},
[node, selectedId, updateNode],
)
const getSupportedPosition = useCallback(
(x: number, z: number): ElevatorNode['position'] => {
if (!node) return [x, 0, z]
const supportY = resolveElevatorSupportY({
buildingId: node.parentId,
preferredLevelId: node.fromLevelId ?? node.defaultLevelId,
x,
z,
})
return [x, supportY, z]
},
[node],
)
const handleClose = useCallback(() => {
clearLivePreview()
setSelection({ selectedIds: [] })
}, [clearLivePreview, setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
clearLivePreview()
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [clearLivePreview, node, setMovingNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!(node && node.parentId)) return
sfxEmitter.emit('sfx:item-pick')
const duplicate = ElevatorNodeSchema.parse({
...structuredClone(node),
id: undefined,
name: node.name ? `${node.name} Copy` : 'Elevator Copy',
position: [node.position[0] + 1, node.position[1], node.position[2] + 1],
metadata: { ...(stripDuplicateFlags(node.metadata) as Record<string, unknown>), isNew: true },
})
createNode(duplicate, node.parentId as AnyNodeId)
clearLivePreview()
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [clearLivePreview, node, createNode, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
sfxEmitter.emit('sfx:structure-delete')
clearLivePreview()
useScene.getState().deleteNode(selectedId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [clearLivePreview, selectedId, node, setSelection])
const requestLevel = useCallback(
(levelId: LevelNode['id']) => {
if (!node) return
if ((node.disabledLevelIds ?? []).includes(levelId)) return
requestElevatorLevel(node.id as AnyNodeId, levelId as AnyNodeId)
},
[node],
)
const toggleLevelAccess = useCallback(
(field: ElevatorAccessField, levelId: LevelNode['id']) => {
if (!node) return
const disabledIds = new Set(node.disabledLevelIds ?? [])
const serviceOnlyIds = new Set(node.serviceOnlyLevelIds ?? [])
const targetSet = field === 'disabledLevelIds' ? disabledIds : serviceOnlyIds
if (targetSet.has(levelId)) {
targetSet.delete(levelId)
} else {
targetSet.add(levelId)
}
if (field === 'disabledLevelIds' && disabledIds.has(levelId)) {
serviceOnlyIds.delete(levelId)
}
if (field === 'serviceOnlyLevelIds' && serviceOnlyIds.has(levelId)) {
disabledIds.delete(levelId)
}
const nextServiceLevels = getServiceLevels(
levels,
getResolvedFromLevelId(node, levels),
getResolvedToLevelId(node, levels, getResolvedFromLevelId(node, levels)),
)
const nextDefaultLevelId =
node.defaultLevelId && !disabledIds.has(node.defaultLevelId)
? node.defaultLevelId
: (nextServiceLevels.find((level) => !disabledIds.has(level.id))?.id ??
nextServiceLevels[0]?.id ??
null)
handleUpdate({
defaultLevelId: nextDefaultLevelId,
disabledLevelIds: Array.from(disabledIds),
serviceOnlyLevelIds: Array.from(serviceOnlyIds),
})
},
[handleUpdate, levels, node],
)
const handleServiceBoundaryChange = useCallback(
(field: 'fromLevelId' | 'toLevelId', levelId: string) => {
if (!node) return
const nextFromLevelId =
field === 'fromLevelId' ? levelId : getResolvedFromLevelId(node, levels)
const nextToLevelId =
field === 'toLevelId' ? levelId : getResolvedToLevelId(node, levels, nextFromLevelId)
const nextServedLevels = getServiceLevels(levels, nextFromLevelId, nextToLevelId)
const currentDefaultIsServed = nextServedLevels.some(
(level) => level.id === node.defaultLevelId,
)
handleUpdate({
[field]: levelId || null,
defaultLevelId: currentDefaultIsServed
? node.defaultLevelId
: nextFromLevelId || nextServedLevels[0]?.id || null,
...(field === 'fromLevelId'
? {
position: [
node.position[0],
resolveElevatorSupportY({
buildingId: node.parentId,
preferredLevelId: nextFromLevelId,
x: node.position[0],
z: node.position[2],
}),
node.position[2],
] as ElevatorNode['position'],
}
: {}),
servedLevelIds: undefined,
} as Partial<ElevatorNode>)
},
[node, levels, handleUpdate],
)
if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null
const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node
const displayPosition = liveTransform?.position ?? displayNode.position
const displayRotation = liveTransform?.rotation ?? displayNode.rotation
const displayRotationDegrees = radiansToDegrees(displayRotation)
const displayShaftWidth = getResolvedShaftWidth(displayNode)
const displayShaftDepth = getResolvedShaftDepth(displayNode)
const displayShaftWallThickness = getResolvedShaftWallThickness(displayNode)
const fromLevelId = getResolvedFromLevelId(node, levels)
const toLevelId = getResolvedToLevelId(node, levels, fromLevelId)
const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId)
const servedLevelIdSet = new Set<string>(servedLevels.map((level) => level.id))
const disabledLevelIds = new Set(
(node.disabledLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)),
)
const serviceOnlyLevelIds = new Set(
(node.serviceOnlyLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)),
)
const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id))
const defaultLevelOptions =
enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels
const selectedDefaultLevelId = defaultLevelOptions.some(
(level) => level.id === node.defaultLevelId,
)
? (node.defaultLevelId ?? '')
: fromLevelId
const activeLevelId =
runtime?.currentLevelId ??
(servedLevels.some((level) => level.id === node.defaultLevelId)
? node.defaultLevelId
: fromLevelId || levels[0]?.id) ??
null
const destinationOrderByLevelId = new Map<string, number>()
const orderedDestinationIds: string[] = []
if (runtime?.targetLevelId) orderedDestinationIds.push(runtime.targetLevelId)
for (const levelId of runtime?.queue ?? []) {
if (!orderedDestinationIds.includes(levelId)) orderedDestinationIds.push(levelId)
}
orderedDestinationIds.forEach((levelId, index) => {
destinationOrderByLevelId.set(levelId, index + 1)
})
return (
<PanelWrapper
icon="/icons/elevator.svg"
onClose={handleClose}
title={node.name || 'Elevator'}
width={300}
>
<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="text-destructive hover:text-destructive"
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label="X"
max={50}
min={-50}
onChange={(value) => {
const position = getSupportedPosition(value, displayPosition[2])
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position = getSupportedPosition(value, displayPosition[2])
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[0])}
/>
<SliderControl
label="Y"
max={50}
min={-50}
onChange={(value) => {
const position: ElevatorNode['position'] = [
displayPosition[0],
value,
displayPosition[2],
]
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position: ElevatorNode['position'] = [
displayPosition[0],
value,
displayPosition[2],
]
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[1])}
/>
<SliderControl
label="Z"
max={50}
min={-50}
onChange={(value) => {
const position = getSupportedPosition(displayPosition[0], value)
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position = getSupportedPosition(displayPosition[0], value)
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[2])}
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label="Yaw"
max={180}
min={-180}
onChange={(degrees) => previewTransform(displayPosition, degreesToRadians(degrees))}
onCommit={(degrees) => commitTransform(displayPosition, degreesToRadians(degrees))}
precision={0}
restoreOnCommit={false}
step={1}
unit="°"
value={displayRotationDegrees}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
commitTransform(displayPosition, displayRotation - Math.PI / 4)
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
commitTransform(displayPosition, displayRotation + Math.PI / 4)
}}
/>
</div>
</PanelSection>
<PanelSection title="Cab">
<MetricControl
label="Width"
max={4}
min={0.8}
onChange={(value) => previewMetric('width', value)}
onCommit={(value) => commitMetric('width', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.width}
/>
<MetricControl
label="Depth"
max={4}
min={0.8}
onChange={(value) => previewMetric('depth', value)}
onCommit={(value) => commitMetric('depth', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.depth}
/>
<MetricControl
label="Cab Height"
max={4}
min={1.8}
onChange={(value) => previewMetric('cabHeight', value)}
onCommit={(value) => commitMetric('cabHeight', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.cabHeight}
/>
</PanelSection>
<PanelSection title="Shaft">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Shaft Style
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({ shaftStyle: event.target.value as ElevatorNode['shaftStyle'] })
}
value={displayNode.shaftStyle ?? 'solid'}
>
{SHAFT_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<MetricControl
label="Shaft Width"
max={5}
min={displayNode.width}
onChange={(value) => previewMetric('shaftWidth', Math.max(value, displayNode.width))}
onCommit={(value) => commitMetric('shaftWidth', Math.max(value, displayNode.width))}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayShaftWidth}
/>
<MetricControl
label="Shaft Depth"
max={5}
min={displayNode.depth}
onChange={(value) => previewMetric('shaftDepth', Math.max(value, displayNode.depth))}
onCommit={(value) => commitMetric('shaftDepth', Math.max(value, displayNode.depth))}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayShaftDepth}
/>
<MetricControl
label="Wall Thickness"
max={0.4}
min={0.04}
onChange={(value) => previewMetric('shaftWallThickness', value)}
onCommit={(value) => commitMetric('shaftWallThickness', value)}
precision={2}
restoreOnCommit={false}
step={0.01}
unit="m"
value={displayShaftWallThickness}
/>
</PanelSection>
<PanelSection title="Doors">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Opening Style
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({ doorStyle: event.target.value as ElevatorNode['doorStyle'] })
}
value={displayNode.doorStyle ?? 'center-opening'}
>
{DOOR_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Door Type
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({
doorPanelStyle: event.target.value as ElevatorNode['doorPanelStyle'],
})
}
value={displayNode.doorPanelStyle ?? 'glass-frame'}
>
{DOOR_PANEL_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<MetricControl
label="Door Width"
max={Math.max(displayNode.width - 0.1, 0.5)}
min={0.45}
onChange={(value) => previewMetric('doorWidth', value)}
onCommit={(value) => commitMetric('doorWidth', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.doorWidth}
/>
<MetricControl
label="Door Height"
max={Math.max(displayNode.cabHeight - 0.1, 1.3)}
min={1.2}
onChange={(value) => previewMetric('doorHeight', value)}
onCommit={(value) => commitMetric('doorHeight', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.doorHeight}
/>
</PanelSection>
<PanelSection title="Service">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
From
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
onChange={(event) =>
handleServiceBoundaryChange('fromLevelId', event.target.value)
}
value={fromLevelId}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
To
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
onChange={(event) => handleServiceBoundaryChange('toLevelId', event.target.value)}
value={toLevelId}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Default Floor
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) => handleUpdate({ defaultLevelId: event.target.value || null })}
value={selectedDefaultLevelId}
>
{defaultLevelOptions.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
</PanelSection>
<PanelSection title="Access">
<div className="space-y-2">
{servedLevels.map((level) => {
const isDisabled = disabledLevelIds.has(level.id)
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
return (
<div
className="flex items-center justify-between gap-2 rounded-lg border border-border/45 bg-[#2C2C2E] px-2.5 py-2"
key={level.id}
>
<span className="min-w-0 truncate text-sm">
{level.name || `Level ${level.level}`}
</span>
<div className="flex shrink-0 gap-1.5">
<button
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
isServiceOnly
? 'border-sky-300/45 bg-sky-400/15 text-sky-100'
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
} ${isDisabled ? 'cursor-not-allowed opacity-45' : ''}`}
disabled={isDisabled}
onClick={() => toggleLevelAccess('serviceOnlyLevelIds', level.id)}
type="button"
>
Service
</button>
<button
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
isDisabled
? 'border-red-300/45 bg-red-400/15 text-red-100'
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
}`}
onClick={() => toggleLevelAccess('disabledLevelIds', level.id)}
type="button"
>
Disabled
</button>
</div>
</div>
)
})}
</div>
</PanelSection>
<PanelSection title="Destination">
<div className="grid grid-cols-2 gap-1.5">
{servedLevels.map((level) => {
const isActive = activeLevelId === level.id
const stopOrder = destinationOrderByLevelId.get(level.id)
const isDisabled = disabledLevelIds.has(level.id)
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
return (
<button
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
isDisabled
? 'cursor-not-allowed border-border/35 bg-[#202024] text-muted-foreground/55'
: isActive
? 'border-emerald-400/45 bg-emerald-400/15 text-emerald-100'
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
}`}
disabled={isDisabled}
key={level.id}
onClick={() => requestLevel(level.id)}
type="button"
>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs">{level.name || `Level ${level.level}`}</span>
{isDisabled ? (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Disabled
</span>
) : isServiceOnly ? (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Service
</span>
) : (
stopOrder && (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Stop {stopOrder}
</span>
)
)}
</span>
<span
className={`flex h-6 min-w-6 items-center justify-center rounded-full border border-white/15 bg-black/20 ${
stopOrder ? 'px-1.5 font-mono text-[11px] font-semibold' : ''
}`}
>
{isDisabled ? '×' : (stopOrder ?? <Send className="h-3 w-3" />)}
</span>
</button>
)
})}
</div>
</PanelSection>
<PanelSection title="Motion">
<SliderControl
label="Speed"
max={8}
min={0.5}
onChange={(value) => handleUpdate({ speed: value })}
precision={1}
step={0.1}
unit="m/s"
value={node.speed}
/>
<SliderControl
label="Door Time"
max={2200}
min={300}
onChange={(value) => handleUpdate({ doorDurationMs: value })}
step={50}
unit="ms"
value={node.doorDurationMs}
/>
<SliderControl
label="Dwell"
max={5000}
min={300}
onChange={(value) => handleUpdate({ dwellMs: value })}
step={100}
unit="ms"
value={node.dwellMs}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,313 +0,0 @@
'use client'
import { type AnyNode, getScaledDimensions, ItemNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { CollectionsPopover } from './collections/collections-popover'
import { PanelWrapper } from './panel-wrapper'
export function ItemPanel() {
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 node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined,
)
const [uniformScale, setUniformScale] = useState(true)
// Panel slider-drag fix recipe (plans/editor-node-registry.md). Item
// panel has scale + position + rotation sliders — same Maximum update
// depth cascade risk as fence / wall / etc. without the nodeRef.
const nodeRef = useRef(node)
nodeRef.current = node
const handleUpdate = useCallback(
(updates: Partial<ItemNode>) => {
if (!selectedId) return
const n = nodeRef.current
if (!n) return
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
if (n.asset.attachTo === 'wall' && n.parentId) {
requestAnimationFrame(() => {
useScene.getState().dirtyNodes.add(n.parentId as AnyNode['id'])
})
}
},
[selectedId],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMove = useCallback(() => {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
const proto = ItemNode.parse({
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
name: node.name,
asset: node.asset,
parentId: node.parentId,
side: node.side,
metadata: { isNew: true },
})
setMovingNode(proto)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] })
}, [selectedId, deleteNode, setSelection])
if (!(node && node.type === 'item' && selectedId)) return null
return (
<PanelWrapper
icon={node.asset.thumbnail || '/icons/furniture.png'}
onClose={handleClose}
title={node.name || node.asset.name}
width={300}
>
<PanelSection title="Position">
<SliderControl
label={
<>
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
</>
}
max={node.position[0] + 2}
min={node.position[0] - 2}
onChange={(value) =>
handleUpdate({ position: [value, node.position[1], node.position[2]] })
}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<SliderControl
label={
<>
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
</>
}
max={node.position[1] + 2}
min={node.position[1] - 2}
onChange={(value) =>
handleUpdate({ position: [node.position[0], value, node.position[2]] })
}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<SliderControl
label={
<>
Z<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
</>
}
max={node.position[2] + 2}
min={node.position[2] - 2}
onChange={(value) =>
handleUpdate({ position: [node.position[0], node.position[1], value] })
}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label={
<>
Y<sub className="ml-[1px] text-[11px] opacity-70">rot</sub>
</>
}
max={Math.round((node.rotation[1] * 180) / Math.PI) + 45}
min={Math.round((node.rotation[1] * 180) / Math.PI) - 45}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
precision={0}
step={1}
unit="°"
value={Math.round((node.rotation[1] * 180) / Math.PI)}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const radians = ((currentDegrees - 45) * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const radians = ((currentDegrees + 45) * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
/>
</div>
</PanelSection>
<PanelSection title="Scale">
<div className="flex items-center justify-between px-2 pb-2">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Uniform Scale
</span>
<button
className={cn(
'flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground',
uniformScale ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
)}
onClick={() => setUniformScale((v) => !v)}
type="button"
>
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
</button>
</div>
{uniformScale ? (
<SliderControl
label={
<>
XYZ<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
</>
}
max={10}
min={0.01}
onChange={(value) => {
const v = Math.max(0.01, value)
handleUpdate({ scale: [v, v, v] })
}}
precision={2}
step={0.1}
value={Math.round(node.scale[0] * 100) / 100}
/>
) : (
<>
<SliderControl
label={
<>
X<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
</>
}
max={10}
min={0.01}
onChange={(value) =>
handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })
}
precision={2}
step={0.1}
value={Math.round(node.scale[0] * 100) / 100}
/>
<SliderControl
label={
<>
Y<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
</>
}
max={10}
min={0.01}
onChange={(value) =>
handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })
}
precision={2}
step={0.1}
value={Math.round(node.scale[1] * 100) / 100}
/>
<SliderControl
label={
<>
Z<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
</>
}
max={10}
min={0.01}
onChange={(value) =>
handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })
}
precision={2}
step={0.1}
value={Math.round(node.scale[2] * 100) / 100}
/>
</>
)}
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
<span>Dimensions</span>
{(() => {
const [w, h, d] = getScaledDimensions(node)
return (
<span className="font-mono text-white">
{Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100}
</span>
)
})()}
</div>
</PanelSection>
<PanelSection title="Collections">
<ActionGroup>
<CollectionsPopover
collectionIds={node.collectionIds}
nodeId={selectedId as AnyNode['id']}
>
<ActionButton label="Manage collections…" />
</CollectionsPopover>
</ActionGroup>
</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>
)
}
@@ -24,23 +24,12 @@ import { useCallback, useEffect, useState } from 'react'
import { useIsMobile } from '../../../hooks/use-mobile'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ColumnPanel } from './column-panel'
import { DoorPanel } from './door-panel'
import { ElevatorPanel } from './elevator-panel'
import { ItemPanel } from './item-panel'
import { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar'
import { getNodeDisplay } from './node-display'
import { PaintPanel } from './paint-panel'
import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel'
import { SpawnPanel } from './spawn-panel'
import { StairPanel } from './stair-panel'
import { StairSegmentPanel } from './stair-segment-panel'
import { WallPanel } from './wall-panel'
import { WindowPanel } from './window-panel'
type MovableNode =
| ItemNode
@@ -81,37 +70,15 @@ function isMovableNode(node: AnyNode | null): node is MovableNode {
function panelForType(type: string | null) {
if (!type) return null
switch (type) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'roof-segment':
return <RoofSegmentPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'spawn':
return <SpawnPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'door':
return <DoorPanel />
case 'elevator':
return <ElevatorPanel />
case 'window':
return <WindowPanel />
default:
// Registry fallback: any kind registered via @pascal-app/nodes with a
// `parametrics` descriptor on its NodeDefinition gets an auto-derived
// panel. Phase 4 will replace the hardcoded switch above with the
// registry-first path; until then this fallback lets new kinds (shelf,
// etc.) have a working inspector without per-kind panel files.
return <ParametricInspector />
}
// Every kind now renders through `<ParametricInspector>`, which either
// composes auto-derived editors from `parametrics.groups` or lazy-
// loads the kind-owned panel via `parametrics.customPanel`. The
// hardcoded switch is gone — all per-kind panel layout lives in
// `nodes/src/<kind>/panel.tsx`. The `type` arg is preserved for
// future cases where we might want a non-registry fallback (e.g.
// reference scale, paint mode); leave the function shape intact.
void type
return <ParametricInspector />
}
function MobilePanelLayer({
@@ -1,280 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
type MaterialSchema,
type RoofNode,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofSurfaceMaterialRole,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint'
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
export function RoofPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
)
// Shallow selector — only re-renders when the segment list content changes.
const segments = useScene(
useShallow((s) => {
if (!node) return []
return (node.children ?? [])
.map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
}),
)
const handleUpdate = useCallback(
(updates: Partial<RoofNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const materialTargetRole =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'top' ||
selectedMaterialTarget.role === 'edge' ||
selectedMaterialTarget.role === 'wall')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetRole)) return
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate, materialTargetRole, node],
)
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetRole)) return
handleUpdate(
buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
)
},
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleAddSegment = useCallback(() => {
if (!node) return
const segment = RoofSegmentNodeSchema.parse({
width: 6,
depth: 6,
wallHeight: 0.5,
roofHeight: 2.5,
roofType: 'gable',
position: [2, 0, 2],
})
createNode(segment, node.id as AnyNodeId)
}, [node, createNode])
const handleSelectSegment = useCallback(
(segmentId: string) => {
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
},
[setSelection],
)
const handleDuplicate = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
try {
duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
} catch (e) {
console.error('Failed to duplicate roof', e)
}
}, [node])
const handleMove = useCallback(() => {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
sfxEmitter.emit('sfx:item-delete')
const parentId = node.parentId
useScene.getState().deleteNode(selectedId as AnyNodeId)
if (parentId) {
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
}
setSelection({ selectedIds: [] })
}, [selectedId, node, setSelection])
if (!(node && node.type === 'roof' && selectedId)) return null
return (
<PanelWrapper
icon="/icons/roof.png"
onClose={handleClose}
title={node.name || 'Roof'}
width={300}
>
<PanelSection title="Segments">
<div className="flex flex-col gap-1">
{segments.map((seg, i) => (
<button
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
key={seg.id}
onClick={() => handleSelectSegment(seg.id)}
type="button"
>
<span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
<span className="text-muted-foreground text-xs capitalize">{seg.roofType}</span>
</button>
))}
</div>
<ActionGroup>
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Segment"
onClick={handleAddSegment}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label="X"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[0] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<SliderControl
label="Y"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[1] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<SliderControl
label="Z"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[2] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
/>
<SliderControl
label="Rotation"
max={180}
min={-180}
onChange={(degrees) => {
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
}}
precision={0}
step={1}
unit="°"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
}}
/>
</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>
<PanelSection title="Material">
{materialTargetRole ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the roof surface you want to edit. Materials apply to one target at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="roof"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,311 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofType,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
const ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [
{ label: 'Hip', value: 'hip' },
{ label: 'Gable', value: 'gable' },
{ label: 'Shed', value: 'shed' },
{ label: 'Flat', value: 'flat' },
]
const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
{ label: 'Gambrel', value: 'gambrel' },
{ label: 'Dutch', value: 'dutch' },
{ label: 'Mansard', value: 'mansard' },
]
export function RoofSegmentPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined,
)
const handleUpdate = useCallback(
(updates: Partial<RoofSegmentNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleBack = useCallback(() => {
if (node?.parentId) {
setSelection({ selectedIds: [node.parentId] })
}
}, [node?.parentId, setSelection])
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
sfxEmitter.emit('sfx:item-pick')
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
// Offset slightly so it's visible
duplicateInfo.position = [
duplicateInfo.position[0] + 1,
duplicateInfo.position[1],
duplicateInfo.position[2] + 1,
]
try {
const duplicate = RoofSegmentNodeSchema.parse(duplicateInfo)
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
setMovingNode(duplicate)
} catch (e) {
console.error('Failed to duplicate roof segment', e)
}
}, [node, setSelection, setMovingNode])
const handleMove = useCallback(() => {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
sfxEmitter.emit('sfx:item-delete')
const parentId = node.parentId
useScene.getState().deleteNode(selectedId as AnyNodeId)
if (parentId) {
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
setSelection({ selectedIds: [parentId] })
} else {
setSelection({ selectedIds: [] })
}
}, [selectedId, node, setSelection])
if (!(node && node.type === 'roof-segment' && selectedId)) return null
return (
<PanelWrapper
icon="/icons/roof.png"
onBack={handleBack}
onClose={handleClose}
title={node.name || 'Roof Segment'}
width={300}
>
<PanelSection title="Roof Type">
<SegmentedControl
onChange={(v) => handleUpdate({ roofType: v })}
options={ROOF_TYPE_OPTIONS}
value={node.roofType}
/>
<SegmentedControl
onChange={(v) => handleUpdate({ roofType: v })}
options={ROOF_TYPE_OPTIONS_2}
value={node.roofType}
/>
</PanelSection>
<PanelSection title="Footprint">
<SliderControl
label="Width"
max={25}
min={0.5}
onChange={(v) => handleUpdate({ width: v })}
precision={2}
step={0.5}
unit="m"
value={Math.round(node.width * 100) / 100}
/>
<SliderControl
label="Depth"
max={25}
min={0.5}
onChange={(v) => handleUpdate({ depth: v })}
precision={2}
step={0.5}
unit="m"
value={Math.round(node.depth * 100) / 100}
/>
</PanelSection>
<PanelSection title="Heights">
<SliderControl
label="Wall"
max={5}
min={0}
onChange={(v) => handleUpdate({ wallHeight: v })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.wallHeight * 100) / 100}
/>
<SliderControl
label="Roof"
max={15}
min={0}
onChange={(v) => handleUpdate({ roofHeight: v })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.roofHeight * 100) / 100}
/>
</PanelSection>
<PanelSection title="Structure">
<SliderControl
label="Wall Thick."
max={1}
min={0.05}
onChange={(v) => handleUpdate({ wallThickness: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.wallThickness * 100) / 100}
/>
<SliderControl
label="Deck Thick."
max={0.3}
min={0.04}
onChange={(v) => handleUpdate({ deckThickness: v })}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.deckThickness * 100) / 100}
/>
<SliderControl
label="Overhang"
max={1}
min={0}
onChange={(v) => handleUpdate({ overhang: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.overhang * 100) / 100}
/>
<SliderControl
label="Shingle Thick."
max={0.3}
min={0.02}
onChange={(v) => handleUpdate({ shingleThickness: v })}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.shingleThickness * 100) / 100}
/>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label="X"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[0] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<SliderControl
label="Y"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[1] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<SliderControl
label="Z"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[2] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
/>
<SliderControl
label="Rotation"
max={180}
min={-180}
onChange={(degrees) => {
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
}}
precision={0}
step={1}
unit="°"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
}}
/>
</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>
)
}
@@ -1,161 +0,0 @@
'use client'
import { type AnyNode, type SpawnNode, useLiveTransforms, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
export function SpawnPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SpawnNode | undefined) : undefined,
)
const [draftRotation, setDraftRotation] = useState<number | null>(null)
useEffect(() => {
if (!(node && node.type === 'spawn')) {
setDraftRotation(null)
return
}
setDraftRotation(node.rotation)
useLiveTransforms.getState().clear(node.id)
}, [node?.id, node?.rotation, node?.type])
const handleUpdate = useCallback(
(updates: Partial<SpawnNode>) => {
if (!(selectedId && node)) return
updateNode(selectedId as AnyNode['id'], updates)
},
[node, selectedId, updateNode],
)
const handleRotationChange = useCallback(
(degrees: number) => {
if (!(node && selectedId)) return
const nextRotation = (degrees * Math.PI) / 180
setDraftRotation(nextRotation)
useLiveTransforms.getState().set(selectedId as AnyNode['id'], {
position: [...node.position],
rotation: nextRotation,
})
},
[node, selectedId],
)
const commitRotation = useCallback(
(degrees: number) => {
if (!(node && selectedId)) return
const nextRotation = (degrees * Math.PI) / 180
useLiveTransforms.getState().clear(selectedId as AnyNode['id'])
setDraftRotation(nextRotation)
if (Math.abs(nextRotation - node.rotation) > 1e-6) {
updateNode(selectedId as AnyNode['id'], { rotation: nextRotation })
}
},
[node, selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId) return
sfxEmitter.emit('sfx:structure-delete')
deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] })
}, [deleteNode, selectedId, setSelection])
if (!(node && node.type === 'spawn' && selectedId)) return null
const rotationDegrees = Math.round(((draftRotation ?? node.rotation) * 180) / Math.PI)
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
return (
<PanelWrapper icon="/icons/site.png" onClose={handleClose} title="Spawn Point" width={300}>
<PanelSection title="Position">
<SliderControl
label="X"
max={node.position[0] + 2}
min={node.position[0] - 2}
onChange={(value) =>
handleUpdate({ position: [value, node.position[1], node.position[2]] })
}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<SliderControl
label="Y"
max={node.position[1] + 2}
min={node.position[1] - 2}
onChange={(value) =>
handleUpdate({ position: [node.position[0], value, node.position[2]] })
}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<SliderControl
label="Z"
max={node.position[2] + 2}
min={node.position[2] - 2}
onChange={(value) =>
handleUpdate({ position: [node.position[0], node.position[1], value] })
}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
/>
</PanelSection>
<PanelSection title="Facing">
<SliderControl
label="Yaw"
max={storedRotationDegrees + 90}
min={storedRotationDegrees - 90}
onChange={handleRotationChange}
onCommit={commitRotation}
precision={0}
step={1}
unit="°"
value={rotationDegrees}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
<ActionButton
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
icon={<Trash2 className="h-4 w-4" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,577 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
getEffectiveStairSurfaceMaterial,
type LevelNode,
type MaterialSchema,
type StairNode,
StairNode as StairNodeSchema,
type StairRailingMode,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
type StairSlabOpeningMode,
type StairSurfaceMaterialRole,
type StairTopLandingMode,
type StairType,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
{ label: 'None', value: 'none' },
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
{ label: 'Both', value: 'both' },
]
const STAIR_TYPE_OPTIONS: { label: string; value: StairType }[] = [
{ label: 'Straight', value: 'straight' },
{ label: 'Curved', value: 'curved' },
{ label: 'Spiral', value: 'spiral' },
]
const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[] = [
{ label: 'None', value: 'none' },
{ label: 'Integrated', value: 'integrated' },
]
const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [
{ label: 'None', value: 'none' },
{ label: 'Destination', value: 'destination' },
]
export function StairPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
)
const levels = useScene(
useShallow((s) =>
Object.values(s.nodes)
.filter((entry): entry is LevelNode => entry.type === 'level')
.sort((left, right) => left.level - right.level),
),
)
const segments = useScene(
useShallow((s) => {
if (!selectedId) return []
const stairNode = s.nodes[selectedId as AnyNode['id']] as StairNode | undefined
if (stairNode?.type !== 'stair') return []
return (stairNode.children ?? [])
.map((childId) => s.nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((entry): entry is StairSegmentNode => entry?.type === 'stair-segment')
}),
)
const handleUpdate = useCallback(
(updates: Partial<StairNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const materialTargetRole =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'railing' ||
selectedMaterialTarget.role === 'tread' ||
selectedMaterialTarget.role === 'side')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetRole)) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate, materialTargetRole, node],
)
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetRole)) return
handleUpdate(
buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
)
},
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const getLastSegmentFillDefaults = useCallback(() => {
if (!node) return { fillToFloor: true }
const children = node.children ?? []
const lastChildId = children[children.length - 1]
if (lastChildId) {
const lastChild = useScene.getState().nodes[lastChildId as AnyNodeId] as
| StairSegmentNode
| undefined
if (lastChild?.type === 'stair-segment') {
return { fillToFloor: lastChild.fillToFloor }
}
}
return { fillToFloor: true }
}, [node])
const handleAddFlight = useCallback(() => {
if (!node) return
const { fillToFloor } = getLastSegmentFillDefaults()
const segment = StairSegmentNodeSchema.parse({
segmentType: 'stair',
width: 1.0,
length: 3.0,
height: 2.5,
stepCount: 10,
attachmentSide: 'front',
fillToFloor,
thickness: 0.25,
position: [0, 0, 0],
})
createNode(segment, node.id as AnyNodeId)
}, [node, createNode, getLastSegmentFillDefaults])
const handleAddLanding = useCallback(() => {
if (!node) return
const { fillToFloor } = getLastSegmentFillDefaults()
const segment = StairSegmentNodeSchema.parse({
segmentType: 'landing',
width: 1.0,
length: 1.0,
height: 0,
stepCount: 0,
attachmentSide: 'front',
fillToFloor,
thickness: 0.32,
position: [0, 0, 0],
})
createNode(segment, node.id as AnyNodeId)
}, [node, createNode, getLastSegmentFillDefaults])
const handleSelectSegment = useCallback(
(segmentId: string) => {
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
},
[setSelection],
)
const handleDuplicate = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
try {
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
} catch (e) {
console.error('Failed to duplicate stair', e)
}
}, [node])
const handleMove = useCallback(() => {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
sfxEmitter.emit('sfx:item-delete')
const parentId = node.parentId
useScene.getState().deleteNode(selectedId as AnyNodeId)
if (parentId) {
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
}
setSelection({ selectedIds: [] })
}, [selectedId, node, setSelection])
if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
return (
<PanelWrapper
icon="/icons/stairs.png"
onClose={handleClose}
title={node.name || 'Staircase'}
width={300}
>
<PanelSection title="Type">
<SegmentedControl
onChange={(value) =>
handleUpdate(
value === 'spiral' && node.stairType !== 'spiral'
? {
stairType: value,
sweepAngle: DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
position: [node.position[0], 0, node.position[2]],
}
: { stairType: value },
)
}
options={STAIR_TYPE_OPTIONS}
value={node.stairType ?? 'straight'}
/>
</PanelSection>
<PanelSection title="Opening">
<div className="space-y-3">
<ToggleControl
checked={(node.slabOpeningMode ?? 'none') === 'destination'}
label="Auto Cutout"
onChange={(checked) =>
handleUpdate({
slabOpeningMode: checked ? 'destination' : 'none',
})
}
/>
<div className="space-y-1.5">
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
From Level
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
value={resolvedFromLevelId ?? ''}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level + 1}`}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
To Level
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
value={resolvedToLevelId ?? ''}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level + 1}`}
</option>
))}
</select>
</div>
<SegmentedControl
onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })}
options={STAIR_SLAB_OPENING_OPTIONS}
value={node.slabOpeningMode ?? 'none'}
/>
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
<MetricControl
label="Opening Offset"
max={0.5}
min={0}
onChange={(value) => handleUpdate({ openingOffset: value })}
precision={2}
step={0.01}
unit="m"
value={Math.round((node.openingOffset ?? 0) * 100) / 100}
/>
) : null}
</div>
</PanelSection>
{node.stairType === 'straight' && (
<PanelSection title="Segments">
<div className="flex flex-col gap-1">
{segments.map((seg, i) => (
<button
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
key={seg.id}
onClick={() => handleSelectSegment(seg.id)}
type="button"
>
<span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
<span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
</button>
))}
</div>
<div className="flex gap-1.5">
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add flight"
onClick={handleAddFlight}
/>
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add landing"
onClick={handleAddLanding}
/>
</div>
</PanelSection>
)}
{(node.stairType === 'curved' || node.stairType === 'spiral') && (
<PanelSection title="Geometry">
<MetricControl
label="Width"
max={10}
min={0.4}
onChange={(value) => handleUpdate({ width: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.width ?? 1) * 100) / 100}
/>
<MetricControl
label="Rise"
max={10}
min={0.2}
onChange={(value) => handleUpdate({ totalRise: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
/>
<MetricControl
label="Steps"
max={32}
min={2}
onChange={(value) => handleUpdate({ stepCount: Math.max(2, Math.round(value)) })}
precision={0}
step={1}
unit=""
value={Math.max(2, Math.round(node.stepCount ?? 10))}
/>
{node.stairType !== 'spiral' && (
<ToggleControl
checked={node.fillToFloor ?? true}
label="Fit To Floor"
onChange={(checked) => handleUpdate({ fillToFloor: checked })}
/>
)}
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
<MetricControl
label="Thickness"
max={1}
min={0.02}
onChange={(value) => handleUpdate({ thickness: value })}
precision={2}
step={0.01}
unit="m"
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
/>
)}
<MetricControl
label="Inner Radius"
max={10}
min={node.stairType === 'spiral' ? 0.05 : 0.2}
onChange={(value) => handleUpdate({ innerRadius: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.innerRadius ?? 0.9) * 100) / 100}
/>
<SliderControl
label="Sweep"
max={node.stairType === 'spiral' ? 720 : 270}
min={node.stairType === 'spiral' ? -720 : -270}
onChange={(degrees) => handleUpdate({ sweepAngle: (degrees * Math.PI) / 180 })}
precision={0}
step={1}
unit="°"
value={Math.round(((node.sweepAngle ?? Math.PI / 2) * 180) / Math.PI)}
/>
{node.stairType === 'spiral' && (
<>
<SegmentedControl
onChange={(value) => handleUpdate({ topLandingMode: value })}
options={TOP_LANDING_MODE_OPTIONS}
value={node.topLandingMode ?? 'none'}
/>
{(node.topLandingMode ?? 'none') === 'integrated' && (
<MetricControl
label="Top Landing"
max={5}
min={0.3}
onChange={(value) => handleUpdate({ topLandingDepth: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100}
/>
)}
<ToggleControl
checked={node.showCenterColumn ?? true}
label="Center Column"
onChange={(checked) => handleUpdate({ showCenterColumn: checked })}
/>
<ToggleControl
checked={node.showStepSupports ?? true}
label="Step Supports"
onChange={(checked) => handleUpdate({ showStepSupports: checked })}
/>
</>
)}
</PanelSection>
)}
<PanelSection title="Position">
<SliderControl
label="X"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[0] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<SliderControl
label="Y"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[1] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<SliderControl
label="Z"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[2] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
/>
<SliderControl
label="Rotation"
max={180}
min={-180}
onChange={(degrees) => {
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
}}
precision={0}
step={1}
unit="°"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
}}
/>
</div>
</PanelSection>
<PanelSection title="Railing">
<SegmentedControl
onChange={(value) => handleUpdate({ railingMode: value })}
options={RAILING_MODE_OPTIONS}
value={node.railingMode ?? 'none'}
/>
{(node.railingMode ?? 'none') !== 'none' && (
<SliderControl
label="Height"
max={1.4}
min={0.7}
onChange={(value) => handleUpdate({ railingHeight: value })}
precision={2}
step={0.02}
unit="m"
value={Math.round((node.railingHeight ?? 0.92) * 100) / 100}
/>
)}
</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>
<PanelSection title="Material">
{materialTargetRole ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the stair surface you want to edit. Materials apply to one target at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="stair"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,325 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type AttachmentSide,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
type StairSegmentType,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
const SEGMENT_TYPE_OPTIONS: { label: string; value: StairSegmentType }[] = [
{ label: 'Flight', value: 'stair' },
{ label: 'Landing', value: 'landing' },
]
const ATTACHMENT_SIDE_OPTIONS: { label: string; value: AttachmentSide }[] = [
{ label: 'Front', value: 'front' },
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]
export function StairSegmentPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined) : undefined,
)
// Boolean selector — re-renders only when this segment's position among the
// parent stair's children flips to/from "first".
const isFirstSegment = useScene((s) => {
if (!node?.parentId) return true
const parent = s.nodes[node.parentId as AnyNodeId]
if (!parent || parent.type !== 'stair') return true
const children = (parent as any).children ?? []
return children[0] === node.id
})
const handleUpdate = useCallback(
(updates: Partial<StairSegmentNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleBack = useCallback(() => {
if (node?.parentId) {
setSelection({ selectedIds: [node.parentId] })
}
}, [node?.parentId, setSelection])
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
sfxEmitter.emit('sfx:item-pick')
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
duplicateInfo.position = [
duplicateInfo.position[0] + 1,
duplicateInfo.position[1],
duplicateInfo.position[2] + 1,
]
try {
const duplicate = StairSegmentNodeSchema.parse(duplicateInfo)
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
setMovingNode(duplicate)
} catch (e) {
console.error('Failed to duplicate stair segment', e)
}
}, [node, setSelection, setMovingNode])
const handleMove = useCallback(() => {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
sfxEmitter.emit('sfx:item-delete')
const parentId = node.parentId
useScene.getState().deleteNode(selectedId as AnyNodeId)
if (parentId) {
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
setSelection({ selectedIds: [parentId] })
} else {
setSelection({ selectedIds: [] })
}
}, [selectedId, node, setSelection])
if (!(node && node.type === 'stair-segment' && selectedId)) return null
return (
<PanelWrapper
icon="/icons/stairs.png"
onBack={handleBack}
onClose={handleClose}
title={node.name || 'Stair Segment'}
width={300}
>
<PanelSection title="Type">
<SegmentedControl
onChange={(v) => {
const updates: Partial<StairSegmentNode> = { segmentType: v }
if (v === 'landing') {
updates.height = 0
updates.stepCount = 0
updates.length = 1.0
} else {
updates.height = 2.5
updates.stepCount = 10
updates.length = 3.0
}
handleUpdate(updates)
}}
options={SEGMENT_TYPE_OPTIONS}
value={node.segmentType}
/>
</PanelSection>
{!isFirstSegment && (
<PanelSection title="Attachment">
<SegmentedControl
onChange={(v) => handleUpdate({ attachmentSide: v })}
options={ATTACHMENT_SIDE_OPTIONS}
value={node.attachmentSide}
/>
</PanelSection>
)}
<PanelSection title="Dimensions">
<SliderControl
label="Width"
max={5}
min={0.5}
onChange={(v) => handleUpdate({ width: v })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.width * 100) / 100}
/>
<SliderControl
label="Length"
max={10}
min={0.5}
onChange={(v) => handleUpdate({ length: v })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.length * 100) / 100}
/>
{node.segmentType === 'stair' && (
<>
<SliderControl
label="Height"
max={10}
min={0.5}
onChange={(v) => handleUpdate({ height: v })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.height * 100) / 100}
/>
<SliderControl
label="Steps"
max={30}
min={2}
onChange={(v) => handleUpdate({ stepCount: Math.round(v) })}
precision={0}
step={1}
unit=""
value={node.stepCount}
/>
</>
)}
</PanelSection>
<PanelSection title="Structure">
<div className="flex items-center justify-between px-1 py-1">
<span className="text-muted-foreground text-xs">Fill to floor</span>
<button
className={`relative h-5 w-10 rounded-full transition-colors ${
node.fillToFloor ? 'bg-blue-500' : 'bg-[#3e3e3e]'
}`}
onClick={() => handleUpdate({ fillToFloor: !node.fillToFloor })}
type="button"
>
<div
className={`absolute top-1 h-3 w-3 rounded-full bg-white transition-transform ${
node.fillToFloor ? 'left-6' : 'left-1'
}`}
/>
</button>
</div>
{!node.fillToFloor && (
<SliderControl
label="Thickness"
max={1}
min={0.05}
onChange={(v) => handleUpdate({ thickness: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
/>
)}
</PanelSection>
<PanelSection title="Position">
<SliderControl
label="X"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[0] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<SliderControl
label="Y"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[1] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<SliderControl
label="Z"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[2] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
/>
<SliderControl
label="Rotation"
max={180}
min={-180}
onChange={(degrees) => {
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
}}
precision={0}
step={1}
unit="°"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
}}
/>
</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>
)
}
@@ -1,185 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
normalizeWallCurveOffset,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
)
// Boolean selector — re-renders only when this specific wall's child
// composition crosses the "has a door/window/wall-item" threshold.
const hasWallChildrenBlockingCurve = useScene((s) => {
if (!node) return false
return (node.children ?? []).some((childId) => {
const child = s.nodes[childId as AnyNodeId]
if (!child) return false
if (child.type === 'door' || child.type === 'window') return true
if (child.type === 'item') {
const attachTo = child.asset?.attachTo
return attachTo === 'wall' || attachTo === 'wall-side'
}
return false
})
})
// Mirror the latest node into a ref so the slider handlers below have
// stable identities across re-renders. Without this, every store tick
// (one per pointermove during a slider drag) rebuilt the handler
// refs, destabilising SliderControl's pointer-capture listeners and
// combining with float drift in `getWallCurveLength` produced a
// "Maximum update depth exceeded" cascade. Same fix in fence-panel.tsx.
const nodeRef = useRef(node)
nodeRef.current = node
const handleUpdate = useCallback(
(updates: Partial<WallNode>) => {
if (!selectedId) return
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId],
)
const handleUpdateLength = useCallback(
(newLength: number) => {
const n = nodeRef.current
if (!n || newLength <= 0) return
const dx = n.end[0] - n.start[0]
const dz = n.end[1] - n.start[1]
const currentLength = Math.sqrt(dx * dx + dz * dz)
if (currentLength === 0) return
const dirX = dx / currentLength
const dirZ = dz / currentLength
const newEnd: [number, number] = [
n.start[0] + dirX * newLength,
n.start[1] + dirZ * newLength,
]
handleUpdate({ end: newEnd })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleCurve = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setCurvingWall(node)
setSelection({ selectedIds: [] })
}, [node, setCurvingWall, setSelection])
if (!(node && node.type === 'wall' && selectedId)) return null
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const length = getWallCurveLength(node)
const height = node.height ?? 2.5
const thickness = node.thickness ?? 0.1
const curveOffset = getClampedWallCurveOffset(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
return (
<PanelWrapper
icon="/icons/wall.png"
onClose={handleClose}
title={node.name || 'Wall'}
width={280}
>
<PanelSection title="Dimensions">
<SliderControl
label="Length"
max={20}
min={0.1}
onChange={handleUpdateLength}
precision={2}
step={0.01}
unit="m"
value={length}
/>
<SliderControl
label="Height"
max={6}
min={0.1}
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
precision={2}
step={0.1}
unit="m"
value={Math.round(height * 100) / 100}
/>
<SliderControl
label="Thickness"
max={1}
min={0.05}
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
precision={3}
step={0.01}
unit="m"
value={Math.round(thickness * 1000) / 1000}
/>
{!hasWallChildrenBlockingCurve && (
<SliderControl
label="Curve"
max={Math.max(0.01, maxCurveOffset)}
min={-Math.max(0.01, maxCurveOffset)}
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
precision={2}
step={0.1}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
)}
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && (
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
)}
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,984 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
emitter,
useInteractive,
useScene,
WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover'
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 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
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
sfxEmitter.emit('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
sfxEmitter.emit('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>
)
}
@@ -130,8 +130,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1]
area -= polygon[j]?.[0] * polygon[i]?.[1]
const pi = polygon[i]
const pj = polygon[j]
if (!(pi && pj)) continue
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
return Math.abs(area) / 2
@@ -38,7 +38,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
return (
<TreeNodeWrapper
actions={<TreeNodeActions node={node} />}
actions={<TreeNodeActions nodeId={node.id} />}
depth={depth}
expanded={false}
hasChildren={false}
@@ -53,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
<InlineRenameInput
defaultName="Fence"
isEditing={isEditing}
node={node}
nodeId={node.id}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
@@ -91,8 +91,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1]
area -= polygon[j]?.[0] * polygon[i]?.[1]
const pi = polygon[i]
const pj = polygon[j]
if (!(pi && pj)) continue
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
return Math.abs(area) / 2
@@ -77,49 +77,61 @@ interface TreeNodeProps {
isLast?: boolean
}
// Per-kind tree-node components keyed by `node.type`. Lookup replaces
// the legacy switch — adding a kind to this map is now the only edit
// needed in this file (the switch's `case '<kind>':` clauses were
// flagged by the Phase 6 grep gate as the last per-kind dispatch
// outside the registry; future work moves these to a
// `def.presentation`-driven generic tree-node and removes this map
// entirely).
const treeNodeByType: Record<
string,
React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }>
> = {
building: BuildingTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
ceiling: CeilingTreeNode,
column: ColumnTreeNode,
elevator: ElevatorTreeNode,
level: LevelTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
shelf: ShelfTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
slab: SlabTreeNode,
spawn: SpawnTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
wall: WallTreeNode,
fence: FenceTreeNode,
roof: RoofTreeNode,
stair: StairTreeNode,
door: DoorTreeNode,
window: WindowTreeNode,
zone: ZoneTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
item: ItemTreeNode,
}
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
const nodeType = useScene((state) => state.nodes[nodeId]?.type)
if (!nodeType) return null
switch (nodeType) {
case 'building':
return (
<BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
)
case 'ceiling':
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'column':
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'elevator':
return <ElevatorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'level':
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':
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'spawn':
return <SpawnTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `spawn_${string}`} />
case 'wall':
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'fence':
return <FenceTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'roof':
return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'stair':
return <StairTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'item':
return <ItemTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'door':
return <DoorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'window':
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'zone':
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `zone_${string}`} />
default:
return null
}
const Component = treeNodeByType[nodeType]
if (!Component) return null
return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
})
interface TreeNodeWrapperProps {
@@ -12,7 +12,7 @@ export const ViewerZoneSystem = () => {
const structureLayer = useEditor.getState().structureLayer
const nodes = useScene.getState().nodes
sceneRegistry.byType.zone.forEach((id) => {
sceneRegistry.byType.zone!.forEach((id) => {
const obj = sceneRegistry.nodes.get(id)
if (!obj) return
+97 -1
View File
@@ -4,6 +4,15 @@ export {
type SnapshotCameraData,
ThumbnailGenerator,
} from './components/editor/thumbnail-generator'
// SVG path builders for arc / annular-sector / arrow-head shapes —
// inlined into `kind: 'path'` / `kind: 'polygon'` primitives by curved
// stair rendering in `nodes/src/stair/floorplan.ts`.
export {
buildSvgAnnularSectorPath,
buildSvgArcPath,
buildSvgArrowHeadPoints,
getArcPlanPoint,
} from './components/editor-2d/svg-paths'
// Phase 5 Stage D transitional exports — pure drafting / angle helpers
// consumed by kind-owned drag actions in @pascal-app/nodes. Stage F
// cleanup moves these into @pascal-app/nodes (fence/drafting.ts +
@@ -13,6 +22,29 @@ export {
type FencePlanPoint,
snapFenceDraftPoint,
} from './components/tools/fence/fence-drafting'
// Placement-math helpers — shared by kind-owned placement tools in
// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement,
// item drop) so kinds don't reach into editor internals.
export {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToGrid,
snapToHalf,
snapUpToGridStep,
stripTransient,
} from './components/tools/item/placement-math'
export type { PlacementState } from './components/tools/item/placement-types'
// Item placement / move primitives. Re-exported here so the registry-driven
// item move-tool in `@pascal-app/nodes` can compose them — same hooks the
// legacy `MoveItemContent` + `ItemTool` use. Once item placement is fully
// owned by `nodes`, these can be inlined there and dropped from editor.
export { type DraftNodeHandle, useDraftNode } from './components/tools/item/use-draft-node'
export {
type PlacementCoordinatorConfig,
usePlacementCoordinator,
} from './components/tools/item/use-placement-coordinator'
export { CursorSphere } from './components/tools/shared/cursor-sphere'
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
export {
@@ -24,10 +56,32 @@ export {
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from './components/tools/shared/segment-angle'
// Stair placement defaults — used by the kind-owned stair / stair-segment
// panels. Re-exported from `components/tools/stair/stair-defaults.ts`.
export {
DEFAULT_CURVED_STAIR_INNER_RADIUS,
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
DEFAULT_SPIRAL_TOP_LANDING_MODE,
DEFAULT_STAIR_ATTACHMENT_SIDE,
DEFAULT_STAIR_FILL_TO_FLOOR,
DEFAULT_STAIR_HEIGHT,
DEFAULT_STAIR_LENGTH,
DEFAULT_STAIR_RAILING_HEIGHT,
DEFAULT_STAIR_RAILING_MODE,
DEFAULT_STAIR_STEP_COUNT,
DEFAULT_STAIR_THICKNESS,
DEFAULT_STAIR_TYPE,
DEFAULT_STAIR_WIDTH,
} from './components/tools/stair/stair-defaults'
export {
createWallOnCurrentLevel,
getWallGridStep,
isWallLongEnough,
snapPointToGrid,
snapScalarToGrid,
snapWallDraftPoint,
type WallPlanPoint,
@@ -36,16 +90,23 @@ export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
export { useCommandPalette } from './components/ui/command-palette'
export { ActionButton, ActionGroup } from './components/ui/controls/action-button'
export { MaterialPicker } from './components/ui/controls/material-picker'
export { MetricControl } from './components/ui/controls/metric-control'
export { PanelSection } from './components/ui/controls/panel-section'
export { SegmentedControl } from './components/ui/controls/segmented-control'
export { SliderControl } from './components/ui/controls/slider-control'
export { ToggleControl } from './components/ui/controls/toggle-control'
export { FloatingLevelSelector } from './components/ui/floating-level-selector'
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
// Item collections UI — used by the kind-owned ItemPanel in nodes/.
export { CollectionsPopover } from './components/ui/panels/collections/collections-popover'
// Phase 5 Stage E — kinds with bespoke editors (slab holes list,
// ceiling height presets, etc.) use `parametrics.customPanel` to mount
// a kind-owned panel and need PanelWrapper for the chrome.
export { PanelWrapper } from './components/ui/panels/panel-wrapper'
// Presets popover — used by kind-owned door / window panels for their
// hardware / type / opening presets.
export { PresetsPopover } from './components/ui/panels/presets/presets-popover'
export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
export { useSidebarStore } from './components/ui/primitives/sidebar'
export { Slider } from './components/ui/primitives/slider'
@@ -60,7 +121,7 @@ export {
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
export { PresetsProvider } from './contexts/presets-context'
export { PresetsProvider, usePresetsAdapter } from './contexts/presets-context'
export type { SaveStatus } from './hooks/use-auto-save'
// useDragAction is the React-side glue for the registry's DragAction
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
@@ -69,9 +130,44 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
export { markToolCancelConsumed } from './hooks/use-keyboard'
export { EDITOR_LAYER } from './lib/constants'
// Helper libs used by the kind-owned roof / stair / elevator panels.
export {
resolveCurrentBuildingId,
resolveElevatorNodeSupportY,
resolveElevatorSupportLevelId,
resolveElevatorSupportY,
} from './lib/elevator-support'
// Floor-plan stair helpers — the cumulative-transform walk
// (`computeFloorplanStairSegmentTransforms`) and the rich segment-entry
// builder (`buildFloorplanStairEntry`) used by the kind-owned stair
// floor-plan emitter in `@pascal-app/nodes/src/stair/floorplan.ts`.
// Each flight's transform depends on every prior sibling's length /
// height / `attachmentSide`, so individual stair-segments can't compute
// their own polygon in isolation — the stair (parent) owns the
// computation and emits the whole stack as one registry entry.
export {
buildFloorplanStairEntry,
type FloorplanStairArrowEntry,
type FloorplanStairEntry,
type FloorplanStairSegmentEntry,
} from './lib/floorplan'
export {
buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
buildWallSurfaceMaterialPatch,
getActivePaintMaterialLabel,
hasActivePaintMaterial,
} from './lib/material-paint'
export { duplicateRoofSubtree } from './lib/roof-duplication'
export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus'
export { duplicateStairSubtree } from './lib/stair-duplication'
// `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/
// nodes` so they don't need their own copy / their own tailwind-merge
// dependency.
export { cn } from './lib/utils'
export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
export type {
+54 -42
View File
@@ -33,50 +33,62 @@ function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
return true
}
/**
* Material field keys per kind, used by the `structure` duplicate preset
* to strip materials from the cloned subtree. Lookup table replaces the
* legacy per-kind switch — the Phase 6 grep gate flagged `case '<kind>':`
* in this file as the remaining per-kind dispatch outside the registry.
*
* Future: move this to a `capabilities.materialFields` declaration on
* each kind's `NodeDefinition` so adding a new kind with materials is a
* registry-only edit. Today the registry doesn't surface material fields
* in a uniform way (each kind's panel reads / writes them directly), so
* this map mirrors the legacy behavior 1:1.
*/
const MATERIAL_FIELDS_BY_KIND: Record<string, ReadonlyArray<string>> = {
wall: [
'material',
'materialPreset',
'interiorMaterial',
'interiorMaterialPreset',
'exteriorMaterial',
'exteriorMaterialPreset',
],
slab: ['material', 'materialPreset'],
ceiling: ['material', 'materialPreset'],
fence: ['material', 'materialPreset'],
shelf: ['material', 'materialPreset'],
'roof-segment': ['material', 'materialPreset'],
'stair-segment': ['material', 'materialPreset'],
window: ['material', 'materialPreset'],
door: ['material', 'materialPreset'],
roof: [
'material',
'materialPreset',
'topMaterial',
'topMaterialPreset',
'edgeMaterial',
'edgeMaterialPreset',
'wallMaterial',
'wallMaterialPreset',
],
stair: [
'material',
'materialPreset',
'railingMaterial',
'railingMaterialPreset',
'treadMaterial',
'treadMaterialPreset',
'sideMaterial',
'sideMaterialPreset',
],
}
function stripMaterials(node: AnyNode): AnyNode {
const fields = MATERIAL_FIELDS_BY_KIND[node.type]
if (!fields) return node
const next = { ...node } as Record<string, unknown>
switch (node.type) {
case 'wall':
delete next.material
delete next.materialPreset
delete next.interiorMaterial
delete next.interiorMaterialPreset
delete next.exteriorMaterial
delete next.exteriorMaterialPreset
break
case 'slab':
case 'ceiling':
case 'fence':
case 'roof-segment':
case 'stair-segment':
case 'window':
case 'door':
delete next.material
delete next.materialPreset
break
case 'roof':
delete next.material
delete next.materialPreset
delete next.topMaterial
delete next.topMaterialPreset
delete next.edgeMaterial
delete next.edgeMaterialPreset
delete next.wallMaterial
delete next.wallMaterialPreset
break
case 'stair':
delete next.material
delete next.materialPreset
delete next.railingMaterial
delete next.railingMaterialPreset
delete next.treadMaterial
delete next.treadMaterialPreset
delete next.sideMaterial
delete next.sideMaterialPreset
break
}
for (const field of fields) delete next[field]
return next as AnyNode
}
+9 -3
View File
@@ -13,6 +13,7 @@ import {
type MaterialTarget,
type RoofNode,
type RoofSurfaceMaterialRole,
type ShelfNode,
type SlabNode,
type StairNode,
type StairSurfaceMaterialRole,
@@ -22,7 +23,7 @@ import {
export type PaintableMaterialTarget = Extract<
MaterialTarget,
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling'
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' | 'shelf'
>
export type SingleSurfaceMaterialRole = 'surface'
@@ -133,7 +134,7 @@ export function buildStairSurfaceMaterialPatch(
}
export function buildSingleSurfaceMaterialPatch<
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
return {
material,
@@ -222,7 +223,8 @@ export function resolveActivePaintMaterialFromSelection(params: {
(selectedNode.type === 'fence' ||
selectedNode.type === 'column' ||
selectedNode.type === 'slab' ||
selectedNode.type === 'ceiling') &&
selectedNode.type === 'ceiling' ||
selectedNode.type === 'shelf') &&
selectedMaterialTarget.role === 'surface'
) {
const target = selectedNode.type
@@ -280,5 +282,9 @@ export function resolvePaintTargetFromSelection(params: {
return 'ceiling'
}
if (selectedNode.type === 'shelf') {
return 'shelf'
}
return null
}
+6 -2
View File
@@ -283,7 +283,11 @@ export function syncEditorSelectionFromCurrentScene() {
if (shouldRestoreEditorUiState) {
if (restoredSelection) {
useViewer.getState().setSelection(restoredSelection)
// PersistedSelectionPath carries plain `string` ids (read from
// localStorage, no branded-template-literal guarantee). The viewer's
// SelectionPath expects branded ids. The runtime values match the
// brand; the cast bridges the static gap.
useViewer.getState().setSelection(restoredSelection as never)
useEditor.setState(
restoredEditorUiState.phase === 'site'
? (selectionDrivenEditorUiState ?? restoredEditorUiState)
@@ -305,7 +309,7 @@ export function syncEditorSelectionFromCurrentScene() {
}
if (restoredSelection) {
useViewer.getState().setSelection(restoredSelection)
useViewer.getState().setSelection(restoredSelection as never)
if (selectionDrivenEditorUiState) {
useEditor.setState(selectionDrivenEditorUiState)
}