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
+50
View File
@@ -0,0 +1,50 @@
import { BuildingNode as BuildingNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { buildingParametrics } from './parametrics'
import { BuildingNode } from './schema'
/**
* Building — Stage A. Container for levels; can be translated /
* rotated as a whole (movable + rotatable on Y). The legacy
* `MoveBuildingContent` handles building-wide drag; the registry
* fallback would translate position, which is close to right —
* but kept legacy at Stage A to avoid disturbing the building's
* world-space group transform handling.
*/
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
kind: 'building',
schemaVersion: 1,
schema: BuildingNode,
category: 'site',
defaults: () => {
const stub = BuildingNodeSchema.parse({ id: 'building_default' as never, type: 'building' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
// Building is a container — sidebar / building switcher drive
// selection, never 3D click. Same reasoning as `level` / `site`.
duplicable: false,
deletable: false,
},
parametrics: buildingParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
presentation: {
label: 'Building',
description: 'A building container holding one or more levels.',
icon: { kind: 'url', src: '/icons/building.png' },
paletteSection: 'site',
paletteOrder: 6,
},
mcp: {
description: 'A building container that groups levels.',
},
}
+1
View File
@@ -0,0 +1 @@
export { buildingDefinition } from './definition'
@@ -0,0 +1,5 @@
import type { BuildingNode, ParametricDescriptor } from '@pascal-app/core'
export const buildingParametrics: ParametricDescriptor<BuildingNode> = {
groups: [],
}
+28
View File
@@ -0,0 +1,28 @@
'use client'
import { type BuildingNode, useRegistry } from '@pascal-app/core'
import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer'
import { useRef } from 'react'
import type { Group } from 'three'
export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref)
const handlers = useNodeEvents(node, 'building')
return (
<group
position={node.position}
ref={ref}
rotation={[node.rotation[0], node.rotation[1], node.rotation[2]]}
{...handlers}
>
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
)
}
export default BuildingRenderer
+1
View File
@@ -0,0 +1 @@
export { BuildingNode } from '@pascal-app/core'
+18
View File
@@ -1,5 +1,11 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildCeilingFloorplan } from './floorplan'
import {
ceilingAddVertexAffordance,
ceilingMoveEdgeAffordance,
ceilingMoveVertexAffordance,
} from './floorplan-affordances'
import { ceilingFloorplanMoveTarget } from './floorplan-move'
import { ceilingParametrics } from './parametrics'
import { CeilingNode } from './schema'
@@ -76,6 +82,18 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
priority: 4,
},
floorplan: buildCeilingFloorplan,
// 2D move handler — translates polygon by cursor delta from first
// pointer position. Mirror of slab; 3D `MoveCeilingTool` skips
// 2D-sourced grid events so they don't double-write on commit.
floorplanMoveTarget: ceilingFloorplanMoveTarget,
// Sister to `affordanceTools['boundary-edit']`. Same `polygon` field;
// SVG vertex handles dispatch to this affordance via the floor-plan
// registry layer.
floorplanAffordances: {
'move-vertex': ceilingMoveVertexAffordance,
'add-vertex': ceilingAddVertexAffordance,
'move-edge': ceilingMoveEdgeAffordance,
},
toolHints: [
{ key: 'Left click', label: 'Trace ceiling outline' },
@@ -0,0 +1,16 @@
import type { CeilingNode } from '@pascal-app/core'
import {
createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance,
} from '../shared/polygon-vertex-affordance'
/**
* 2D drag affordances for ceiling. Same three operations as slab
* (`move-vertex`, `add-vertex`, `move-edge`), each accepting an
* optional `holeIndex`. See `slab/floorplan-affordances.ts` for the
* full contract.
*/
export const ceilingMoveVertexAffordance = createPolygonVertexAffordance<CeilingNode>('ceiling')
export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance<CeilingNode>('ceiling')
export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<CeilingNode>('ceiling')
@@ -0,0 +1,88 @@
import {
type AnyNodeId,
type CeilingNode,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
/**
* 2D floor-plan move handler for ceiling — mirrors the 3D `MoveCeilingTool`
* live-drag pattern. See the equivalent module in `slab/floorplan-move.ts`
* for the full rationale; the only ceiling-specific detail is the
* preserved Y offset (`CeilingSystem` positions the mesh at `height 0.01`
* on rebuild, so the direct `mesh.position.y` mirrors that to avoid a
* vertical teleport when the React group position is reconciled).
*/
const GRID_STEP = 0.5
function translatePolygon(
polygon: ReadonlyArray<readonly [number, number]>,
dx: number,
dz: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number])
}
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node }) => {
const ceilingId = node.id as AnyNodeId
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
const originalHoles = (node.holes ?? []).map((hole) =>
hole.map(([x, z]) => [x, z] as [number, number]),
)
const height = node.height ?? 2.5
let anchor: [number, number] | null = null
let lastDelta: [number, number] = [0, 0]
const session: FloorplanMoveTargetSession = {
affectedIds: [ceilingId],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
if (!anchor) {
anchor = [snapped[0], snapped[1]]
return
}
const dx = snapped[0] - anchor[0]
const dz = snapped[1] - anchor[1]
lastDelta = [dx, dz]
useLiveTransforms.getState().set(ceilingId, {
position: [dx, 0, dz],
rotation: 0,
})
const mesh = sceneRegistry.nodes.get(ceilingId) as THREE.Object3D | undefined
// Preserve ceiling height — `CeilingSystem` sets `mesh.position.y =
// height 0.01` on each rebuild; mirror that during the drag so
// the mesh stays at ceiling height (not collapsed to y=0).
if (mesh) mesh.position.set(dx, height - 0.01, dz)
},
canCommit() {
const live = useScene.getState().nodes[ceilingId] as CeilingNode | undefined
if (!live || live.type !== 'ceiling') return false
const [dx, dz] = lastDelta
if (dx === 0 && dz === 0) return false
// Sync commit sequence — see `slab/floorplan-move.ts` for the
// full ordering rationale (scene write → direct markDirty →
// useLiveTransforms.clear, all sync in this handler so React
// render + CeilingSystem rebuild land in the same paint).
useScene.getState().updateNodes([
{
id: ceilingId,
data: {
polygon: translatePolygon(originalPolygon, dx, dz),
holes: originalHoles.map((h) => translatePolygon(h, dx, dz)),
},
},
])
useScene.getState().markDirty(ceilingId)
useLiveTransforms.getState().clear(ceilingId)
return true
},
}
return session
}
+86 -14
View File
@@ -1,15 +1,29 @@
import type { CeilingNode, FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
import type {
CeilingNode,
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
} from '@pascal-app/core'
/**
* Stage C floor-plan builder for ceiling. Renders the polygon outline
* as a dashed boundary (ceilings are above and would visually obscure
* the slab/walls if drawn solid). Same shape as slab but visually
* distinct.
* Stage C floor-plan builder for ceiling. Dashed boundary (ceilings sit
* above the slab); when selected, mounts the same boundary editor as
* slab — vertex + midpoint + edge handles on the outer ring AND every
* hole, with `holeIndex` carried in the handle payloads.
*/
export function buildCeilingFloorplan(node: CeilingNode): FloorplanGeometry | null {
export function buildCeilingFloorplan(
node: CeilingNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const polygon = node.polygon
if (!polygon || polygon.length < 3) return null
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
const outer: FloorplanPoint[] = polygon.map(([x, z]) => [x, z] as FloorplanPoint)
const ring = (points: FloorplanPoint[]) => {
@@ -25,13 +39,71 @@ export function buildCeilingFloorplan(node: CeilingNode): FloorplanGeometry | nu
segments.push(ring(hole.map(([x, z]) => [x, z] as FloorplanPoint)))
}
return {
kind: 'path',
d: segments.join(' '),
fill: 'none',
stroke: '#94a3b8',
strokeWidth: 0.03,
strokeDasharray: '0.15 0.1',
opacity: 0.7,
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#94a3b8'
const children: FloorplanGeometry[] = [
{
kind: 'path',
d: segments.join(' '),
fill: 'none',
stroke,
strokeWidth: showSelectedChrome ? 0.04 : 0.03,
strokeDasharray: '0.15 0.1',
opacity: showSelectedChrome ? 0.95 : 0.7,
},
]
if (isSelected) {
appendRingEditor(children, polygon, undefined)
holes.forEach((hole, holeIndex) => {
if (hole.length >= 3) appendRingEditor(children, hole, holeIndex)
})
}
return { kind: 'group', children }
}
/**
* Same boundary editor as slab — see `nodes/src/slab/floorplan.ts` for
* the contract. The kinds differ only in their fill / stroke chrome;
* the editor primitives are identical.
*/
function appendRingEditor(
children: FloorplanGeometry[],
ring: ReadonlyArray<readonly [number, number]>,
holeIndex: number | undefined,
): void {
for (let i = 0; i < ring.length; i++) {
const a = ring[i]!
const b = ring[(i + 1) % ring.length]!
children.push({
kind: 'edge-handle',
x1: a[0],
y1: a[1],
x2: b[0],
y2: b[1],
affordance: 'move-edge',
payload: { holeIndex, edgeIndex: i },
})
}
for (let i = 0; i < ring.length; i++) {
const a = ring[i]!
const b = ring[(i + 1) % ring.length]!
children.push({
kind: 'midpoint-handle',
point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
affordance: 'add-vertex',
payload: { holeIndex, edgeIndex: i },
})
}
for (let i = 0; i < ring.length; i++) {
const [x, z] = ring[i]!
children.push({
kind: 'endpoint-handle',
point: [x, z],
state: 'idle',
affordance: 'move-vertex',
payload: { holeIndex, vertexIndex: i },
})
}
}
+137 -1
View File
@@ -11,8 +11,9 @@ import {
} from '@pascal-app/core'
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type * as THREE from 'three'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
/**
* Phase 5 Stage D — ceiling whole-move tool.
@@ -57,6 +58,24 @@ function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number, height: nu
if (mesh) mesh.position.set(deltaX, height - 0.01, deltaZ)
}
/**
* Distinguish 3D-canvas grid events (this tool) from 2D floor-plan
* grid events (`ceilingFloorplanMoveTarget` + `FloorplanRegistryMoveOverlay`
* Path 1). See the equivalent helper in `slab/move-tool.tsx` for the
* full rationale.
*/
function isFloorplanSourcedEvent(event: GridEvent): boolean {
const native: unknown = event.nativeEvent
const candidate =
(native as { target?: unknown; nativeEvent?: { target?: unknown } } | null) ?? null
const target =
(candidate?.target as Element | null | undefined) ??
(candidate?.nativeEvent as { target?: Element | null } | undefined)?.target ??
null
if (!target || typeof (target as Element).closest !== 'function') return false
return (target as Element).closest('[data-floorplan-scene]') != null
}
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]))
@@ -112,6 +131,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
}
const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
@@ -130,6 +150,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
}
const onGridClick = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
@@ -176,11 +197,126 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
}
}, [exitMoveMode, node.id])
return (
<CeilingMovePreview
ceilingId={node.id}
cursorLocalPos={cursorLocalPos}
height={node.height ?? 2.5}
originalHoles={originalHolesRef.current}
originalPolygon={originalPolygonRef.current}
/>
)
}
/**
* Translucent fill + bright outline showing where the ceiling will land
* during the drag. Mirrors the legacy `MoveCeilingTool` overlay so the
* 3D viewer has a visible cue from above (the ceiling's child grid mesh
* is hidden by default — without this preview the only mesh that shows
* from above is the (translated) translucent ceiling itself, which is
* easy to miss). Works for both the 3D `grid:move` path (this tool
* writes `useLiveTransforms.position = [Δx, 0, Δz]` directly) and the
* 2D floor-plan move path (`slab/ceiling/floorplan-move.ts` writes the
* same value); we subscribe to that store so the preview tracks the
* current delta regardless of which mover is driving it.
*/
function CeilingMovePreview({
ceilingId,
cursorLocalPos,
height,
originalHoles,
originalPolygon,
}: {
ceilingId: AnyNodeId
cursorLocalPos: [number, number, number]
height: number
originalHoles: Array<Array<[number, number]>>
originalPolygon: Array<[number, number]>
}) {
const live = useLiveTransforms((s) => s.get(ceilingId))
const dx = live?.position[0] ?? 0
const dz = live?.position[2] ?? 0
const previewPolygon = useMemo(
() => originalPolygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
[originalPolygon, dx, dz],
)
const previewHoles = useMemo(
() => originalHoles.map((hole) => hole.map(([x, z]) => [x + dx, z + dz] as [number, number])),
[originalHoles, dx, dz],
)
const previewFillGeometry = useMemo(
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
[previewPolygon, previewHoles],
)
const previewOutlineGeometry = useMemo(
() => createCeilingOutlineGeometry(previewPolygon),
[previewPolygon],
)
return (
<group>
<mesh geometry={previewFillGeometry} position={[0, height + 0.012, 0]}>
<meshBasicMaterial
color="#f5f5f4"
depthWrite={false}
opacity={0.3}
side={DoubleSide}
transparent
/>
</mesh>
{/* @ts-ignore - `<line>` is a valid R3F intrinsic but conflicts with SVG line typing */}
<line geometry={previewOutlineGeometry} position={[0, height + 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 first = polygon[0]!
shape.moveTo(first[0], -first[1])
for (let i = 1; i < polygon.length; i++) {
const pt = polygon[i]!
shape.lineTo(pt[0], -pt[1])
}
shape.closePath()
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
const hole = new Path()
const hf = holePolygon[0]!
hole.moveTo(hf[0], -hf[1])
for (let i = 1; i < holePolygon.length; i++) {
const pt = holePolygon[i]!
hole.lineTo(pt[0], -pt[1])
}
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 first = polygon[0]!
points.push(new Vector3(first[0], 0, first[1]))
geometry.setFromPoints(points)
return geometry
}
export default MoveCeilingTool
+102 -11
View File
@@ -1,15 +1,106 @@
'use client'
import { CeilingRenderer } from '@pascal-app/viewer'
import {
type CeilingNode,
getMaterialPresetByRef,
resolveMaterial,
useRegistry,
} from '@pascal-app/core'
import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute } from 'three'
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
function createEmptyGeometry() {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
return geometry
}
const gridScale = 5
const gridX = positionWorld.x.mul(gridScale).fract()
const gridY = positionWorld.z.mul(gridScale).fract()
const lineWidth = 0.05
const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX))
const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY))
const gridPattern = lineX.max(lineY)
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
function createCeilingMaterials(color = '#999999') {
const topMaterial = new MeshBasicNodeMaterial({
color,
transparent: true,
depthWrite: false,
side: FrontSide,
})
topMaterial.opacityNode = gridOpacity
const bottomMaterial = new MeshBasicNodeMaterial({
color,
transparent: true,
side: BackSide,
})
return { topMaterial, bottomMaterial }
}
const ceilingMaterialCache = new Map<string, ReturnType<typeof createCeilingMaterials>>()
function getCeilingMaterials(color = '#999999') {
const cacheKey = color
const cached = ceilingMaterialCache.get(cacheKey)
if (cached) return cached
const materials = createCeilingMaterials(color)
ceilingMaterialCache.set(cacheKey, materials)
return materials
}
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const ref = useRef<Mesh>(null!)
const placeholderGeometry = useMemo(createEmptyGeometry, [])
const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
useRegistry(node.id, 'ceiling', ref)
const handlers = useNodeEvents(node, 'ceiling')
useEffect(
() => () => {
placeholderGeometry.dispose()
gridPlaceholderGeometry.dispose()
},
[gridPlaceholderGeometry, placeholderGeometry],
)
const materials = useMemo(() => {
const preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material)
const color = props.color || '#999999'
return getCeilingMaterials(color)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
return (
<mesh geometry={placeholderGeometry} material={materials.bottomMaterial} ref={ref}>
<mesh
geometry={gridPlaceholderGeometry}
material={materials.topMaterial}
name="ceiling-grid"
{...handlers}
scale={0}
visible={false}
/>
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</mesh>
)
}
/**
* Wrap-export of the legacy `CeilingRenderer`.
*
* Ceiling's renderer uses TSL shader code for the grid-line pattern
* (~100 lines incl. material setup) — too much to duplicate at Stage A.
* The legacy file stays in viewer; the registry imports it through the
* public export. Phase 5 Stage B/F (per-kind migration stages, see
* plans/editor-node-registry.md) moves the renderer body into this
* folder and deletes the legacy file.
*/
export default CeilingRenderer
+3 -6
View File
@@ -3,13 +3,10 @@
import { CeilingSystem } from '@pascal-app/viewer'
/**
* Registry-driven ceiling system bundle. Re-exports the legacy
* `CeilingSystem` so it mounts via `RegisteredSystems` when ceiling is
* registry-driven. `<LegacySystem kind="ceiling">` in viewer/components/
* viewer/index.tsx short-circuits whenever `nodeRegistry.has('ceiling')`
* is true — same shape wall / fence / slab use.
* Registry-driven ceiling system bundle. Wraps `CeilingSystem` so it
* mounts via `RegisteredSystems`.
*
* Future Phase 5+: extract polygon triangulation + hole CSG into a pure
* Future: extract polygon triangulation + hole CSG into a pure
* `buildCeilingGeometry(node)` and migrate to `def.geometry`.
*/
const CeilingSystems = () => {
+64
View File
@@ -0,0 +1,64 @@
import { ColumnNode as ColumnNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { buildColumnFloorplan } from './floorplan'
import { columnParametrics } from './parametrics'
import { ColumnNode } from './schema'
/**
* Column — Stage A registration. Wrap-export of the legacy
* `ColumnRenderer` (no system — column geometry is computed inline in
* the renderer). Inspector / move / floorplan still go through legacy
* paths via panel-manager.tsx / item-move-tool.tsx / floorplan-panel.tsx
* (their hardcoded `case 'column':` entries fire before the registry
* fallback).
*
* Capabilities: column doesn't declare `movable` because its move is
* bespoke (legacy MoveColumnTool snaps to slab + free placement on
* the X/Z plane with rotation).
*
* Defaults computed via stub-parse so we leverage every zod
* `.default()` annotation on the schema (~60 fields).
*/
export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'column',
schemaVersion: 1,
schema: ColumnNode,
category: 'structure',
defaults: () => {
const stub = ColumnNodeSchema.parse({ id: 'column_default' as never, type: 'column' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: columnParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
// Stage D — 3D move-tool (registry-driven). Replaces the legacy
// `MoveColumnTool` in editor's dispatcher. Same 0.5m grid snap +
// live-transform preview the legacy used.
affordanceTools: {
move: () => import('./move-tool'),
},
floorplan: buildColumnFloorplan,
presentation: {
label: 'Column',
description: 'A parametric column with configurable cross-section, base, and capital.',
icon: { kind: 'url', src: '/icons/column.png' },
paletteSection: 'structure',
paletteOrder: 70,
},
mcp: {
description: 'A parametric column placed on a slab or level.',
},
}
+185
View File
@@ -0,0 +1,185 @@
import type {
ColumnNode,
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
} from '@pascal-app/core'
/**
* Stage C floor-plan builder for column. Inlined from the legacy
* `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The
* footprint shape depends on `crossSection` (square / rectangular /
* round / octagonal / sixteen-sided) and `supportStyle` (vertical /
* a-frame / x-brace / etc.) — brace supports use a rotated rectangle
* spanning the base spread; standalone columns use the shaft profile.
*
* When selected, switches to a themed accent stroke and emits a move
* handle at the column center. No dimension overlay (columns don't
* have a natural "length" axis like a wall).
*/
export function buildColumnFloorplan(
node: ColumnNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const polygon = getColumnPlanFootprint(node)
if (polygon.length < 3) return null
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151'
const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af'
const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint)
const children: FloorplanGeometry[] = [
{
kind: 'polygon',
points,
fill,
stroke,
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
opacity: 0.92,
},
]
// Hatch overlay on selected — same `<defs>` pattern as the wall.
if (isSelected && palette) {
children.push({
kind: 'hatch',
points,
color: palette.selectedHatch,
opacity: 0.7,
})
}
// Move handle at the column center when selected.
if (isSelected) {
children.push({
kind: 'move-handle',
point: [node.position[0], node.position[2]],
})
}
return { kind: 'group', children }
}
// ── Inlined helpers from legacy floorplan-panel.tsx ───────────────────
type PlanPoint = { x: number; y: number }
function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
const c = Math.cos(rotation)
const s = Math.sin(rotation)
return [x * c - y * s, x * s + y * c]
}
function getRotatedRectanglePolygon(
center: PlanPoint,
width: number,
depth: number,
rotation: number,
): PlanPoint[] {
const halfW = width / 2
const halfD = depth / 2
const corners: Array<[number, number]> = [
[-halfW, -halfD],
[halfW, -halfD],
[halfW, halfD],
[-halfW, halfD],
]
return corners.map(([x, y]) => {
const [rx, ry] = rotatePlanVector(x, y, rotation)
return { x: center.x + rx, y: center.y + ry }
})
}
function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
const center: PlanPoint = { x: column.position[0], y: column.position[2] }
// Brace-support columns: rotated rectangle spanning the base spread.
if (
column.supportStyle === 'a-frame' ||
column.supportStyle === 'y-frame' ||
column.supportStyle === 'v-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
) {
const width = Math.max(
column.supportStyle === 'a-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
? (column.braceBottomSpread ?? 1.2)
: 0,
column.braceTopSpread ??
(column.supportStyle === 'y-frame' ||
column.supportStyle === 'v-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
? 1
: 0),
(column.braceWidth ?? column.width) * 2,
)
const depth = Math.max(
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'box-frame'
? (column.braceTopSpread ?? 1)
: 0,
column.braceDepth ?? column.depth,
0.08,
)
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
}
// Standalone column: shaft profile expanded for base + capital.
const isRound =
column.crossSection === 'round' ||
column.crossSection === 'octagonal' ||
column.crossSection === 'sixteen-sided'
const shaftWidth = isRound ? column.radius * 2 : column.width
const shaftDepth = isRound ? column.radius * 2 : column.depth
const width = Math.max(
shaftWidth,
column.width * column.baseWidthScale,
column.width * column.capitalWidthScale,
)
const depth = Math.max(
shaftDepth,
column.depth * column.baseDepthScale,
column.depth * column.capitalDepthScale,
)
if (column.crossSection === 'square' || column.crossSection === 'rectangular') {
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
}
const segmentCount =
column.crossSection === 'octagonal' ? 8 : column.crossSection === 'sixteen-sided' ? 16 : 32
return Array.from({ length: segmentCount }, (_, index) => {
const angle = (index / segmentCount) * Math.PI * 2
const localX = Math.cos(angle) * (width / 2)
const localY = Math.sin(angle) * (depth / 2)
const [offsetX, offsetY] = rotatePlanVector(localX, localY, column.rotation)
return { x: center.x + offsetX, y: center.y + offsetY }
})
}
+1
View File
@@ -0,0 +1 @@
export { columnDefinition } from './definition'
+119
View File
@@ -0,0 +1,119 @@
'use client'
import {
type AnyNodeId,
type ColumnNode,
ColumnNode as ColumnNodeSchema,
emitter,
type GridEvent,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
/**
* Phase 5 Stage D — column's registry-driven 3D move affordance.
*
* Replaces the legacy `MoveColumnTool` in `editor/src/components/tools/
* column/move-column-tool.tsx`. Behaviour is identical: grid:move
* snaps the cursor to a 0.5m grid and previews the column at that
* position via `useLiveTransforms` + a direct `sceneRegistry.nodes.get
* (id).position.set(...)` (the live-drag exception documented in
* `wiki/architecture/tools.md`); grid:click commits via `useScene.
* updateNode`. Cancel restores the pre-drag position.
*
* Wired via `def.affordanceTools.move`. The editor's `MoveTool`
* dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup
* picks this up before its legacy chain reaches `<MoveColumnTool>`.
*/
const roundToHalf = (value: number) => Math.round(value * 2) / 2
function MoveColumnTool({ node }: { node: ColumnNode }) {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
let committed = false
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?: ColumnNode['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 = ColumnNodeSchema.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)
triggerSFX('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} />
}
export default MoveColumnTool
+930
View File
@@ -0,0 +1,930 @@
'use client'
import {
type AnyNode,
COLUMN_PRESETS,
type ColumnNode,
type ColumnPresetId,
useScene,
} from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
cn,
PanelSection,
PanelWrapper,
SliderControl,
ToggleControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
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 default 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
triggerSFX('sfx:structure-delete')
deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] })
}, [deleteNode, selectedId, setSelection])
const handleMove = useCallback(() => {
if (!node) return
triggerSFX('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>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { ColumnNode } from './schema'
/**
* Stage A inspector — minimal. Column has 60+ schema fields (cross-
* section, shaft profile, capital style, base style, carvings, ring
* placement, etc.); the legacy `<ColumnPanel>` renders these via
* panel-manager's hardcoded switch. The descriptor below registers
* the kind as "has parametric data" without trying to express the
* full legacy panel — Stage E will replace it via `customPanel`.
*/
export const columnParametrics: ParametricDescriptor<ColumnNode> = {
groups: [
{
label: 'Dimensions',
fields: [
{ key: 'height', kind: 'number', unit: 'm', min: 0.5, max: 6, step: 0.05 },
{ key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 },
{ key: 'depth', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 },
],
},
],
customPanel: () => import('./panel'),
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export { ColumnNode } from '@pascal-app/core'
+19 -1
View File
@@ -1,5 +1,6 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildDoorFloorplan } from './floorplan'
import { doorFloorplanMoveTarget } from './floorplan-move'
import { doorParametrics } from './parametrics'
import { DoorNode } from './schema'
@@ -58,6 +59,23 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
// Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
// direction + perpendicular for the cutout footprint.
floorplan: buildDoorFloorplan,
// Stage D — placement (`def.tool`) + move-on-wall (`def.
// affordanceTools.move`). Both ports of the legacy tools at
// `editor/components/tools/door/`, relocated into the kind folder and
// wired through ToolManager's registry-first dispatch (`def.tool` for
// build-mode placement, `getRegistryAffordanceTool` for the move-on-
// pick flow). Same legacy semantics: wall-event-driven snap, clamped
// wall-local coords, hasWallChildOverlap guard, live mesh updates.
tool: () => import('./tool'),
affordanceTools: {
move: () => import('./move-tool'),
},
// 2D move-on-floorplan handler. When `useEditor.movingNode` is a
// door and the floor plan is active, `FloorplanRegistryMoveOverlay`
// dispatches to this instead of the generic translate path — pointer
// snaps to the nearest wall, projects onto the wall axis, snaps
// local-X to 0.5m, clamps inside wall bounds.
floorplanMoveTarget: doorFloorplanMoveTarget,
toolHints: [
{ key: 'Left click', label: 'Place door on wall' },
@@ -67,7 +85,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
presentation: {
label: 'Door',
description: 'A door cut into a wall. Animated open/close state.',
icon: { kind: 'iconify', name: 'lucide:door-open' },
icon: { kind: 'url', src: '/icons/door.png' },
paletteSection: 'structure',
paletteOrder: 50,
},
+110
View File
@@ -0,0 +1,110 @@
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
}
+87
View File
@@ -0,0 +1,87 @@
import {
type AnyNodeId,
type DoorNode,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
useScene,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { findClosestWallInPlan } from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './door-math'
/**
* 2D floor-plan move handler for door — kicks in when the user clicks
* "Move" on the door inspector (or action menu) and the floor-plan
* view is active. Pointer in plan space → snap to nearest wall →
* project onto wall axis → snap local-X to 0.5m grid → clamp inside
* wall bounds → commit via `useScene.updateNodes`.
*
* Mirrors the 3D `move-tool.tsx` behaviour minus the R3F event plumbing:
* - Re-parents on transition between walls (parentId + wallId).
* - Adapts `side` + `rotation` from the wall normal under the pointer.
* - hasWallChildOverlap blocks committing overlapping placements.
*
* Curved walls are skipped by `findClosestWallInPlan` — same guardrail
* as the 3D port and the legacy `DoorTool` / `MoveDoorTool`.
*/
export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node }) => {
// Snapshot of the door's "valid" state at move-start — used by
// canCommit to decide whether the current snapped position is OK.
const startLevelId = (() => {
// Walk up via parentId until we hit a node whose type isn't 'wall'
// — that's the level (or null). The door is wall-hosted, so the
// wall's parent is the level. Cached at start because the parent
// chain doesn't change during a move.
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null
})()
const session: FloorplanMoveTargetSession = {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
if (!hit) return // pointer off any wall — keep door at last valid position
// Snap the wall-local X to 0.5m grid (Shift bypasses).
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
// Build the updates atomically — position + rotation + side +
// parentId + wallId in a single scene write. The current door's
// parent might be a different wall; re-anchoring requires moving
// the node in the parent's children list (the registry's
// updateNode does this when parentId changes).
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
data: {
position: [clampedX, clampedY, 0],
rotation: [0, hit.itemRotation, 0],
side: hit.side,
parentId: hit.wall.id,
wallId: hit.wall.id,
},
},
])
},
canCommit() {
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
if (!live || live.type !== 'door') return false
// Block commit if the door overlaps any other wall child at its
// current position. The 3D port has the same guard.
const overlapping = hasWallChildOverlap(
live.parentId as string,
live.position[0],
live.position[1],
live.width,
live.height,
live.id,
)
return !overlapping
},
}
return session
}
+165 -13
View File
@@ -5,18 +5,31 @@ import type {
GeometryContext,
WallNode,
} from '@pascal-app/core'
import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions'
/**
* Stage C floor-plan builder for door. Doors render as a small polygon
* sitting in the wall's cutout — width = door.width along the wall
* direction, depth = wall.thickness perpendicular.
* Stage C floor-plan builder for door. 1:1 visual port of the legacy
* floorplan-panel door rendering:
*
* 1. The door footprint rectangle in the wall cutout (themed
* accent stroke when selected).
* 2. The door swing arc — a quarter-circle from the hinge to the
* door's open position, modulated by `swingAngle`, `hingesSide`,
* and `swingDirection`. Renders as a wedge of low-opacity fill so
* the swept area reads at a glance.
* 3. The door leaf — a thick line from the hinge to the open
* position, terminating at the arc end.
* 4. Center line through the cutout (matches the legacy's
* `getOpeningCenterLine` segment for visual continuity).
*
* Requires `ctx.parent` to be a wall (door.parentId is the wall it's
* mounted on). Returns null when the parent isn't a wall (orphaned
* doors during placement etc.).
*
* Inlined from the legacy `getOpeningFootprint` helper in
* floorplan-panel.tsx. Window's builder is structurally identical.
* Skipped vs the full legacy for now: hinge / strike cubes (small
* indicator squares at the rotation pivots), rounded-opening shape
* variants, panic bar markers. Those are rare visual variations the
* follow-up port can revisit.
*/
export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null {
const wall = ctx.parent as WallNode | null
@@ -31,10 +44,11 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
const dirX = dx / length
const dirZ = dz / length
// Perpendicular unit normal (rotate 90° CCW).
const perpX = -dirZ
const perpZ = dirX
const distance = node.position[0] // door's local X = distance along wall
const distance = node.position[0]
const width = node.width
const depth = wall.thickness ?? 0.1
const cx = x1 + dirX * distance
@@ -42,6 +56,18 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
const halfWidth = width / 2
const halfDepth = depth / 2
const isPlanFlipped = isOpeningPlanFlipped(node.rotation)
const baseHingesSide = node.hingesSide ?? 'left'
const baseSwingDirection = node.swingDirection ?? 'inward'
const hingesSide = isPlanFlipped ? (baseHingesSide === 'left' ? 'right' : 'left') : baseHingesSide
const swingDirection = isPlanFlipped
? baseSwingDirection === 'inward'
? 'outward'
: 'inward'
: baseSwingDirection
const swingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0))
// Footprint rectangle in the cutout.
const points: readonly FloorplanPoint[] = [
[cx - dirX * halfWidth + perpX * halfDepth, cz - dirZ * halfWidth + perpZ * halfDepth],
[cx + dirX * halfWidth + perpX * halfDepth, cz + dirZ * halfWidth + perpZ * halfDepth],
@@ -49,12 +75,138 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
[cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth],
]
return {
kind: 'polygon',
points,
fill: '#f8fafc',
stroke: '#374151',
strokeWidth: 0.015,
opacity: 0.95,
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
// Match the legacy floor-plan door render: unselected is a quiet
// grey accent so the door reads as a hole in the wall, selected is
// a full orange treatment (body + outline) so the user can see at
// a glance which door is targeted by the inspector / move handle.
const accentColor = showSelectedChrome ? '#f97316' : 'rgba(100, 116, 139, 0.82)'
const accentMuted = accentColor
const fillColor = showSelectedChrome ? '#fed7aa' : '#ffffff'
const children: FloorplanGeometry[] = [
// Background — the cutout is filled white so the swing arc sits on
// a clean canvas (the wall hatch shows through otherwise).
{
kind: 'polygon',
points,
fill: fillColor,
stroke: accentMuted,
strokeWidth: showSelectedChrome ? 2 : 1.25,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
},
]
// Swing geometry. The hinge sits at one end of the door along the
// wall direction; the strike sits at the opposite end. The leaf
// rotates around the hinge by `swingAngle` toward the inward /
// outward side of the wall.
const hingeTangentSign = hingesSide === 'left' ? 1 : -1
const swingSign = swingDirection === 'inward' ? 1 : -1
const hingeX = cx - dirX * halfWidth * hingeTangentSign
const hingeZ = cz - dirZ * halfWidth * hingeTangentSign
// Closed leaf vector points from hinge to strike (along the wall).
const closedLeafX = dirX * width * hingeTangentSign
const closedLeafZ = dirZ * width * hingeTangentSign
if (swingAngle > 1e-3 && width > 1e-3) {
// Rotate the closed leaf vector by `swingAngle * swingSign *
// hingeTangentSign` around the hinge to get the open leaf tip.
const angle = swingAngle * swingSign * hingeTangentSign
const cos = Math.cos(angle)
const sin = Math.sin(angle)
const openLeafX = closedLeafX * cos - closedLeafZ * sin
const openLeafZ = closedLeafX * sin + closedLeafZ * cos
const tipX = hingeX + openLeafX
const tipZ = hingeZ + openLeafZ
// Closed leaf tip — where the leaf would land if fully closed.
const closedTipX = hingeX + closedLeafX
const closedTipZ = hingeZ + closedLeafZ
// Swing arc — a path from closed tip to open tip via an arc
// centered at the hinge. SVG's A command takes rx ry rotation
// large-arc-flag sweep-flag x y. Sweep flag flips based on the
// signed angle direction.
const sweepFlag = angle >= 0 ? 1 : 0
const arcPath = `M ${closedTipX} ${closedTipZ} A ${width} ${width} 0 0 ${sweepFlag} ${tipX} ${tipZ}`
// Swept wedge fill (light, low opacity) — gives the door a
// visible "this is the open zone" treatment.
children.push({
kind: 'path',
d: `M ${hingeX} ${hingeZ} L ${closedTipX} ${closedTipZ} ${arcPath
.replace(/^M [^A]+/, '')
.trim()} Z`,
fill: accentColor,
fillOpacity: showSelectedChrome ? 0.08 : 0.05,
stroke: 'none',
})
// The arc itself, stroked.
children.push({
kind: 'path',
d: arcPath,
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.6 : 1.1,
strokeOpacity: 0.85,
vectorEffect: 'non-scaling-stroke',
strokeLinecap: 'round',
})
// The door leaf — line from hinge to the open tip.
children.push({
kind: 'line',
x1: hingeX,
y1: hingeZ,
x2: tipX,
y2: tipZ,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2.4 : 1.7,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
}
// Move handle — orange dot at the door center. Only visible when
// selected. Pointer-down on this triggers `setMovingNode(door)`
// → `FloorplanRegistryMoveOverlay` → `def.floorplanMoveTarget`.
if (isSelected) {
children.push({
kind: 'move-handle',
point: [cx, cz],
})
}
// Placement-measurement dimensions — distances to adjacent openings
// (or wall ends) on each side. Only visible while actively moving
// (the user clicked Move or grabbed the orange dot).
if (view?.moving) {
for (const dim of buildOpeningPlacementDimensions(node, ctx)) {
children.push(dim)
}
}
return { kind: 'group', children }
}
/**
* The opening's wall-normal orientation is encoded in the door's Y
* rotation. When the door faces "inward" along an angle in [π/2, 3π/2],
* the rendering needs the hinge side + swing direction flipped to
* keep the visual swing on the correct side of the wall.
*
* Mirrors `isOpeningPlanFlipped` in `floorplan-panel.tsx`.
*/
function isOpeningPlanFlipped(rotation: readonly [number, number, number]): boolean {
const normalized =
((((rotation[1] % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) + 1e-6) % (Math.PI * 2)
return normalized > Math.PI / 2 && normalized < (Math.PI * 3) / 2
}
+414
View File
@@ -0,0 +1,414 @@
import {
type AnyNodeId,
DoorNode,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
useScene,
type WallEvent,
} from '@pascal-app/core'
import {
calculateCursorRotation,
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
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()
triggerSFX('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>
)
}
export default MoveDoorTool
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -2,15 +2,12 @@ import type { ParametricDescriptor } from '@pascal-app/core'
import type { DoorNode } from './schema'
/**
* Minimal inspector descriptor for door. The legacy `<DoorPanel>` has
* 29 SliderControls covering segments, hardware, hinges, panic bar,
* opening shape, etc. — too elaborate for the auto-inspector at Stage A.
* Legacy panel keeps rendering via the hardcoded `case 'door':` in
* panel-manager.tsx. This descriptor only exposes the simple dimension
* fields so the registry knows door has parametric data. Phase 5 Stage E
* (drop legacy panel) will extend this — likely via
* `parametrics.customPanel?` since door has too much non-numeric UI
* (segmented controls, presets) to fit the generic auto-UI.
* Stage E inspector for door. Mounts the kind-owned panel
* (`panel.tsx`) via `customPanel` — door has 29+ controls (segments,
* hardware, hinges, panic bar, opening shape, etc.) that can't fit
* into the generic auto-inspector. The `groups` entries stay populated
* so the registry still considers door "parametric" (for tooling that
* lists kinds with editable schema).
*/
export const doorParametrics: ParametricDescriptor<DoorNode> = {
groups: [
@@ -29,4 +26,5 @@ export const doorParametrics: ParametricDescriptor<DoorNode> = {
],
},
],
customPanel: () => import('./panel'),
}
+32 -7
View File
@@ -1,11 +1,36 @@
'use client'
import { DoorRenderer } from '@pascal-app/viewer'
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useNodeEvents } from '@pascal-app/viewer'
import { useLayoutEffect, useRef } from 'react'
import { type Mesh, MeshBasicMaterial } from 'three'
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'door', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
return (
<mesh
castShadow
material={doorHitboxMaterial}
position={node.position}
receiveShadow
ref={ref}
rotation={node.rotation}
visible={node.visible}
{...(isTransient ? {} : handlers)}
>
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
}
/**
* Wrap-export of the legacy `DoorRenderer`. The renderer is 33 lines
* (thin placeholder + register + dirty-on-mount) — could be duplicated
* but at Stage A re-export is sufficient. Phase 5 Stage F will inline
* it here and delete the viewer-side file.
*/
export default DoorRenderer
+4 -9
View File
@@ -3,7 +3,7 @@
import { DoorAnimationSystem, DoorSystem } from '@pascal-app/viewer'
/**
* Registry-driven door system bundle. Door has TWO per-frame systems:
* Registry-driven door system bundle.
*
* - **`DoorSystem`** — rebuilds frame / leaf / glass / hardware
* geometry from `dirtyNodes`. Cascades dirty to the parent wall so
@@ -13,14 +13,9 @@ import { DoorAnimationSystem, DoorSystem } from '@pascal-app/viewer'
* folding) at frame priority 2, then marks the door dirty so the
* geometry system rebuilds at priority 3.
*
* Both are wrapped in `<LegacySystem kind="door">` at the legacy mount
* point; with door registered, those wrappers short-circuit and this
* bundle takes over.
*
* Future Phase 5 Stage B: extract the geometry into a pure
* `buildDoorGeometry(node, ctx)` and migrate to `def.geometry`. The
* animation system stays as `def.system` (it's a real per-frame
* concern, not a geometry build).
* Future: extract the geometry into a pure `buildDoorGeometry(node, ctx)`
* and migrate to `def.geometry`. The animation system stays as
* `def.system` (it's a real per-frame concern, not a geometry build).
*/
const DoorSystems = () => {
return (
+326
View File
@@ -0,0 +1,326 @@
import {
type AnyNodeId,
DoorNode,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
} from '@pascal-app/core'
import {
calculateCursorRotation,
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
triggerSFX,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './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).
*/
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()
triggerSFX('sfx:item-place')
event.stopPropagation()
}
const onWallLeave = () => {
destroyDraft()
hideCursor()
}
const onCancel = () => {
destroyDraft()
hideCursor()
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('tool:cancel', onCancel)
return () => {
destroyDraft()
hideCursor()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
}, [])
// Cursor geometry: 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>
)
}
export default DoorTool
+54
View File
@@ -0,0 +1,54 @@
import { ElevatorNode as ElevatorNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { buildElevatorFloorplan } from './floorplan'
import { elevatorParametrics } from './parametrics'
import { ElevatorNode } from './schema'
/**
* Elevator — Stage A registration. Wrap-exports the legacy renderer +
* the three legacy systems (runtime / interaction / opening) bundled
* as one `def.system`. Move / inspector still go through legacy
* (`MoveElevatorTool`, `<ElevatorPanel>`) via panel-manager's
* hardcoded switch.
*/
export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
kind: 'elevator',
schemaVersion: 1,
schema: ElevatorNode,
category: 'structure',
defaults: () => {
const stub = ElevatorNodeSchema.parse({ id: 'elevator_default' as never, type: 'elevator' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: elevatorParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 3,
},
floorplan: buildElevatorFloorplan,
presentation: {
label: 'Elevator',
description: 'A multi-level elevator shaft with configurable openings per level.',
icon: { kind: 'url', src: '/icons/wallcut.png' },
paletteSection: 'structure',
paletteOrder: 80,
},
mcp: {
description: 'A multi-level elevator with shaft + openings per level.',
},
}
+313
View File
@@ -0,0 +1,313 @@
import {
type AnyNodeId,
type ElevatorNode,
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
resolveElevatorServiceLevelIds,
useInteractive,
useLiveNodeOverrides,
} from '@pascal-app/core'
/**
* Stage C floor-plan emitter for elevator. Renders:
*
* - **Outer shaft footprint** — rotated rectangle (cab + wall thickness).
* - **Cab indicator** — inner rectangle showing the cab's position within
* the shaft. Highlighted when `runtime.currentLevelId` matches the
* active level (i.e. the car is *on this floor*).
* - **Door opening indicator** — a short marker on the front face
* spanning `doorWidth` so users can see which way the doors open.
* - **Selection / target / queued chrome** — selection stroke when
* the elevator is selected, accent stroke when the runtime targets
* this level (cab is travelling here) or this level is queued.
*
* Reads the elevator's live state via `useLiveNodeOverrides.getState()`
* (inspector edits) and `useInteractive.getState().elevators[id]`
* (runtime cab travel). Those reads are non-reactive on their own —
* `FloorplanRegistryLayer` subscribes to both stores so the layer
* re-renders when they change, propagating into this builder.
*
* Per-level served-level chips (the small floor-label badges on each
* shaft side) are not emitted yet — they need an HTML-overlay primitive
* in `FloorplanGeometry` to render properly (SVG `<text>` rotates with
* the plan, which mangles label legibility). Tracked as follow-up; the
* legacy `<FloorplanElevatorLayer>` still renders the chips for
* pre-registry builds while we figure out the right primitive shape.
*/
const STAGE_LEVEL_FILTER_HIDE = true
export function buildElevatorFloorplan(
node: ElevatorNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
// Merge in any live overrides (inspector edits not yet committed).
const overrides = useLiveNodeOverrides.getState().get(node.id)
const display: ElevatorNode = overrides ? ({ ...node, ...overrides } as ElevatorNode) : node
// Service-level gate. If the active level isn't one the elevator
// serves, render nothing — legacy behaviour. The level id comes via
// `ctx.parent` (the elevator's parent in the tree is the level it's
// hosted on, which is the active level when the registry layer walks
// from `levelId`).
const parentLevelId = ctx.parent?.id
if (STAGE_LEVEL_FILTER_HIDE && parentLevelId) {
const sceneNodes = collectAllNodes(ctx)
const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes)
if (!serviceLevelIds.includes(parentLevelId as AnyNodeId)) {
return null
}
}
const wallThickness = Math.max(display.shaftWallThickness ?? 0.09, 0.04)
const cabWidth = Math.max(display.width, 0.8)
const cabDepth = Math.max(display.depth, 0.8)
const shaftWidth = Math.max(display.shaftWidth ?? display.width, cabWidth, 0.8)
const shaftDepth = Math.max(display.shaftDepth ?? display.depth, cabDepth, 0.8)
const doorWidth = Math.min(Math.max(display.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 center = { x: display.position[0], y: display.position[2] }
const cos = Math.cos(display.rotation)
const sin = Math.sin(display.rotation)
const rotate = (lx: number, ly: number): [number, number] => {
// Same clockwise convention as `rotatePlanVector` in editor — see
// `wiki/architecture/tools.md` for why every plan-space rotation
// uses this matrix and not the standard counter-clockwise one.
return [lx * cos + ly * sin, -lx * sin + ly * cos]
}
// Outer shaft footprint corners.
const outerCorners: Array<readonly [number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
]
const outerPoints: FloorplanPoint[] = outerCorners.map(([lx, ly]) => {
const [rx, ry] = rotate(lx, ly)
return [center.x + rx, center.y + ry]
})
// Cab inner rectangle. The cab sits flush against the front face
// (-Z in local coords) so its center is `-shaftDepth/2 + cabDepth/2`
// away from shaft center.
const cabCenterLocalY = -shaftDepth / 2 + cabDepth / 2
const cabHalfW = cabWidth / 2
const cabHalfD = cabDepth / 2
const cabCorners: Array<readonly [number, number]> = [
[-cabHalfW, cabCenterLocalY - cabHalfD],
[cabHalfW, cabCenterLocalY - cabHalfD],
[cabHalfW, cabCenterLocalY + cabHalfD],
[-cabHalfW, cabCenterLocalY + cabHalfD],
]
const cabPoints: FloorplanPoint[] = cabCorners.map(([lx, ly]) => {
const [rx, ry] = rotate(lx, ly)
return [center.x + rx, center.y + ry]
})
// Runtime state — current level / target level / queued.
const runtime = useInteractive.getState().elevators[node.id]
const isCarOnLevel = parentLevelId ? runtime?.currentLevelId === parentLevelId : false
const isTargetLevel = parentLevelId ? runtime?.targetLevelId === parentLevelId : false
const isQueuedLevel = parentLevelId
? (runtime?.queue.includes(parentLevelId as never) ?? false)
: false
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
// Stroke selection — selected wins, then runtime target / queued
// states get the accent palette colour so users can spot "the cab is
// coming here" at a glance.
const stroke =
showSelectedChrome && palette
? palette.selectedStroke
: isTargetLevel || isQueuedLevel
? '#0ea5e9'
: '#475569'
// Shaft fill — orange when selected, light slate otherwise. When the
// car is *on this level*, the cab indicator inside gets the highlight
// instead of the whole shaft (more legible).
const shaftFill = showSelectedChrome ? '#fed7aa' : '#cbd5e1'
const cabFill = isCarOnLevel ? '#22c55e' : showSelectedChrome ? '#fef3c7' : '#e2e8f0'
const cabStroke = isCarOnLevel ? '#15803d' : '#475569'
const children: FloorplanGeometry[] = []
// Outer shaft.
children.push({
kind: 'polygon',
points: outerPoints,
fill: shaftFill,
stroke,
strokeWidth: showSelectedChrome ? 0.04 : 0.03,
strokeLinejoin: 'round',
opacity: 0.85,
})
// Cab inner rectangle.
children.push({
kind: 'polygon',
points: cabPoints,
fill: cabFill,
fillOpacity: isCarOnLevel ? 0.85 : 0.55,
stroke: cabStroke,
strokeWidth: 0.018,
strokeLinejoin: 'round',
opacity: 0.92,
})
// Door opening indicator — a short line on the front edge centered
// on the cab. The legacy renders a more complex slide / center-open
// hint; this is the minimum useful signal.
const doorY = -halfDepth
const [doorStartX, doorStartY] = rotate(-doorWidth / 2, doorY)
const [doorEndX, doorEndY] = rotate(doorWidth / 2, doorY)
children.push({
kind: 'line',
x1: center.x + doorStartX,
y1: center.y + doorStartY,
x2: center.x + doorEndX,
y2: center.y + doorEndY,
stroke: isCarOnLevel ? '#15803d' : '#0f172a',
strokeWidth: 0.05,
strokeLinecap: 'round',
opacity: 0.92,
})
// Served-level chips — vertical column of marker circles + level
// numbers to the right of the shaft, only when selected and the
// elevator serves more than one level. Mirrors the legacy
// `<FloorplanElevatorLayer>` chip rendering (~line 6423 in
// floorplan-panel.tsx).
if (isSelected && parentLevelId) {
const sceneNodes = collectAllNodes(ctx)
const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes)
if (serviceLevelIds.length > 1) {
const disabledLevelIds = new Set(display.disabledLevelIds ?? [])
const serviceOnlyLevelIds = new Set(display.serviceOnlyLevelIds ?? [])
const rangeStep = 0.18
const rangeHeight = Math.max(0, (serviceLevelIds.length - 1) * rangeStep)
const [rangeOffsetX, rangeOffsetY] = rotate(halfWidth + 0.38, 0)
const rangeX = center.x + rangeOffsetX
const rangeBottomY = center.y + rangeOffsetY + rangeHeight / 2
const rangeTopY = center.y + rangeOffsetY - rangeHeight / 2
// Connector spine — single vertical line tying the chips to the
// shaft. Sky blue, semi-transparent.
children.push({
kind: 'line',
x1: rangeX,
y1: rangeTopY,
x2: rangeX,
y2: rangeBottomY,
stroke: '#0ea5e9',
strokeOpacity: 0.52,
strokeWidth: 0.018,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
// One chip per served level. Lowest level at the bottom of the
// column, index increases upward — matches legacy ordering.
serviceLevelIds.forEach((levelId, index) => {
const isCurrent = runtime?.currentLevelId === levelId
const isTarget = runtime?.targetLevelId === levelId
// `resolveElevatorServiceLevelIds` returns plain `string[]`, but
// the runtime queue is `AnyNodeId[]` (branded). The values agree
// at runtime — narrowing through `as never` keeps the includes
// call type-safe without dragging the brand into the helper's
// public return type.
const isQueued = runtime?.queue.includes(levelId as never) ?? false
const isDisabled = disabledLevelIds.has(levelId)
const isServiceOnly = serviceOnlyLevelIds.has(levelId)
const isUnavailable = isDisabled || isServiceOnly
const markerFill = isCurrent
? '#22c55e'
: isTarget || isQueued
? '#38bdf8'
: isUnavailable
? '#94a3b8'
: '#ffffff'
const markerStroke = isUnavailable ? '#64748b' : '#0369a1'
const labelColor = isUnavailable ? '#64748b' : '#075985'
const y = rangeBottomY - index * rangeStep
children.push({
kind: 'circle',
cx: rangeX,
cy: y,
r: 0.055,
fill: markerFill,
fillOpacity: isUnavailable ? 0.72 : 0.95,
stroke: markerStroke,
strokeWidth: 0.012,
})
children.push({
kind: 'text',
x: rangeX + 0.11,
y,
text: String(index + 1),
fontSize: 0.13,
fontWeight: 700,
fill: labelColor,
textAnchor: 'start',
dominantBaseline: 'middle',
})
})
}
}
if (isSelected) {
children.push({
kind: 'move-handle',
point: [display.position[0], display.position[2]],
})
}
return { kind: 'group', children }
}
/**
* `ctx` exposes `resolve` and `children` / `siblings` / `parent`, but
* not the full nodes map. `resolveElevatorServiceLevelIds` wants a
* `Record<id, AnyNode>`; we rebuild it by walking the chain we DO have
* access to. For the elevator's service-level check we only need the
* elevator's parent (the level), its building, and any level siblings.
* This is the minimum graph the resolver needs.
*
* If a future use needs the full nodes map for a builder, we'd surface
* it through ctx — but doing so leaks the whole scene store into every
* `def.floorplan` call. Narrow opt-in is the better default.
*/
function collectAllNodes(ctx: GeometryContext): Record<string, never> {
// We need the building → levels graph for service-level resolution.
// Walk up from the elevator: parent (level) → its parent (building) →
// building.children (all levels). That's enough for the resolver.
const out: Record<string, unknown> = {}
const level = ctx.parent
if (level) {
out[level.id] = level
const building = (level as { parentId?: string }).parentId
? ctx.resolve((level as { parentId: string }).parentId as never)
: undefined
if (building) {
out[building.id] = building
const childIds = (building as unknown as { children?: string[] }).children
if (Array.isArray(childIds)) {
for (const cid of childIds) {
const child = ctx.resolve(cid as never)
if (child) out[child.id] = child
}
}
}
}
return out as Record<string, never>
}
+1
View File
@@ -0,0 +1 @@
export { elevatorDefinition } from './definition'
+940
View File
@@ -0,0 +1,940 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type ElevatorNode,
ElevatorNode as ElevatorNodeSchema,
type LevelNode,
requestElevatorLevel,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
MetricControl,
PanelSection,
PanelWrapper,
resolveElevatorNodeSupportY,
resolveElevatorSupportY,
SliderControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
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'
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 default 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
triggerSFX('sfx:item-pick')
clearLivePreview()
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [clearLivePreview, node, setMovingNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!(node && node.parentId)) return
triggerSFX('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
triggerSFX('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={() => {
triggerSFX('sfx:item-rotate')
commitTransform(displayPosition, displayRotation - Math.PI / 4)
}}
/>
<ActionButton
label="+45°"
onClick={() => {
triggerSFX('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>
)
}
@@ -0,0 +1,6 @@
import type { ElevatorNode, ParametricDescriptor } from '@pascal-app/core'
export const elevatorParametrics: ParametricDescriptor<ElevatorNode> = {
groups: [],
customPanel: () => import('./panel'),
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export { ElevatorNode } from '@pascal-app/core'
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core'
import { ElevatorInteractionSystem } from '@pascal-app/viewer'
/**
* Composite system for elevator — bundles three per-frame systems:
* `ElevatorRuntimeSystem` (cab travel + door state machine),
* `ElevatorInteractionSystem` (call buttons / cab UI), and
* `ElevatorOpeningSystem` (wall + slab cutout cascade).
*/
export default function ElevatorSystem() {
return (
<>
<ElevatorRuntimeSystem />
<ElevatorInteractionSystem />
<ElevatorOpeningSystem />
</>
)
}
+8
View File
@@ -1,5 +1,6 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildFenceFloorplan } from './floorplan'
import { fenceMoveEndpointAffordance } from './floorplan-affordances'
import { buildFenceGeometry } from './geometry'
import { fenceParametrics } from './parametrics'
import { FenceNode } from './schema'
@@ -74,6 +75,13 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
// Legacy `floorplanFenceEntries` short-circuits to [] when fence is
// registered (see floorplan-panel.tsx).
floorplan: buildFenceFloorplan,
// 2D drag affordance — sister to `actions/move-endpoint.ts`. The 3D
// DragAction drives R3F grid events through `createDragSession`; this
// one drives SVG pointer events through the floor-plan registry
// dispatcher's snapshot + single-undo dance. Same legacy semantics.
floorplanAffordances: {
'move-endpoint': fenceMoveEndpointAffordance,
},
// Stage D — all four fence drag-affordances live in this folder.
// curve / move-endpoint / move are 1:1 ports of the legacy tools
// (same snap pipeline, same history dance, same cursor render),
@@ -0,0 +1,132 @@
import {
type AnyNode,
type AnyNodeId,
type FenceNode,
type FloorplanAffordance,
type FloorplanAffordanceSession,
useScene,
type WallNode,
} from '@pascal-app/core'
import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pascal-app/editor'
/**
* Floor-plan 2D drag affordances for fence — sister to the 3D
* `actions/move-endpoint.ts` `DragAction`. Same legacy interaction
* (endpoint snap pipeline + linked-fence cascade via `endpoint-match`
* with an epsilon, ALT-detach), driven from SVG pointer events instead
* of R3F grid events.
*
* Why not share the `DragAction`? The 3D code goes through
* `createDragSession` which assumes a `SceneApi`-style helper bag
* (snapshot, restoreAll, pauseHistory, resumeHistory). The 2D registry
* layer owns those semantics directly via the dispatcher's snapshot +
* pause/resume dance, so the affordance only needs the pure mutation
* logic. The shape is intentionally close to the legacy fence drag —
* 1:1 behaviorally.
*/
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
type FenceEndpointPayload = { fenceId: AnyNodeId; endpoint: 'start' | 'end' }
function pointsNearlyEqual(a: FencePlanPoint, b: FencePlanPoint): boolean {
return (
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
)
}
function collectLevel(
nodes: Record<AnyNodeId, AnyNode>,
parentId: string | null,
): { walls: WallNode[]; fences: FenceNode[] } {
const walls: WallNode[] = []
const fences: FenceNode[] = []
for (const node of Object.values(nodes)) {
if (!node) continue
if ((node.parentId ?? null) !== parentId) continue
if (node.type === 'wall') walls.push(node as WallNode)
else if (node.type === 'fence') fences.push(node as FenceNode)
}
return { walls, fences }
}
function collectLinkedFences(
fences: FenceNode[],
draggedFenceId: AnyNodeId,
linkedPoint: FencePlanPoint,
): Array<{ id: AnyNodeId; start: FencePlanPoint; end: FencePlanPoint }> {
const out: Array<{ id: AnyNodeId; start: FencePlanPoint; end: FencePlanPoint }> = []
for (const fence of fences) {
if (fence.id === draggedFenceId) continue
if (!pointsNearlyEqual(fence.start, linkedPoint) && !pointsNearlyEqual(fence.end, linkedPoint))
continue
out.push({
id: fence.id,
start: [fence.start[0], fence.start[1]],
end: [fence.end[0], fence.end[1]],
})
}
return out
}
export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
start({ node, payload, nodes }): FloorplanAffordanceSession {
const { endpoint } = payload as FenceEndpointPayload
const originalStart: FencePlanPoint = [node.start[0], node.start[1]]
const originalEnd: FencePlanPoint = [node.end[0], node.end[1]]
const originalMovingPoint = endpoint === 'start' ? originalStart : originalEnd
const fixedPoint: FencePlanPoint = endpoint === 'start' ? originalEnd : originalStart
const parentId = node.parentId ?? null
const { walls, fences } = collectLevel(nodes, parentId)
const linkedOriginals = collectLinkedFences(fences, node.id, originalMovingPoint)
const affectedIds: AnyNodeId[] = [node.id, ...linkedOriginals.map((l) => l.id)]
return {
affectedIds,
apply({ planPoint, modifiers }) {
// Re-collect siblings each tick: the user might be dragging a
// fence whose sibling positions changed (the dragged fence
// itself is excluded via `ignoreFenceIds`).
const sceneNodes = useScene.getState().nodes
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId)
const snapped = snapFenceDraftPoint({
point: planPoint as FencePlanPoint,
walls: nextWalls,
fences: nextFences,
start: fixedPoint,
angleSnap: !modifiers.shiftKey,
ignoreFenceIds: [node.id],
})
const nextStart = endpoint === 'start' ? snapped : fixedPoint
const nextEnd = endpoint === 'end' ? snapped : fixedPoint
const linkedUpdates = modifiers.altKey
? []
: linkedOriginals.map((l) => ({
id: l.id,
start: pointsNearlyEqual(l.start, originalMovingPoint) ? snapped : l.start,
end: pointsNearlyEqual(l.end, originalMovingPoint) ? snapped : l.end,
}))
useScene.getState().updateNodes([
{ id: node.id, data: { start: nextStart, end: nextEnd } },
...linkedUpdates.map((u) => ({
id: u.id,
data: { start: u.start, end: u.end },
})),
])
},
canCommit() {
const finalFence = useScene.getState().nodes[node.id] as FenceNode | undefined
return (
!!finalFence &&
finalFence.type === 'fence' &&
isWallLongEnough(finalFence.start, finalFence.end)
)
},
}
},
}
+366 -22
View File
@@ -1,32 +1,376 @@
import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
import { isCurvedWall, sampleWallCenterline } from '@pascal-app/core'
import {
type FloorplanGeometry,
type GeometryContext,
getWallCurveFrameAt,
getWallCurveLength,
isCurvedWall,
sampleWallCenterline,
} from '@pascal-app/core'
import type { FenceNode } from './schema'
/**
* Stage C floor-plan builder for fence. Draws the fence centerline as
* a polyline; thickness becomes the stroke width.
* Stage C floor-plan builder for fence. 1:1 visual port of the legacy
* `FloorplanFenceLayer` from `floorplan-panel.tsx`:
*
* Curved fences sample the centerline at 24 segments — same density the
* legacy `floorplanFenceEntries` useMemo uses, so straight + curved
* fences look comparable to the legacy rendering.
* 1. Three stacked stroke paths along the centerline, all with
* `vectorEffect: 'non-scaling-stroke'` so widths stay constant on
* screen at any zoom:
* a. Optional glow (semi-transparent, only when active/hovered).
* b. White underlay — the visual "fence body" base layer.
* c. Dark accent — the actual fence outline.
* 2. Style-aware markers at computed positions along the centerline:
* - `privacy`: rotated rectangle (vertical slat).
* - `rail`: concentric circle stack (post + ring + tiny center).
* - default `slat`: white X mark with a coloured X on top.
* 3. Markers thinned when `showInfill === false` — only first + last
* remain (matches legacy "endpoints only" mode).
* 4. Selection chrome: dots on the endpoints + centered length label
* (same shape as the wall builder).
*
* Visual nuances the legacy ships (side hatching to indicate thickness
* direction, post markers along the centerline) are deferred — Phase 5
* Stage D will revisit if real visual parity is needed.
* `getFloorplanFenceMarkerTs` is inlined here — it was a private helper
* in the legacy panel and is fence-specific, so it lives with the kind.
*/
export function buildFenceFloorplan(node: FenceNode): FloorplanGeometry {
const points: FloorplanPoint[] = isCurvedWall(node)
? sampleWallCenterline(node, 24).map((p) => [p.x, p.y] as FloorplanPoint)
: [
[node.start[0], node.start[1]],
[node.end[0], node.end[1]],
]
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function getFloorplanFenceLength(fence: FenceNode): number {
return isCurvedWall(fence)
? getWallCurveLength(fence)
: Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1])
}
/**
* Distribute markers along the fence centerline. Spacing depends on
* `postSpacing` (tighter for privacy style); `inset` keeps the first /
* last marker away from the endpoints. Returns a list of t-values in
* [0, 1] suitable for `getWallCurveFrameAt`.
*/
function getFloorplanFenceMarkerTs(fence: FenceNode): number[] {
const length = getFloorplanFenceLength(fence)
if (length <= 0.24) return [0.5]
const spacing = clamp(
fence.style === 'privacy' ? fence.postSpacing * 0.72 : fence.postSpacing,
0.34,
1.5,
)
const inset = clamp(
Math.max(fence.postSize * 1.25, fence.edgeInset * 10),
0.18,
Math.min(0.48, length * 0.22),
)
const usableLength = Math.max(length - inset * 2, 0)
if (usableLength <= 0.001) return [0.5]
const markerCount = Math.max(1, Math.min(24, Math.floor(usableLength / spacing) + 1))
if (markerCount === 1) return [0.5]
return Array.from({ length: markerCount }, (_, index) =>
clamp((inset + (usableLength * index) / (markerCount - 1)) / length, 0.08, 0.92),
)
}
function buildCenterlinePathD(points: ReadonlyArray<{ x: number; y: number }>): string {
if (points.length < 2) return ''
const first = points[0]!
return [`M ${first.x} ${first.y}`, ...points.slice(1).map((p) => `L ${p.x} ${p.y}`)].join(' ')
}
function buildMarker(
fence: FenceNode,
point: { x: number; y: number },
angleRadians: number,
accentColor: string,
surfaceColor: string,
isActive: boolean,
): FloorplanGeometry {
const markerStrokeWidth = isActive ? 1.65 : 1.35
if (fence.style === 'privacy') {
const w = clamp(fence.postSize * 0.58, 0.038, 0.068)
const h = clamp(Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4), 0.1, 0.17)
// Surface plate underneath + accent rectangle on top — gives a clean
// "punched out of the underlay stroke" look at all zooms.
return {
kind: 'group',
transform: { translate: [point.x, point.y], rotate: angleRadians },
children: [
{
kind: 'rect',
x: -(w + 0.032) / 2,
y: -(h + 0.038) / 2,
width: w + 0.032,
height: h + 0.038,
rx: 0.014,
ry: 0.014,
fill: surfaceColor,
},
{
kind: 'rect',
x: -w / 2,
y: -h / 2,
width: w,
height: h,
rx: 0.01,
ry: 0.01,
fill: accentColor,
},
],
}
}
if (fence.style === 'rail') {
const r = clamp(fence.postSize * 0.52, 0.048, 0.078)
return {
kind: 'group',
transform: { translate: [point.x, point.y] },
children: [
{ kind: 'circle', cx: 0, cy: 0, r: r + 0.018, fill: surfaceColor },
{
kind: 'circle',
cx: 0,
cy: 0,
r,
fill: surfaceColor,
stroke: accentColor,
strokeWidth: markerStrokeWidth,
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'circle',
cx: 0,
cy: 0,
r: r * 0.34,
fill: accentColor,
fillOpacity: isActive ? 0.24 : 0.18,
vectorEffect: 'non-scaling-stroke',
},
],
}
}
// Default — slat X mark.
const half = clamp(fence.postSize * 0.42, 0.03, 0.055)
return {
kind: 'polyline',
points,
stroke: node.color || '#475569',
strokeWidth: Math.max(node.thickness, 0.05),
opacity: 0.9,
kind: 'group',
transform: { translate: [point.x, point.y], rotate: angleRadians },
children: [
// White underlay so the X shows against any background.
{
kind: 'line',
x1: -half,
y1: -half,
x2: half,
y2: half,
stroke: surfaceColor,
strokeWidth: 2.8,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'line',
x1: half,
y1: -half,
x2: -half,
y2: half,
stroke: surfaceColor,
strokeWidth: 2.8,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
// Accent X on top.
{
kind: 'line',
x1: -half,
y1: -half,
x2: half,
y2: half,
stroke: accentColor,
strokeWidth: markerStrokeWidth,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'line',
x1: half,
y1: -half,
x2: -half,
y2: half,
stroke: accentColor,
strokeWidth: markerStrokeWidth,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
],
}
}
export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): FloorplanGeometry {
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const isHovered = view?.hovered ?? false
const isActive = isSelected || isHighlighted
const showInteractiveChrome = isActive || isHovered
// Centerline path — sampled for curved fences so the underlay /
// accent / glow all trace the same shape.
const centerlinePoints = isCurvedWall(node)
? sampleWallCenterline(node, 24)
: [
{ x: node.start[0], y: node.start[1] },
{ x: node.end[0], y: node.end[1] },
]
const pathD = buildCenterlinePathD(centerlinePoints)
// Stroke shifts: selected wins; hover (not selected) → `wallHoverStroke`
// (light blue from the legacy palette, same as walls); otherwise dark
// accent. Mirrors the `fenceStroke` ternary in the legacy panel.
const accentStroke =
isActive && palette
? palette.selectedStroke
: isHovered && palette
? palette.wallHoverStroke
: '#111827'
const glowStroke =
isActive && palette
? palette.selectedStroke
: isHovered && palette
? palette.wallHoverStroke
: accentStroke
const underlayStroke = 'rgba(255, 255, 255, 0.98)'
// Surface (white) for marker plates — themed dark surface would look
// wrong on the white underlay, so we hardcode white here.
const markerSurface = '#ffffff'
// Widths step up on hover (between idle and active) — same pattern the
// legacy panel uses for `fenceUnderlayWidth` / `fenceStrokeWidth`.
const underlayWidth = isActive ? 6.5 : isHovered ? 6 : 5.2
const accentWidth = isActive ? 2.6 : isHovered ? 2.35 : 2.05
// Glow only appears when active or hovered. Opacity gradient matches
// the legacy (0.22 active / 0.14 hover / 0 idle).
const glowOpacity = isActive ? 0.22 : isHovered ? 0.14 : 0
// Marker frames. Filter to first+last when infill is off so the user
// still sees end posts (matches legacy).
const markerTs = getFloorplanFenceMarkerTs(node)
const markerFrames = markerTs.map((t) => {
const frame = getWallCurveFrameAt(node, t)
return {
point: frame.point,
angle: Math.atan2(frame.tangent.y, frame.tangent.x),
}
})
const visibleMarkers =
(node.showInfill ?? true)
? markerFrames
: markerFrames.filter((_, i) => i === 0 || i === markerFrames.length - 1)
const children: FloorplanGeometry[] = []
// 1. Glow (only when active / highlighted / hovered). Wide,
// low-opacity ring. Width steps with the interaction level so hover
// is subtler than active.
if (glowOpacity > 0) {
children.push({
kind: 'path',
d: pathD,
fill: 'none',
stroke: glowStroke,
strokeWidth: isActive ? 9.5 : isHovered ? 8.8 : 8.2,
strokeOpacity: glowOpacity,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
}
// 2. White underlay — visible fence body base layer.
children.push({
kind: 'path',
d: pathD,
fill: 'none',
stroke: underlayStroke,
strokeOpacity: 0.98,
strokeWidth: underlayWidth,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
// 3. Dark accent on top.
children.push({
kind: 'path',
d: pathD,
fill: 'none',
stroke: accentStroke,
strokeWidth: accentWidth,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
// 4. Style-aware markers. Pass `showInteractiveChrome` so hover also
// bumps marker stroke widths slightly (legacy panel does the same).
for (const marker of visibleMarkers) {
children.push(
buildMarker(
node,
marker.point,
marker.angle,
accentStroke,
markerSurface,
showInteractiveChrome,
),
)
}
// 5. Hit-line for click detection.
children.push({
kind: 'hit-line',
x1: node.start[0],
y1: node.start[1],
x2: node.end[0],
y2: node.end[1],
strokeWidthPx: 18,
cursor: 'pointer',
})
// 6. Endpoint handles + length label when selected.
if (isSelected) {
children.push({
kind: 'endpoint-handle',
point: [node.start[0], node.start[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'start' as const },
})
children.push({
kind: 'endpoint-handle',
point: [node.end[0], node.end[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'end' as const },
})
const length = getWallCurveLength(node)
if (length >= 0.1) {
const midX = (node.start[0] + node.end[0]) / 2
const midZ = (node.start[1] + node.end[1]) / 2
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
children.push({
kind: 'dimension-label',
cx: midX,
cy: midZ,
text: `${Number.parseFloat(length.toFixed(2))}m`,
angle: Math.atan2(dz, dx),
})
}
}
return { kind: 'group', children }
}
+51
View File
@@ -0,0 +1,51 @@
import { GuideNode as GuideNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { guideParametrics } from './parametrics'
import { GuideNode } from './schema'
/**
* Guide — Stage A. Measurement reference annotations placed by the
* user (linear / area / arc). `GuideSystem` handles per-frame
* positioning; the renderer mounts the visual marker + dimensioning
* HUD via `<Html>`.
*/
export const guideDefinition: NodeDefinition<typeof GuideNode> = {
kind: 'guide',
schemaVersion: 1,
schema: GuideNode,
category: 'site',
defaults: () => {
const stub = GuideNodeSchema.parse({ id: 'guide_default' as never, type: 'guide' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: false,
deletable: true,
},
parametrics: guideParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 5,
},
presentation: {
label: 'Guide',
description: 'A measurement / reference annotation (linear, area, or arc).',
icon: { kind: 'url', src: '/icons/blueprint.png' },
paletteSection: 'site',
paletteOrder: 30,
},
mcp: {
description: 'A measurement reference guide annotation.',
},
}
+1
View File
@@ -0,0 +1 @@
export { guideDefinition } from './definition'
+5
View File
@@ -0,0 +1,5 @@
import type { GuideNode, ParametricDescriptor } from '@pascal-app/core'
export const guideParametrics: ParametricDescriptor<GuideNode> = {
groups: [],
}
+72
View File
@@ -0,0 +1,72 @@
'use client'
import { type GuideNode, useRegistry } from '@pascal-app/core'
import { useAssetUrl, useViewer } from '@pascal-app/viewer'
import { useLoader } from '@react-three/fiber'
import { Suspense, useMemo, useRef } from 'react'
import { DoubleSide, type Group, type Texture, TextureLoader } from 'three'
import { float, texture } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
export const GuideRenderer = ({ node }: { node: GuideNode }) => {
const showGuides = useViewer((s) => s.showGuides)
const ref = useRef<Group>(null!)
useRegistry(node.id, 'guide', ref)
const resolvedUrl = useAssetUrl(node.url)
return (
<group
position={node.position}
ref={ref}
rotation={[0, node.rotation[1], 0]}
visible={showGuides && node.visible !== false}
>
{resolvedUrl && (
<Suspense>
<GuidePlane opacity={node.opacity} scale={node.scale} url={resolvedUrl} />
</Suspense>
)}
</group>
)
}
const GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opacity: number }) => {
const tex = useLoader(TextureLoader, url) as Texture
const { width, height, material } = useMemo(() => {
const img = tex.image as HTMLImageElement | ImageBitmap
const w = img.width || 1
const h = img.height || 1
const aspect = w / h
// Default: 10 meters wide, height from aspect ratio
const planeWidth = 10 * scale
const planeHeight = (10 / aspect) * scale
const normalizedOpacity = opacity / 100
const mat = new MeshBasicNodeMaterial({
transparent: true,
colorNode: texture(tex),
opacityNode: float(normalizedOpacity),
side: DoubleSide,
depthWrite: false,
})
return { width: planeWidth, height: planeHeight, material: mat }
}, [tex, scale, opacity])
return (
<mesh
frustumCulled={false}
material={material}
raycast={() => {}}
rotation={[-Math.PI / 2, 0, 0]}
>
<planeGeometry args={[width, height]} boundingBox={null} boundingSphere={null} />
</mesh>
)
}
export default GuideRenderer
+1
View File
@@ -0,0 +1 @@
export { GuideNode } from '@pascal-app/core'
+5
View File
@@ -0,0 +1,5 @@
'use client'
import { GuideSystem } from '@pascal-app/viewer'
export default GuideSystem
+26 -4
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { loadPlugin, nodeRegistry } from '@pascal-app/core'
import { AnyNode, loadPlugin, nodeRegistry } from '@pascal-app/core'
import { builtinPlugin } from './index'
describe('builtinPlugin', () => {
@@ -15,10 +15,32 @@ describe('builtinPlugin', () => {
test('loads the registered kinds without error', async () => {
await loadPlugin(builtinPlugin)
// Phase 2 registers shelf unconditionally; spawn is flag-gated. So the
// registry should always contain shelf, and may contain spawn depending
// on the NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN env value at module load.
expect(nodeRegistry.has('shelf')).toBe(true)
expect(nodeRegistry.size).toBeGreaterThanOrEqual(1)
})
test('every AnyNode discriminator is registered in builtinPlugin', async () => {
// Phase 6 coverage check. The `AnyNode` discriminated union and the
// `builtinPlugin.nodes` array are both hand-maintained today (full
// codegen would have to run at module-load time, which loses the
// static node typing TypeScript relies on). This test makes drift a
// CI failure: every node `type` literal in the union must have a
// matching `def.kind` in the plugin, and vice versa.
//
// When a kind is added: append it to both `core/src/schema/types.ts`
// (the union) and `nodes/src/index.ts` (the plugin), and this test
// will keep them honest.
await loadPlugin(builtinPlugin)
const unionKinds = new Set(
AnyNode.options.map((option) => {
const typeShape = (option as unknown as { shape: { type: { value: string } } }).shape.type
return typeShape.value
}),
)
const registryKinds = new Set(Array.from(nodeRegistry.entries(), ([kind]) => kind))
const missingFromRegistry = [...unionKinds].filter((k) => !registryKinds.has(k))
const missingFromUnion = [...registryKinds].filter((k) => !unionKinds.has(k))
expect(missingFromRegistry).toEqual([])
expect(missingFromUnion).toEqual([])
})
})
+41 -4
View File
@@ -1,13 +1,25 @@
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import { buildingDefinition } from './building'
import { ceilingDefinition } from './ceiling'
import { columnDefinition } from './column'
import { doorDefinition } from './door'
import { elevatorDefinition } from './elevator'
import { fenceDefinition } from './fence'
import { guideDefinition } from './guide'
import { itemDefinition } from './item'
import { levelDefinition } from './level'
import { roofDefinition } from './roof'
import { roofSegmentDefinition } from './roof-segment'
import { scanDefinition } from './scan'
import { shelfDefinition } from './shelf'
import { siteDefinition } from './site'
import { slabDefinition } from './slab'
import { spawnDefinition } from './spawn'
import { stairDefinition } from './stair'
import { stairSegmentDefinition } from './stair-segment'
import { wallDefinition } from './wall'
import { windowDefinition } from './window'
import { zoneDefinition } from './zone'
/**
* Built-in plugin bundling every node kind shipped with the Pascal editor.
@@ -22,15 +34,14 @@ import { windowDefinition } from './window'
*
* All kinds are registered unconditionally. Parity is verified by
* comparing against deployed production rather than an in-app env-var
* flag toggle. Legacy paths still exist in `viewer/` and `editor/` for
* kinds undergoing migration; they short-circuit via the Phase 0
* `<LegacySystem>` wrapper + `NodeRenderer`'s registry-first dispatch.
* Phase 6 deletes the legacy paths.
* flag toggle. As of Phase 6 the legacy mount points in `viewer/` are
* gone — every kind dispatches through the registry.
*/
export const builtinPlugin: Plugin = {
id: 'pascal:core',
apiVersion: 1,
nodes: [
// Stage E-complete (full registry path)
shelfDefinition as unknown as AnyNodeDefinition,
spawnDefinition as unknown as AnyNodeDefinition,
wallDefinition as unknown as AnyNodeDefinition,
@@ -40,15 +51,41 @@ export const builtinPlugin: Plugin = {
doorDefinition as unknown as AnyNodeDefinition,
windowDefinition as unknown as AnyNodeDefinition,
itemDefinition as unknown as AnyNodeDefinition,
// Stage A — wrap-exports the legacy renderer + system. Legacy
// panels / move tools / floorplan branches still serve these.
columnDefinition as unknown as AnyNodeDefinition,
elevatorDefinition as unknown as AnyNodeDefinition,
roofDefinition as unknown as AnyNodeDefinition,
roofSegmentDefinition as unknown as AnyNodeDefinition,
stairDefinition as unknown as AnyNodeDefinition,
stairSegmentDefinition as unknown as AnyNodeDefinition,
zoneDefinition as unknown as AnyNodeDefinition,
siteDefinition as unknown as AnyNodeDefinition,
buildingDefinition as unknown as AnyNodeDefinition,
levelDefinition as unknown as AnyNodeDefinition,
guideDefinition as unknown as AnyNodeDefinition,
scanDefinition as unknown as AnyNodeDefinition,
],
}
export { buildingDefinition } from './building'
export { ceilingDefinition } from './ceiling'
export { columnDefinition } from './column'
export { doorDefinition } from './door'
export { elevatorDefinition } from './elevator'
export { fenceDefinition } from './fence'
export { guideDefinition } from './guide'
export { itemDefinition } from './item'
export { levelDefinition } from './level'
export { roofDefinition } from './roof'
export { roofSegmentDefinition } from './roof-segment'
export { scanDefinition } from './scan'
export { shelfDefinition } from './shelf'
export { siteDefinition } from './site'
export { slabDefinition } from './slab'
export { spawnDefinition } from './spawn'
export { stairDefinition } from './stair'
export { stairSegmentDefinition } from './stair-segment'
export { wallDefinition } from './wall'
export { windowDefinition } from './window'
export { zoneDefinition } from './zone'
+24 -1
View File
@@ -1,5 +1,6 @@
import type { ItemNode as ItemNodeType, NodeDefinition } from '@pascal-app/core'
import { buildItemFloorplan } from './floorplan'
import { itemFloorplanMoveTarget } from './floorplan-move'
import { itemParametrics } from './parametrics'
import { ItemNode } from './schema'
@@ -79,9 +80,31 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
// Same priority as the legacy ItemSystem.
priority: 2,
},
// Catalog placement tool — mounted when `useEditor.tool === 'item'`.
// Wraps the same placement coordinator the move-tool uses (surface
// strategies for floor / wall / ceiling / item-surface). Replaces
// the legacy `editor/src/components/tools/item/item-tool.tsx`.
tool: () => import('./tool'),
// Stage D — 3D move-tool (registry-driven). Adopts the moving node
// and runs the placement coordinator with surface strategies for
// floor / wall / ceiling / item-surface, including attachTo
// *transitions* (drop a wall item on a ceiling and have it switch).
// Replaces the legacy `MoveItemContent` in editor's dispatcher; the
// `getRegistryAffordanceTool('item', 'move')` lookup picks this up.
affordanceTools: {
move: () => import('./move-tool'),
},
// Stage C: floor-plan polygon. ctx.resolve walks the parent chain
// (wall / nested item / level) to compute the world-space transform.
floorplan: buildItemFloorplan,
// 2D move-on-floorplan handler. Branches on `asset.attachTo`:
// wall items snap to walls (like door / window), ceiling items
// snap to ceiling polygons, floor items snap to slabs. attachTo
// *transitions* (drop a wall item on a ceiling) remain canonical
// in the 3D path; 2D only re-anchors within the same family.
floorplanMoveTarget: itemFloorplanMoveTarget,
toolHints: [
{ key: 'Left click', label: 'Place item' },
@@ -94,7 +117,7 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
presentation: {
label: 'Item',
description: 'A catalog-backed item (furniture, fixtures, decorations).',
icon: { kind: 'iconify', name: 'lucide:armchair' },
icon: { kind: 'url', src: '/icons/item.png' },
paletteSection: 'furnish',
paletteOrder: 10,
},
+78 -8
View File
@@ -6,6 +6,7 @@ import {
type GeometryContext,
getScaledDimensions,
type ItemNode,
useLiveTransforms,
} from '@pascal-app/core'
/**
@@ -25,10 +26,17 @@ import {
*/
type Transform = { x: number; y: number; rotation: number }
// Plan-space rotation convention used by the legacy `rotatePlanVector`
// in `editor/src/lib/floorplan/geometry.ts`. This is a CLOCKWISE rotation
// — the registry-side equivalent of the canonical floor-plan transform
// math. Don't switch to a standard counter-clockwise rotation; the wall
// items math (wallRotation = -atan2(dy, dx)) is calibrated against this
// convention, and the legacy item floor-plan stack reads from these
// offsets across many sites.
function rotateVec(x: number, y: number, angle: number): [number, number] {
const c = Math.cos(angle)
const s = Math.sin(angle)
return [x * c - y * s, x * s + y * c]
return [x * c + y * s, -x * s + y * c]
}
function resolveItemTransform(
@@ -75,6 +83,35 @@ function resolveItemTransform(
rotation: parentT.rotation + localRotation,
}
}
} else if (parentNode?.type === 'shelf') {
// Shelf-hosted item: `item.position` is in shelf-local coords. The
// shelf has its own `position` + `rotation[1]` in its parent (level)
// frame, so the item's plan-space position composes the shelf's
// pose with the item's local offset. Without this branch the
// `else` below would treat shelf-local coords as level-local and
// the item would render at the wrong spot whenever the shelf is
// anywhere other than (0, 0, 0).
//
// We also check `useLiveTransforms` for the shelf — if the shelf is
// mid-move (3D or 2D), its scene-state `position` is still at the
// pre-move spot but the live transform carries the cursor-tracked
// position. Reading the live value here keeps the hosted item
// following the shelf in 2D throughout the drag, mirroring how the
// shelf's own entry follows via the layer's effectiveNode override.
const shelf = parentNode as AnyNode & {
position: [number, number, number]
rotation: [number, number, number]
}
const live = useLiveTransforms.getState().get(shelf.id as AnyNodeId)
const shelfX = live?.position[0] ?? shelf.position[0]
const shelfZ = live?.position[2] ?? shelf.position[2]
const shelfRotationY = live?.rotation ?? shelf.rotation[1] ?? 0
const [offsetX, offsetY] = rotateVec(item.position[0], item.position[2], shelfRotationY)
result = {
x: shelfX + offsetX,
y: shelfZ + offsetY,
rotation: shelfRotationY + localRotation,
}
} else {
// Level / slab / ceiling parent — item.position is level-local.
result = {
@@ -116,12 +153,45 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
return [cx + rx, cy + ry] as FloorplanPoint
})
return {
kind: 'polygon',
points,
fill: '#fef3c7',
stroke: '#92400e',
strokeWidth: 0.012,
opacity: 0.85,
const isSelected = ctx.viewState?.selected ?? false
const floorPlanUrl = node.asset.floorPlanUrl
const children: FloorplanGeometry[] = [
{
kind: 'polygon',
points,
// When an asset thumbnail is present, the polygon is a transparent
// hit-target — the image carries the visual weight. Without a
// thumbnail the polygon needs a light fill to read at all.
//
// `transparent` (not `none`) so the interior remains hit-testable —
// the registry layer's wrapping `<g>` only fires onPointerDown when
// the child renders a paintable surface. `fill="none"` would make
// clicks pass through to whatever's beneath, breaking selection.
fill: floorPlanUrl ? 'transparent' : '#fef3c7',
stroke: '#92400e',
strokeWidth: 0.012,
opacity: 0.85,
},
]
// Asset thumbnail — top-down PNG capture from the asset modal. Drawn
// inside the footprint with the item's rotation applied. Matches the
// legacy `FloorplanItemImage` overlay.
if (floorPlanUrl) {
children.push({
kind: 'image',
url: floorPlanUrl,
center: [cx, cy],
width,
height: depth,
rotation: transform.rotation,
})
}
// Move handle — orange dot at the item center. Only when selected.
if (isSelected) {
children.push({
kind: 'move-handle',
point: [cx, cy],
})
}
return { kind: 'group', children }
}
+118
View File
@@ -0,0 +1,118 @@
'use client'
import type { ItemNode } from '@pascal-app/core'
import {
type PlacementState,
triggerSFX,
useDraftNode,
useEditor,
usePlacementCoordinator,
} from '@pascal-app/editor'
import { Vector3 } from 'three'
/**
* Phase 5 Stage D — item's registry-driven 3D move affordance.
*
* Replaces the legacy `MoveItemContent` in `editor/src/components/tools/
* item/move-tool.tsx`. Behaviour is identical: it adopts the moving node
* (or creates a draft for duplicates flagged `isNew`), runs the placement
* coordinator with surface strategies for floor / wall / ceiling / item-
* surface, and commits via `useScene.updateNode` on click.
*
* Registered via `def.affordanceTools.move`. The editor's
* `MoveTool` dispatcher picks this up through `getRegistryAffordance
* Tool('item', 'move')` before its legacy chain reaches `<MoveItemContent>`
* — so the legacy fallback can now go away.
*
* Closes the 2D ↔ 3D coexistence bugs from last session: when both
* paths mounted, the legacy mover's `destroy()` would clobber the 2D
* commit; with this tool owning the move, only one path is alive at a
* time.
*
* Placement primitives (`useDraftNode`, `usePlacementCoordinator`,
* `PlacementState`) are re-exported from `@pascal-app/editor` — same
* hooks the legacy code used. When `ItemTool` (item placement, not
* move) also ports to `def.tool`, the primitives can be inlined here
* and dropped from editor.
*/
function getInitialState(node: ItemNode): PlacementState {
const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
return {
surface: 'wall',
wallId: node.parentId,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
}
}
if (attachTo === 'ceiling') {
return {
surface: 'ceiling',
wallId: null,
ceilingId: node.parentId,
surfaceItemId: null,
shelfId: null,
}
}
return {
surface: 'floor',
wallId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
}
}
export function MoveItemTool({ node }: { node: ItemNode }) {
const draftNode = useDraftNode()
const meta =
typeof node.metadata === 'object' && node.metadata !== null
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const cursor = usePlacementCoordinator({
asset: node.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,
shelfId: null,
}
: getInitialState(node),
// Preserve the original item's scale so Y-position calculations use the correct height.
defaultScale: isNew ? node.scale : undefined,
initDraft: (gridPosition) => {
if (isNew) {
// Duplicate: floor items get a draft immediately; wall/ceiling
// items are created lazily on surface entry.
gridPosition.copy(new Vector3(...node.position))
if (!node.asset.attachTo) {
draftNode.create(gridPosition, node.asset, node.rotation, node.scale)
}
} else {
draftNode.adopt(node)
gridPosition.copy(new Vector3(...node.position))
}
},
onCommitted: () => {
triggerSFX('sfx:item-place')
useEditor.getState().setMovingNode(null)
return false
},
onCancel: () => {
draftNode.destroy()
useEditor.getState().setMovingNode(null)
},
})
return <>{cursor}</>
}
export default MoveItemTool
+328
View File
@@ -0,0 +1,328 @@
'use client'
import { type AnyNode, getScaledDimensions, ItemNode, useScene } from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
CollectionsPopover,
PanelSection,
PanelWrapper,
SliderControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
/**
* Stage E inspector for item. 1:1 port of the legacy
* `editor/components/ui/panels/item-panel.tsx`, relocated into the
* kind's folder so `parametrics.customPanel` mounts it through the
* registry inspector. The catalog popover (`<CollectionsPopover>`) is
* the only kind-specific UI that can't be expressed via the generic
* auto-inspector today — kept inline.
*
* Slider-drag fix recipe applied: scale / position / rotation slider
* `onChange` callbacks read from a `useRef(node)` instead of the
* closure-captured node, which would re-render every panel-driven
* update mid-drag and exceed React's update-depth budget on big scenes
* (see the wiki / plan recipe).
*/
export default 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)
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)
// When an item is mounted on a wall, dirty the wall so the next
// frame regenerates its cutout geometry around the moved item.
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) {
triggerSFX('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node) return
triggerSFX('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
triggerSFX('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={() => {
triggerSFX('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={() => {
triggerSFX('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={
uniformScale
? 'flex h-6 w-6 items-center justify-center rounded-md bg-[#3e3e3e] text-muted-foreground transition-colors hover:text-foreground'
: 'flex h-6 w-6 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground'
}
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>
)
}
+7 -10
View File
@@ -2,17 +2,14 @@ import type { ParametricDescriptor } from '@pascal-app/core'
import type { ItemNode } from './schema'
/**
* Minimal inspector descriptor for item. Items have catalog-driven
* properties (asset.id, asset.dimensions, asset.interactive controls,
* etc.) that don't fit the auto-inspector at Stage A — those are edited
* via the legacy `<ItemPanel>` which renders the catalog-defined
* controls dynamically. Auto-inspector covers only the per-instance
* transform (uniform scale).
*
* Phase 5 Stage E (drop legacy panel) probably uses
* `parametrics.customPanel` to render the catalog-driven controls in
* a registry-aware way.
* Inspector descriptor for item. The fields shape (position / rotation /
* scale sliders, catalog popover, move / duplicate / delete actions)
* can't be expressed via the auto-inspector — they need the kind-owned
* `<ItemPanel>` for layout, the catalog popover, and the move-on-pick
* behaviour. `customPanel` mounts `panel.tsx` through
* `<ParametricInspector>`'s lazy-load slot.
*/
export const itemParametrics: ParametricDescriptor<ItemNode> = {
groups: [],
customPanel: () => import('./panel'),
}
+287 -17
View File
@@ -1,21 +1,291 @@
'use client'
import { ItemRenderer } from '@pascal-app/viewer'
import {
type AnimationEffect,
type AnyNodeId,
type Interactive,
type ItemNode,
type LightEffect,
useInteractive,
useRegistry,
useScene,
} from '@pascal-app/core'
import {
baseMaterial,
ErrorBoundary,
glassMaterial,
NodeRenderer,
resolveCdnUrl,
useItemLightPool,
useNodeEvents,
} from '@pascal-app/viewer'
import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { useFrame } from '@react-three/fiber'
import { Suspense, useEffect, useMemo, useRef } from 'react'
import type { AnimationAction, Group, Material, Mesh } from 'three'
import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu'
const getMaterialForOriginal = (original: Material): Material => {
if (original.name.toLowerCase() === 'glass') {
return glassMaterial
}
return baseMaterial
}
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
const handlers = useNodeEvents(node, 'item')
const [w, h, d] = node.asset.dimensions
return (
<mesh position-y={h / 2} {...handlers}>
<boxGeometry args={[w, h, d]} />
<meshStandardMaterial color="#ef4444" opacity={0.6} transparent wireframe />
</mesh>
)
}
export const ItemRenderer = ({ node }: { node: ItemNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref)
return (
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
<Suspense fallback={<PreviewModel node={node} />}>
<ModelRenderer node={node} />
</Suspense>
</ErrorBoundary>
{node.children?.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
)
}
const previewMaterial = new MeshStandardNodeMaterial({
color: '#cccccc',
roughness: 1,
metalness: 0,
depthTest: false,
})
const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract())
previewMaterial.opacityNode = previewOpacity
previewMaterial.transparent = true
const PreviewModel = ({ node }: { node: ItemNode }) => {
return (
<mesh material={previewMaterial} position-y={node.asset.dimensions[1] / 2}>
<boxGeometry
args={[node.asset.dimensions[0], node.asset.dimensions[1], node.asset.dimensions[2]]}
/>
</mesh>
)
}
const multiplyScales = (
a: [number, number, number],
b: [number, number, number],
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
const ModelRenderer = ({ node }: { node: ItemNode }) => {
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
const ref = useRef<Group>(null!)
const { actions } = useAnimations(animations, ref)
// Freeze the interactive definition at mount — asset schemas don't change at runtime
const interactiveRef = useRef(node.asset.interactive)
if (nodes.cutout) {
nodes.cutout.visible = false
}
const handlers = useNodeEvents(node, 'item')
useEffect(() => {
if (!node.parentId) return
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
}, [node.parentId])
useEffect(() => {
const interactive = interactiveRef.current
if (!interactive) return
useInteractive.getState().initItem(node.id, interactive)
return () => useInteractive.getState().removeItem(node.id)
}, [node.id])
useMemo(() => {
scene.traverse((child) => {
if ((child as Mesh).isMesh) {
const mesh = child as Mesh
if (mesh.name === 'cutout') {
child.visible = false
return
}
let hasGlass = false
// Handle both single material and material array cases
if (Array.isArray(mesh.material)) {
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
hasGlass = mesh.material.some((mat) => mat.name === 'glass')
// Fix geometry groups that reference materialIndex beyond the material
// array length — this causes three-mesh-bvh to crash with
// "Cannot read properties of undefined (reading 'side')"
const matCount = mesh.material.length
if (mesh.geometry.groups.length > 0) {
for (const group of mesh.geometry.groups) {
if (group.materialIndex !== undefined && group.materialIndex >= matCount) {
group.materialIndex = 0
}
}
}
} else {
mesh.material = getMaterialForOriginal(mesh.material)
hasGlass = mesh.material.name === 'glass'
}
mesh.castShadow = !hasGlass
mesh.receiveShadow = !hasGlass
}
})
}, [scene])
const interactive = interactiveRef.current
const animEffect =
interactive?.effects.find((e): e is AnimationEffect => e.kind === 'animation') ?? null
const lightEffects =
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
// useGLTF caches scenes, and Clone shares child geometry/material references.
// Undo can unmount one item while another clone of the same asset still needs them.
return (
<>
<Clone
dispose={null}
object={scene}
position={node.asset.offset}
ref={ref}
rotation={node.asset.rotation}
scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])}
{...handlers}
/>
{animations.length > 0 && (
<ItemAnimation
actions={actions}
animations={animations}
animEffect={animEffect}
interactive={interactive ?? null}
nodeId={node.id}
/>
)}
{lightEffects.map((effect, i) => (
<ItemLightRegistrar
effect={effect}
index={i}
interactive={interactive!}
key={i}
nodeId={node.id}
/>
))}
</>
)
}
const ItemAnimation = ({
nodeId,
animEffect,
interactive,
actions,
animations,
}: {
nodeId: AnyNodeId
animEffect: AnimationEffect | null
interactive: Interactive | null
actions: Record<string, AnimationAction | null>
animations: { name: string }[]
}) => {
const activeClipRef = useRef<string | null>(null)
const fadingOutRef = useRef<AnimationAction | null>(null)
// Reactive: derive target clip name — only re-renders when the clip name itself changes
const targetClip = useInteractive((s) => {
const values = s.items[nodeId]?.controlValues
if (!animEffect) return animations[0]?.name ?? null
const toggleIndex = interactive!.controls.findIndex((c) => c.kind === 'toggle')
const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex]) : false
return isOn
? (animEffect.clips.on ?? null)
: (animEffect.clips.off ?? animEffect.clips.loop ?? null)
})
// When target clip changes: kick off the transition
useEffect(() => {
// Cancel any ongoing fade-out immediately
if (fadingOutRef.current) {
fadingOutRef.current.timeScale = 0
fadingOutRef.current = null
}
// Move current clip to fade-out
if (activeClipRef.current && activeClipRef.current !== targetClip) {
const old = actions[activeClipRef.current]
if (old?.isRunning()) fadingOutRef.current = old
}
// Start new clip at timeScale 0.01 (as 0 would cause isRunning to be false and thus not play at all), then fade in to 1
activeClipRef.current = targetClip
if (targetClip) {
const next = actions[targetClip]
if (next) {
next.timeScale = 0.01
next.play()
}
}
}, [targetClip, actions])
// useFrame: only lerping — no logic
useFrame((_, delta) => {
if (fadingOutRef.current) {
const action = fadingOutRef.current
action.timeScale = MathUtils.lerp(action.timeScale, 0, Math.min(delta * 5, 1))
if (action.timeScale < 0.01) {
action.timeScale = 0
fadingOutRef.current = null
}
}
if (activeClipRef.current) {
const action = actions[activeClipRef.current]
if (action?.isRunning() && action.timeScale < 1) {
action.timeScale = MathUtils.lerp(action.timeScale, 1, Math.min(delta * 5, 1))
if (1 - action.timeScale < 0.01) action.timeScale = 1
}
}
})
return null
}
const ItemLightRegistrar = ({
nodeId,
effect,
interactive,
index,
}: {
nodeId: AnyNodeId
effect: LightEffect
interactive: Interactive
index: number
}) => {
useEffect(() => {
const key = `${nodeId}:${index}`
useItemLightPool.getState().register(key, nodeId, effect, interactive)
return () => useItemLightPool.getState().unregister(key)
}, [nodeId, index, effect, interactive])
return null
}
/**
* Wrap-export of the legacy `ItemRenderer`.
*
* Item's renderer is ~280 lines using `useGLTF` from `@react-three/drei`
* to load GLB assets from the CDN. It also handles asset-loaded
* `interactive` widgets (clickable hot-spots, sliders inside the
* scene), surface mounting, attachment offsets — too much code to
* duplicate at Stage A. Phase 5 Stage F (cleanup) moves it into this
* folder if useful, or leaves it in viewer with the public re-export.
*
* Item is also the first kind to demonstrate the "custom def.renderer"
* escape hatch documented in plans/editor-node-registry.md — kinds with
* GLB loaders, drei helpers, `useGLTF`, etc., set `def.renderer` to a
* full React component rather than trying to express geometry as a
* pure builder.
*/
export default ItemRenderer
-3
View File
@@ -9,9 +9,6 @@ import { ItemLightSystem, ItemSystem } from '@pascal-app/viewer'
* (wall-side z-offset, slab elevation, ceiling mounting).
* - **`ItemLightSystem`** — manages light sources attached to items
* (lamps, ceiling lights, etc.).
*
* Both are wrapped in `<LegacySystem kind="item">` legacy mounts; with
* item registered, those short-circuit and this bundle takes over.
*/
const ItemSystems = () => {
return (
+54
View File
@@ -0,0 +1,54 @@
'use client'
import type { AssetInput } from '@pascal-app/core'
import { triggerSFX, useDraftNode, useEditor, usePlacementCoordinator } from '@pascal-app/editor'
/**
* Registry-driven item placement tool. Mounted by `ToolManager` when
* `useEditor.tool === 'item'` (the catalog picker is what selects which
* asset; this tool handles the cursor follow + click-to-commit flow).
*
* Wraps the same `usePlacementCoordinator` + `useDraftNode` primitives
* the move-tool uses. The placement coordinator runs surface strategies
* (floor / wall / ceiling / item-surface) so the same cursor logic
* handles wall-mounted artwork, floor furniture, ceiling fans, and
* nested items on tables.
*
* Replaces the legacy `editor/src/components/tools/item/item-tool.tsx`.
* The `tools` map in `tool-manager.tsx` no longer needs an `item:` entry
* — `getRegistryTool('item')` finds this through `def.tool`.
*/
function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) {
const draftNode = useDraftNode()
const cursor = usePlacementCoordinator({
asset: selectedItem,
draftNode,
initDraft: (gridPosition) => {
// Only floor items get a draft on mount; wall / ceiling items are
// created lazily by the placement coordinator when the cursor
// enters a surface (so the draft doesn't appear at world origin
// before the first move event).
if (selectedItem && !selectedItem.attachTo) {
draftNode.create(gridPosition, selectedItem)
}
},
onCommitted: () => {
triggerSFX('sfx:item-place')
// Returning `true` tells the coordinator to immediately spawn the
// next draft so the user can keep placing copies — matches the
// "repeat-on-click" UX of the legacy tool.
return true
},
})
return <>{cursor}</>
}
function ItemTool() {
const selectedItem = useEditor((state) => state.selectedItem)
if (!selectedItem) return null
return <ItemPlacementContent selectedItem={selectedItem} />
}
export default ItemTool
+57
View File
@@ -0,0 +1,57 @@
import { LevelNode as LevelNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { levelParametrics } from './parametrics'
import { LevelNode } from './schema'
/**
* Level — Stage A. Container for walls / slabs / ceilings / etc. on
* a single floor. `LevelSystem` does level-wide work (Y-position
* snapping to true positions when levels reorder); wrap-exported.
*/
export const levelDefinition: NodeDefinition<typeof LevelNode> = {
kind: 'level',
schemaVersion: 1,
schema: LevelNode,
category: 'site',
defaults: () => {
const stub = LevelNodeSchema.parse({ id: 'level_default' as never, type: 'level' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
// Level is a container — selection happens via the sidebar tree
// and the floating level switcher, never via 3D click. Declaring
// `selectable` here would make `getSelectableKinds()` add the
// kind to `SelectionManager`'s subscription list, and the event
// would fire on every wall/slab/ceiling click that bubbled to the
// level group — selecting the level instead of the actual node
// hit. Legacy `allTypes` deliberately omitted containers; we
// mirror that.
duplicable: false,
deletable: true,
},
parametrics: levelParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 1,
},
presentation: {
label: 'Level',
description: 'A single floor of a building, holding walls / slabs / ceilings / items.',
icon: { kind: 'url', src: '/icons/level.png' },
paletteSection: 'site',
paletteOrder: 7,
},
mcp: {
description: 'A level (floor) container under a building.',
},
}
+1
View File
@@ -0,0 +1 @@
export { levelDefinition } from './definition'
+5
View File
@@ -0,0 +1,5 @@
import type { LevelNode, ParametricDescriptor } from '@pascal-app/core'
export const levelParametrics: ParametricDescriptor<LevelNode> = {
groups: [],
}
+23
View File
@@ -0,0 +1,23 @@
'use client'
import { type LevelNode, useRegistry } from '@pascal-app/core'
import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer'
import { useRef } from 'react'
import type { Group } from 'three'
export const LevelRenderer = ({ node }: { node: LevelNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref)
const handlers = useNodeEvents(node, 'level')
return (
<group ref={ref} {...handlers}>
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
)
}
export default LevelRenderer
+1
View File
@@ -0,0 +1 @@
export { LevelNode } from '@pascal-app/core'
+5
View File
@@ -0,0 +1,5 @@
'use client'
import { LevelSystem } from '@pascal-app/viewer'
export default LevelSystem
@@ -0,0 +1,52 @@
import { type NodeDefinition, RoofSegmentNode as RoofSegmentNodeSchema } from '@pascal-app/core'
import { buildRoofSegmentFloorplan } from './floorplan'
import { roofSegmentParametrics } from './parametrics'
import { RoofSegmentNode } from './schema'
/**
* Roof segment — Stage A. Child of a roof node, owns the per-segment
* polygon + pitch. Geometry is generated by `RoofSystem` (registered
* under the parent roof's `def.system`), so the segment kind itself
* only needs a renderer wrap.
*/
export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
kind: 'roof-segment',
schemaVersion: 1,
schema: RoofSegmentNode,
category: 'structure',
defaults: () => {
const stub = RoofSegmentNodeSchema.parse({
id: 'roof-segment_default' as never,
type: 'roof-segment',
})
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: false,
deletable: true,
},
parametrics: roofSegmentParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
floorplan: buildRoofSegmentFloorplan,
presentation: {
label: 'Roof Segment',
description: 'A single pitched plane of a parent roof.',
icon: { kind: 'url', src: '/icons/roof.png' },
paletteSection: 'structure',
paletteOrder: 101,
},
mcp: {
description: 'A single roof segment with polygon footprint + pitch.',
},
}
@@ -0,0 +1,106 @@
import type {
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
RoofNode,
RoofSegmentNode,
} from '@pascal-app/core'
/**
* Stage C floor-plan builder for roof segment. Renders the segment's
* footprint as a rotated rectangle in world coords (parent roof's
* position + rotation composed with the segment's own).
*
* Inlined from `getRoofSegmentPolygon` / `getRoofSegmentCenter` in
* `floorplan-panel.tsx`. Ridge line not yet rendered — adds a follow-up
* for full visual parity.
*/
export function buildRoofSegmentFloorplan(
node: RoofSegmentNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const roof = ctx.parent as RoofNode | null
if (!roof || roof.type !== 'roof') return null
// Segment center in world coords: parent roof's transform applied to
// the segment's local position offset.
const cosRoof = Math.cos(roof.rotation)
const sinRoof = Math.sin(roof.rotation)
const localX = node.position[0]
const localZ = node.position[2]
const cx = roof.position[0] + localX * cosRoof - localZ * sinRoof
const cz = roof.position[2] + localX * sinRoof + localZ * cosRoof
const rotation = roof.rotation + node.rotation
const cos = Math.cos(rotation)
const sin = Math.sin(rotation)
const halfWidth = node.width / 2
const halfDepth = node.depth / 2
const corners: Array<[number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
]
const points: FloorplanPoint[] = corners.map(([x, y]) => [
cx + x * cos - y * sin,
cz + x * sin + y * cos,
])
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
const stroke =
showSelectedChrome && palette ? palette.selectedStroke : 'rgba(125, 211, 252, 0.82)'
const fill = showSelectedChrome ? '#fed7aa' : 'rgba(56, 189, 248, 0.16)'
const children: FloorplanGeometry[] = [
{
kind: 'polygon',
points,
fill,
stroke,
strokeWidth: showSelectedChrome ? 0.04 : 0.025,
strokeLinejoin: 'round',
opacity: 0.85,
},
]
// Ridge line — only for pitched segments, not flat roofs.
if (node.roofType !== 'flat') {
const ridgeAxis =
node.roofType === 'gable' || node.roofType === 'gambrel'
? 'x'
: node.roofType === 'dutch'
? node.width >= node.depth
? 'x'
: 'z'
: 'z'
const axisAngle = ridgeAxis === 'x' ? rotation : rotation + Math.PI / 2
const halfSpan = ridgeAxis === 'x' ? node.width / 2 : node.depth / 2
children.push({
kind: 'line',
x1: cx - halfSpan * Math.cos(axisAngle),
y1: cz - halfSpan * Math.sin(axisAngle),
x2: cx + halfSpan * Math.cos(axisAngle),
y2: cz + halfSpan * Math.sin(axisAngle),
stroke: showSelectedChrome ? '#eff6ff' : 'rgba(186, 230, 253, 0.84)',
strokeWidth: 1.4,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
}
if (isSelected) {
children.push({
kind: 'move-handle',
point: [cx, cz],
})
}
return { kind: 'group', children }
}
+1
View File
@@ -0,0 +1 @@
export { roofSegmentDefinition } from './definition'
+314
View File
@@ -0,0 +1,314 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofType,
useScene,
} from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
PanelSection,
PanelWrapper,
SegmentedControl,
SliderControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
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 default 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
triggerSFX('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) {
triggerSFX('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
triggerSFX('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={() => {
triggerSFX('sfx:item-rotate')
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
triggerSFX('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>
)
}
@@ -0,0 +1,6 @@
import type { ParametricDescriptor, RoofSegmentNode } from '@pascal-app/core'
export const roofSegmentParametrics: ParametricDescriptor<RoofSegmentNode> = {
groups: [],
customPanel: () => import('./panel'),
}
@@ -0,0 +1,65 @@
'use client'
import {
type AnyNodeId,
type RoofNode,
type RoofSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { getRoofMaterialArray, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!)
const nodes = useScene((state) => state.nodes)
useRegistry(node.id, 'roof-segment', ref)
const handlers = useNodeEvents(node, 'roof-segment')
const debugColors = useViewer((s) => s.debugColors)
const parentNode = node.parentId
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
: undefined
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
geometry.addGroup(0, 0, 2)
geometry.addGroup(0, 0, 3)
return geometry
}, [])
const customMaterial = useMemo(() => {
if (node.material !== undefined || typeof node.materialPreset === 'string') {
return null
}
return parentNode ? getRoofMaterialArray(parentNode) : null
}, [node, parentNode])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return (
<mesh
geometry={placeholderGeometry}
material={material}
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
/>
)
}
export default RoofSegmentRenderer
@@ -0,0 +1 @@
export { RoofSegmentNode } from '@pascal-app/core'
+54
View File
@@ -0,0 +1,54 @@
import { type NodeDefinition, RoofNode as RoofNodeSchema } from '@pascal-app/core'
import { roofParametrics } from './parametrics'
import { RoofNode } from './schema'
/**
* Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer`
* + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` +
* CSG). Inspector / move / floorplan stay legacy until Stage B-E.
*
* Roof is a "composite" node — it has `roof-segment` children that
* own per-segment geometry. The parent roof handles overall framing;
* each segment is its own registered kind (see `roof-segment`).
*/
export const roofDefinition: NodeDefinition<typeof RoofNode> = {
kind: 'roof',
schemaVersion: 1,
schema: RoofNode,
category: 'structure',
defaults: () => {
const stub = RoofNodeSchema.parse({ id: 'roof_default' as never, type: 'roof' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: roofParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 3,
},
presentation: {
label: 'Roof',
description: 'A pitched / hip / gable roof composed of one or more segments.',
icon: { kind: 'url', src: '/icons/roof.png' },
paletteSection: 'structure',
paletteOrder: 100,
},
mcp: {
description: 'A roof composed of segmented planes (gable / hip / shed).',
},
}
+1
View File
@@ -0,0 +1 @@
export { roofDefinition } from './definition'
+281
View File
@@ -0,0 +1,281 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
type MaterialSchema,
type RoofNode,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
useScene,
} from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
buildRoofSurfaceMaterialPatch,
duplicateRoofSubtree,
MaterialPicker,
PanelSection,
PanelWrapper,
SliderControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
export default 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
triggerSFX('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) {
triggerSFX('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
triggerSFX('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={() => {
triggerSFX('sfx:item-rotate')
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
triggerSFX('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>
)
}
+6
View File
@@ -0,0 +1,6 @@
import type { ParametricDescriptor, RoofNode } from '@pascal-app/core'
export const roofParametrics: ParametricDescriptor<RoofNode> = {
groups: [],
customPanel: () => import('./panel'),
}
+60
View File
@@ -0,0 +1,60 @@
'use client'
import { type RoofNode, useRegistry } from '@pascal-app/core'
import { getRoofMaterialArray, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { roofDebugMaterials, roofMaterials } from './roof-materials'
export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(node.id, 'roof', ref)
const handlers = useNodeEvents(node, 'roof')
const debugColors = useViewer((s) => s.debugColors)
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
geometry.addGroup(0, 0, 2)
geometry.addGroup(0, 0, 3)
return geometry
}, [])
const customMaterial = useMemo(() => getRoofMaterialArray(node), [node])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return (
<group
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
>
<mesh
castShadow
geometry={placeholderGeometry}
material={material}
name="merged-roof"
receiveShadow
/>
<group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
</group>
)
}
export default RoofRenderer
+18
View File
@@ -0,0 +1,18 @@
import * as THREE from 'three'
// Production materials — match the rest of the scene (white walls, light-gray slabs).
// Indices: 0 = Wall/Trim, 1 = Deck, 2 = Interior, 3 = Shingle
export const roofMaterials: THREE.Material[] = [
new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 0: Wall/Trim
new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 1, side: THREE.FrontSide }), // 1: Deck
new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 2: Interior
new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle
]
// Debug materials — vivid, distinct colours to identify each surface group.
export const roofDebugMaterials: THREE.Material[] = [
new THREE.MeshStandardMaterial({ color: '#eaeaea', roughness: 0.8, side: THREE.DoubleSide }), // 0: Wall
new THREE.MeshStandardMaterial({ color: '#000000', roughness: 0.9, side: THREE.FrontSide }), // 1: Deck
new THREE.MeshStandardMaterial({ color: '#dddddd', roughness: 0.9, side: THREE.DoubleSide }), // 2: Interior
new THREE.MeshStandardMaterial({ color: '#4ade80', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle
]
+1
View File
@@ -0,0 +1 @@
export { RoofNode } from '@pascal-app/core'
+5
View File
@@ -0,0 +1,5 @@
'use client'
import { RoofSystem } from '@pascal-app/viewer'
export default RoofSystem
+50
View File
@@ -0,0 +1,50 @@
import { type NodeDefinition, ScanNode as ScanNodeSchema } from '@pascal-app/core'
import { scanParametrics } from './parametrics'
import { ScanNode } from './schema'
/**
* Scan — Stage A. Mesh imported from the capture pipeline (LiDAR /
* photogrammetry). `ScanSystem` handles mesh loading + per-frame
* positioning; renderer mounts the imported geometry.
*/
export const scanDefinition: NodeDefinition<typeof ScanNode> = {
kind: 'scan',
schemaVersion: 1,
schema: ScanNode,
category: 'site',
defaults: () => {
const stub = ScanNodeSchema.parse({ id: 'scan_default' as never, type: 'scan' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: false,
deletable: true,
},
parametrics: scanParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 1,
},
presentation: {
label: 'Scan',
description: 'A captured mesh (LiDAR / photogrammetry) imported as a scene reference.',
icon: { kind: 'url', src: '/icons/mesh.png' },
paletteSection: 'site',
paletteOrder: 40,
},
mcp: {
description: 'A captured mesh import.',
},
}
+1
View File
@@ -0,0 +1 @@
export { scanDefinition } from './definition'
+5
View File
@@ -0,0 +1,5 @@
import type { ParametricDescriptor, ScanNode } from '@pascal-app/core'
export const scanParametrics: ParametricDescriptor<ScanNode> = {
groups: [],
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import { type ScanNode, useRegistry } from '@pascal-app/core'
import { useAssetUrl, useGLTFKTX2, useViewer } from '@pascal-app/viewer'
import { Suspense, useMemo, useRef } from 'react'
import type { Group, Material, Mesh } from 'three'
export const ScanRenderer = ({ node }: { node: ScanNode }) => {
const showScans = useViewer((s) => s.showScans)
const ref = useRef<Group>(null!)
useRegistry(node.id, 'scan', ref)
const resolvedUrl = useAssetUrl(node.url)
return (
<group
position={node.position}
ref={ref}
rotation={node.rotation}
scale={[node.scale, node.scale, node.scale]}
visible={showScans}
>
{resolvedUrl && (
<Suspense>
<ScanModel opacity={node.opacity} url={resolvedUrl} />
</Suspense>
)}
</group>
)
}
const ScanModel = ({ url, opacity }: { url: string; opacity: number }) => {
const gltf = useGLTFKTX2(url) as any
const scene = gltf.scene
useMemo(() => {
const normalizedOpacity = opacity / 100
const isTransparent = normalizedOpacity < 1
const updateMaterial = (material: Material) => {
if (isTransparent) {
material.transparent = true
material.opacity = normalizedOpacity
material.depthWrite = false
} else {
material.transparent = false
material.opacity = 1
material.depthWrite = true
}
material.needsUpdate = true
}
scene.traverse((child: any) => {
if ((child as Mesh).isMesh) {
const mesh = child as Mesh
// Disable raycasting
mesh.raycast = () => {}
// Exclude from bounding box calculations
mesh.geometry.boundingBox = null
mesh.geometry.boundingSphere = null
mesh.frustumCulled = false
if (Array.isArray(mesh.material)) {
mesh.material.forEach((material) => {
updateMaterial(material)
})
} else {
updateMaterial(mesh.material)
}
}
})
}, [scene, opacity])
return <primitive object={scene} />
}
export default ScanRenderer
+1
View File
@@ -0,0 +1 @@
export { ScanNode } from '@pascal-app/core'
+5
View File
@@ -0,0 +1,5 @@
'use client'
import { ScanSystem } from '@pascal-app/viewer'
export default ScanSystem
@@ -0,0 +1,172 @@
import {
type AnyNode,
type AnyNodeId,
type DoorNode,
type FloorplanGeometry,
type GeometryContext,
isCurvedWall,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
/**
* Build placement-measurement dimension lines for a door / window
* being moved on a wall. Mirrors the legacy
* `movingOpeningPlacementMeasurements` in `floorplan-panel.tsx`:
*
* - Find the previous opening on the same wall (or the wall start
* if none) → distance from its right face to this opening's
* left face.
* - Find the next opening (or wall end) → distance from this
* opening's right face to its left face.
* - Each renders as a `dimension` primitive offset to the wall's
* outer face so the labels don't overlap the wall body.
*
* Returns an empty array if the parent isn't a wall, the wall is
* curved, or the opening is at wall length 0 (invalid).
*/
export function buildOpeningPlacementDimensions(
opening: DoorNode | WindowNode,
ctx: GeometryContext,
): FloorplanGeometry[] {
const wall = ctx.parent as WallNode | null
if (!wall || wall.type !== 'wall') return []
if (isCurvedWall(wall)) return []
const [x1, z1] = wall.start
const [x2, z2] = wall.end
const dx = x2 - x1
const dz = z2 - z1
const wallLength = Math.hypot(dx, dz)
if (wallLength < 1e-6) return []
const dirX = dx / wallLength
const dirZ = dz / wallLength
// Outward normal — chosen by the wall builder via the level
// centroid. We replicate that decision here so the dimension lines
// land on the same face. Walk wall's siblings (the level's other
// walls) via ctx.resolve to compute the centroid.
const outwardNormal = computeOutwardNormal(wall, ctx, dirX, dirZ)
const halfWidth = opening.width / 2
const startDist = opening.position[0] - halfWidth
const endDist = opening.position[0] + halfWidth
// Walk wall.children to find adjacent openings (door OR window).
// ctx.siblings only includes same-kind nodes; doors + windows need
// each other so we go via the parent's children directly.
const childIds = ((wall as unknown as { children?: AnyNodeId[] }).children ?? []) as AnyNodeId[]
let leftBoundary: number | null = null
let rightBoundary: number | null = null
for (const childId of childIds) {
if (childId === opening.id) continue
const sibling = ctx.resolve(childId) as AnyNode | undefined
if (!sibling || (sibling.type !== 'door' && sibling.type !== 'window')) continue
const sib = sibling as DoorNode | WindowNode
const sibStart = sib.position[0] - sib.width / 2
const sibEnd = sib.position[0] + sib.width / 2
if (sibEnd <= startDist && (leftBoundary === null || sibEnd > leftBoundary)) {
leftBoundary = sibEnd
}
if (sibStart >= endDist && (rightBoundary === null || sibStart < rightBoundary)) {
rightBoundary = sibStart
}
}
const leftFromDist = leftBoundary ?? 0
const rightToDist = rightBoundary ?? wallLength
// Place the dimension line at a constant offset from the wall's
// outer face — same value the legacy uses for its placement
// measurements (`FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET`). The
// dimension's `start` / `end` are points on that outer face (not
// the wall centerline), so the extension lines stay short and the
// overall layout matches the legacy treatment 1:1.
const wallThickness = wall.thickness ?? 0.1
const halfThickness = wallThickness / 2
const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
// Project a point on the wall axis at distance `along` onto the
// wall's outer face by adding `halfThickness * outwardNormal`.
const facePoint = (along: number): readonly [number, number] => [
x1 + dirX * along + outwardNormal[0] * halfThickness,
z1 + dirZ * along + outwardNormal[1] * halfThickness,
]
const out: FloorplanGeometry[] = []
const leftDistance = startDist - leftFromDist
if (leftDistance >= 0.01) {
out.push({
kind: 'dimension',
start: facePoint(leftFromDist),
end: facePoint(startDist),
offsetNormal: outwardNormal,
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
extensionOvershoot: 0.12,
text: `${Number.parseFloat(leftDistance.toFixed(2))}m`,
stroke: '#f97316',
})
}
const rightDistance = rightToDist - endDist
if (rightDistance >= 0.01) {
out.push({
kind: 'dimension',
start: facePoint(endDist),
end: facePoint(rightToDist),
offsetNormal: outwardNormal,
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
extensionOvershoot: 0.12,
text: `${Number.parseFloat(rightDistance.toFixed(2))}m`,
stroke: '#f97316',
})
}
return out
}
/**
* Choose the perpendicular wall normal that points away from the
* other walls' centroid — same logic the wall builder uses to place
* its own dimension overlay so left / right placement dimensions land
* on the same face the wall label is on.
*/
function computeOutwardNormal(
wall: WallNode,
ctx: GeometryContext,
dirX: number,
dirZ: number,
): readonly [number, number] {
const nx = -dirZ
const nz = dirX
// Find the level by walking up via wall.parentId.
const level = wall.parentId
? (ctx.resolve(wall.parentId as AnyNodeId) as AnyNode | undefined)
: null
const levelChildren = ((level as unknown as { children?: AnyNodeId[] })?.children ??
[]) as AnyNodeId[]
let sumX = 0
let sumZ = 0
let count = 0
for (const childId of levelChildren) {
const child = ctx.resolve(childId) as AnyNode | undefined
if (!child || child.type !== 'wall') continue
const w = child as WallNode
sumX += w.start[0] + w.end[0]
sumZ += w.start[1] + w.end[1]
count += 2
}
if (count === 0) return [nx, nz]
const centroidX = sumX / count
const centroidZ = sumZ / count
const wallMidX = (wall.start[0] + wall.end[0]) / 2
const wallMidZ = (wall.start[1] + wall.end[1]) / 2
const fromCentroidX = wallMidX - centroidX
const fromCentroidZ = wallMidZ - centroidZ
const facingAway = fromCentroidX * nx + fromCentroidZ * nz >= 0 ? 1 : -1
return [nx * facingAway, nz * facingAway]
}
@@ -0,0 +1,285 @@
import {
type AnyNodeId,
type FloorplanAffordance,
type FloorplanAffordanceSession,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
/**
* Shared "edit polygon" floor-plan affordances. Used by kinds whose
* primary editable shape is a `polygon: [number, number][]` field
* (slab, ceiling, site, zone) with optional `holes: [number, number][][]`.
*
* Each affordance accepts an optional `holeIndex` in its payload — when
* present, the operation targets `node.holes[holeIndex]`; otherwise it
* targets the outer `node.polygon`. The same factory wires both
* boundary and hole interactions without duplicating the math.
*
* Three affordances available:
*
* - `move-vertex` — drag an existing vertex.
* - `add-vertex` — insert a new vertex at an edge midpoint, then drag
* it (click-without-drag reverts to the snapshot).
* - `move-edge` — drag a whole edge perpendicular to itself (both
* endpoints translate by `normal * projection`).
*/
export type PolygonVertexPayload = {
/** Target a hole's polygon instead of the boundary. */
holeIndex?: number
vertexIndex: number
}
export type AddVertexPayload = {
holeIndex?: number
edgeIndex: number
}
export type EdgeDragPayload = {
holeIndex?: number
edgeIndex: number
}
type PolygonShape = {
polygon: ReadonlyArray<readonly [number, number]>
holes?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>
}
function getRing(node: PolygonShape, holeIndex: number | undefined): [number, number][] | null {
if (holeIndex === undefined) {
return node.polygon.map(([x, y]) => [x, y] as [number, number])
}
const hole = node.holes?.[holeIndex]
if (!hole) return null
return hole.map(([x, y]) => [x, y] as [number, number])
}
/**
* Returns a patch object that, when applied to the node, updates the
* targeted ring (boundary polygon or specific hole) to `nextRing`. The
* cast through `unknown → Partial<unknown> → never` satisfies the
* generic `updateNodes` patch type without forcing every variant of
* the kind union into scope here.
*/
function buildRingPatch(
node: PolygonShape,
holeIndex: number | undefined,
nextRing: ReadonlyArray<[number, number]>,
): unknown {
if (holeIndex === undefined) {
return { polygon: nextRing }
}
const nextHoles = (node.holes ?? []).map((hole, i) =>
i === holeIndex ? nextRing : hole.map(([x, y]) => [x, y] as [number, number]),
)
return { holes: nextHoles }
}
export function createPolygonVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string,
): FloorplanAffordance<N> {
return {
start({ node, payload }): FloorplanAffordanceSession {
const { vertexIndex, holeIndex } = payload as PolygonVertexPayload
const originalRing = getRing(node, holeIndex)
if (!originalRing) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? (planPoint as WallPlanPoint)
: snapPointToGrid(planPoint as WallPlanPoint)
const nextRing: [number, number][] = originalRing.map((p, i) =>
i === vertexIndex ? [snapped[0], snapped[1]] : p,
)
const patch = buildRingPatch(node, holeIndex, nextRing)
useScene
.getState()
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
},
canCommit() {
const final = useScene.getState().nodes[node.id] as N | undefined
if (!final || (final as unknown as { type: string }).type !== kind) return false
const finalRing = holeIndex === undefined ? final.polygon : (final.holes ?? [])[holeIndex]
return !!finalRing && finalRing.length >= 3
},
}
},
}
}
/**
* Companion to `createPolygonVertexAffordance`. Inserts a new vertex at
* the midpoint of edge `edgeIndex` (between vertices i and i+1) and
* then drags that new vertex with the pointer. The dispatcher's
* snapshot was taken **before** `start()` ran, so a pointer-up without
* movement reverts to the pre-insert ring — "click without drag" is a
* no-op, matching the legacy slab boundary editor.
*/
export function createPolygonAddVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string,
): FloorplanAffordance<N> {
return {
start({ node, payload }): FloorplanAffordanceSession {
const { edgeIndex, holeIndex } = payload as AddVertexPayload
const originalRing = getRing(node, holeIndex)
if (!originalRing) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
const a = originalRing[edgeIndex]
const b = originalRing[(edgeIndex + 1) % originalRing.length]
if (!a || !b) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
const midpoint: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
const newVertexIndex = edgeIndex + 1
const initialRing: [number, number][] = [
...originalRing.slice(0, newVertexIndex),
midpoint,
...originalRing.slice(newVertexIndex),
]
// Apply the insert immediately so the user sees the new vertex
// before they even move.
const initialPatch = buildRingPatch(node, holeIndex, initialRing)
useScene
.getState()
.updateNodes([{ id: node.id, data: initialPatch as Partial<unknown> as never }])
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? (planPoint as WallPlanPoint)
: snapPointToGrid(planPoint as WallPlanPoint)
const nextRing: [number, number][] = initialRing.map((p, i) =>
i === newVertexIndex ? [snapped[0], snapped[1]] : p,
)
const patch = buildRingPatch(node, holeIndex, nextRing)
useScene
.getState()
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
},
canCommit() {
const final = useScene.getState().nodes[node.id] as N | undefined
if (!final || (final as unknown as { type: string }).type !== kind) return false
const finalRing = holeIndex === undefined ? final.polygon : (final.holes ?? [])[holeIndex]
return !!finalRing && finalRing.length >= 3
},
}
},
}
}
/**
* Edge-drag: move a whole edge perpendicular to itself. Both endpoints
* translate by `edgeNormal * projectedDelta`. The other vertices of
* the ring stay put — adjacent edges effectively pivot around their
* far endpoints.
*
* Snap is grid-aligned on the projected scalar (so a Shift-free drag
* lands on grid lines along the edge normal).
*/
export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string,
): FloorplanAffordance<N> {
return {
start({ node, payload, initialPlanPoint }): FloorplanAffordanceSession {
const { edgeIndex, holeIndex } = payload as EdgeDragPayload
const originalRing = getRing(node, holeIndex)
if (!originalRing) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
const startVertex = originalRing[edgeIndex]
const endVertex = originalRing[(edgeIndex + 1) % originalRing.length]
if (!startVertex || !endVertex) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
const dx = endVertex[0] - startVertex[0]
const dy = endVertex[1] - startVertex[1]
const len = Math.hypot(dx, dy)
if (len < 1e-6) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
// Perpendicular unit normal (rotate 90° CCW).
const normalX = -dy / len
const normalY = dx / len
const startX = initialPlanPoint[0]
const startY = initialPlanPoint[1]
const edgeStartIndex = edgeIndex
const edgeEndIndex = (edgeIndex + 1) % originalRing.length
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
// Project the pointer delta onto the edge normal — that's the
// signed perpendicular distance the edge should travel.
const deltaX = planPoint[0] - startX
const deltaY = planPoint[1] - startY
let projection = deltaX * normalX + deltaY * normalY
if (!modifiers.shiftKey) {
// Snap the projection scalar to a 0.5m grid (legacy uses the
// same half-meter snap for slab edges).
projection = Math.round(projection * 2) / 2
}
const nextRing: [number, number][] = originalRing.map((p, i) => {
if (i === edgeStartIndex || i === edgeEndIndex) {
return [p[0] + normalX * projection, p[1] + normalY * projection]
}
return [p[0], p[1]] as [number, number]
})
const patch = buildRingPatch(node, holeIndex, nextRing)
useScene
.getState()
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
},
canCommit() {
const final = useScene.getState().nodes[node.id] as N | undefined
if (!final || (final as unknown as { type: string }).type !== kind) return false
const finalRing = holeIndex === undefined ? final.polygon : (final.holes ?? [])[holeIndex]
return !!finalRing && finalRing.length >= 3
},
}
},
}
}
@@ -0,0 +1,129 @@
import { type AnyNode, type AnyNodeId, isCurvedWall, type WallNode } from '@pascal-app/core'
/**
* Shared helpers for the kinds whose 2D move snaps onto a wall in plan
* space (door, window, item with `attachTo === 'wall' | 'wall-side'`).
*
* The 3D move tools listen to R3F `WallEvent`s (mesh-hit with normal)
* for wall snapping. The 2D path doesn't have that — pointer events
* land on the SVG layer, not on the wall meshes. This helper does the
* equivalent plan-space projection: for each wall on the level, find
* the perpendicular projection of the pointer onto the wall line and
* pick the closest one within a reasonable range.
*
* Curved walls are excluded — the legacy door / window placement also
* rejects curved walls (mitering + arc + opening would tear in 3D).
*/
const WALL_SNAP_DISTANCE_M = 1.5
export type WallHit = {
wall: WallNode
/** Distance along the wall from `start` (clamped to [0, length]). */
localX: number
/** Signed perpendicular distance from the wall axis (+ on the "front" side). */
perpDistance: number
/** Which face of the wall the pointer was on. */
side: 'front' | 'back'
/** Wall direction unit vector, x. */
dirX: number
/** Wall direction unit vector, y (== z in plan). */
dirY: number
/** Wall length in metres. */
wallLength: number
/**
* Rotation around Y in **wall-local** space — 0 for the front face,
* π for the back. Matches the 3D `calculateItemRotation(normal)`
* convention (normal +Z → 0, normal -Z → π). Items / doors / windows
* are children of the wall mesh, so their `rotation.y` is in the
* wall's local frame; writing a world-space rotation here would mis-
* orient the node by `wallRotation` (off by 90° on vertical walls).
*/
itemRotation: number
}
/**
* Walk every wall under `parentLevelId` and return the closest one to
* `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`.
* `excludeWallId` skips a specific wall (e.g. the current parent during
* a re-parent flow if you want a "must change" guard).
*/
export function findClosestWallInPlan(
planPoint: readonly [number, number],
nodes: Record<AnyNodeId, AnyNode>,
parentLevelId: AnyNodeId | null,
excludeWallId?: AnyNodeId,
): WallHit | null {
if (!parentLevelId) return null
const level = nodes[parentLevelId]
const childIds = (level as unknown as { children?: AnyNodeId[] })?.children
if (!Array.isArray(childIds)) return null
let best: WallHit | null = null
for (const childId of childIds) {
const node = nodes[childId]
if (!node || node.type !== 'wall') continue
if (childId === excludeWallId) continue
const wall = node as WallNode
if (isCurvedWall(wall)) continue
const sx = wall.start[0]
const sy = wall.start[1]
const dx = wall.end[0] - sx
const dy = wall.end[1] - sy
const wallLength = Math.hypot(dx, dy)
if (wallLength < 1e-6) continue
const dirX = dx / wallLength
const dirY = dy / wallLength
// Project pointer onto wall axis.
const px = planPoint[0] - sx
const py = planPoint[1] - sy
const along = px * dirX + py * dirY
const perpRaw = px * -dirY + py * dirX // signed perpendicular distance
const clampedAlong = Math.max(0, Math.min(wallLength, along))
// Distance from the pointer to the wall segment (not just the line).
const closestPointX = sx + dirX * clampedAlong
const closestPointY = sy + dirY * clampedAlong
const distance = Math.hypot(planPoint[0] - closestPointX, planPoint[1] - closestPointY)
if (distance > WALL_SNAP_DISTANCE_M) continue
if (best && distance >= Math.abs(best.perpDistance) && best.wall.id !== wall.id) continue
// Side determination, calibrated to the 3D wall convention. In
// wall-local space the wall extends along +X and its +Z axis is the
// front-face normal. After `mesh.rotation.y = -wallAngle`:
// - For a wall going `+X` in plan (wallAngle=0): wall-local +Z
// maps to world +Z = plan +Y, so the front face is on plan +Y.
// `perpRaw = py` is positive → front.
// - For a wall going `+Y` in plan (wallAngle=π/2): wall-local +Z
// maps to world -X = plan -X, so the front face is on plan -X.
// `perpRaw = -px` is positive there → front.
// So `perpRaw >= 0` is consistently the front side. The earlier
// labelling had this flipped, which produced rotations that were
// off by 90° on non-horizontal walls.
const side: 'front' | 'back' = perpRaw >= 0 ? 'front' : 'back'
// Rotation in wall-local space — matches 3D `calculateItemRotation`:
// 0 when the item faces the front normal (+Z), π for the back. The
// node is parented to the wall, so this composes with the wall's
// own rotation when rendered. Don't return a world-space rotation
// here — the consumer writes this straight into `node.rotation[1]`.
const itemRotation = side === 'front' ? 0 : Math.PI
best = {
wall,
localX: clampedAlong,
perpDistance: perpRaw,
side,
dirX,
dirY,
wallLength,
itemRotation,
}
}
return best
}
+49
View File
@@ -0,0 +1,49 @@
import { type NodeDefinition, SiteNode as SiteNodeSchema } from '@pascal-app/core'
import { siteParametrics } from './parametrics'
import { SiteNode } from './schema'
/**
* Site — Stage A. Top-level container under the scene root; holds
* buildings + property-line polygon + zones. No system (sites don't
* have per-frame work). Not movable / deletable — they're the scene
* root.
*/
export const siteDefinition: NodeDefinition<typeof SiteNode> = {
kind: 'site',
schemaVersion: 1,
schema: SiteNode,
category: 'site',
defaults: () => {
const stub = SiteNodeSchema.parse({ id: 'site_default' as never, type: 'site' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
// Site is the root container — sidebar / property-line tool drive
// selection, never 3D click (event bubbling from descendants would
// override their selection). Same reasoning as `level`.
duplicable: false,
deletable: false,
},
parametrics: siteParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
presentation: {
label: 'Site',
description: 'The top-level container holding buildings, zones, and the property boundary.',
icon: { kind: 'url', src: '/icons/site.png' },
paletteSection: 'site',
paletteOrder: 5,
},
mcp: {
description: 'Top-level site container.',
},
}
+1
View File
@@ -0,0 +1 @@
export { siteDefinition } from './definition'
+5
View File
@@ -0,0 +1,5 @@
import type { ParametricDescriptor, SiteNode } from '@pascal-app/core'
export const siteParametrics: ParametricDescriptor<SiteNode> = {
groups: [],
}
+148
View File
@@ -0,0 +1,148 @@
'use client'
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
import { NodeRenderer, unionPolygons, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
const Y_OFFSET = 0.01
/**
* Creates simple line geometry for site boundary
* Single horizontal line at ground level
*/
const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeometry => {
const geometry = new BufferGeometry()
if (points.length < 2) return geometry
const positions: number[] = []
// Create a simple line loop at ground level
for (const [x, z] of points) {
positions.push(x ?? 0, Y_OFFSET, z ?? 0)
}
// Close the loop
positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0)
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
return geometry
}
type S = ReturnType<typeof useScene.getState>
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, 'site', ref)
const theme = useViewer((state) => state.theme)
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
// Cache slab polygon references to keep the selector stable across unrelated store updates
const slabPolygonsCache = useRef<[number, number][][]>([])
const slabPolygons = useScene((state: S) => {
const nodeList = Object.values(state.nodes)
const levelIndexById = new Map<string, number>()
let lowestLevelIndex = Number.POSITIVE_INFINITY
nodeList.forEach((n) => {
if (n.type !== 'level') return
levelIndexById.set(n.id, n.level)
lowestLevelIndex = Math.min(lowestLevelIndex, n.level)
})
const next = nodeList
.filter(
(n): n is SlabNode =>
n.type === 'slab' &&
n.visible &&
n.polygon.length >= 3 &&
// Only recessed slabs should punch through the site ground.
// Positive slabs are real floor geometry and should not create a
// ghost footprint in the background ground fill.
(n.elevation ?? 0.05) < 0,
)
.filter((n) => {
if (!Number.isFinite(lowestLevelIndex)) return true
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
return parentLevel === lowestLevelIndex
})
.map((n) => n.polygon as [number, number][])
const prev = slabPolygonsCache.current
if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev
slabPolygonsCache.current = next
return next
})
// Ground shape: site polygon with slab footprints punched as holes
const groundShape = useMemo(() => {
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
const pts = node.polygon.points
const shape = new Shape()
shape.moveTo(pts[0]![0], -pts[0]![1])
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
shape.closePath()
if (slabPolygons.length > 0) {
for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
if (ring.length < 3) continue
const hole = new Path()
hole.moveTo(ring[0]![0], ring[0]![1])
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
hole.closePath()
shape.holes.push(hole)
}
}
return shape
}, [node?.polygon?.points, slabPolygons])
// Create boundary line geometry
const lineGeometry = useMemo(() => {
if (!node?.polygon?.points || node.polygon.points.length < 2) return null
return createBoundaryLineGeometry(node.polygon.points)
}, [node?.polygon?.points])
const handlers = useNodeEvents(node, 'site')
if (!(node && lineGeometry)) {
return null
}
return (
<group ref={ref} {...handlers}>
{/* Render children (buildings and items) */}
{node.children.map((child) => (
<NodeRenderer
key={typeof child === 'string' ? child : child.id}
nodeId={typeof child === 'string' ? child : child.id}
/>
))}
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
{groundShape && (
<mesh position={[0, -0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<shapeGeometry args={[groundShape]} />
<meshBasicMaterial
color={bgColor}
polygonOffset={true}
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
</mesh>
)}
{/* Simple boundary line */}
{/* @ts-ignore */}
<line frustumCulled={false} geometry={lineGeometry} renderOrder={9}>
<lineBasicMaterial color="#f59e0b" linewidth={2} opacity={0.6} transparent />
</line>
</group>
)
}
export default SiteRenderer
+1
View File
@@ -0,0 +1 @@
export { SiteNode } from '@pascal-app/core'
+19
View File
@@ -1,5 +1,11 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildSlabFloorplan } from './floorplan'
import {
slabAddVertexAffordance,
slabMoveEdgeAffordance,
slabMoveVertexAffordance,
} from './floorplan-affordances'
import { slabFloorplanMoveTarget } from './floorplan-move'
import { buildSlabGeometry } from './geometry'
import { slabParametrics } from './parametrics'
import { SlabNode } from './schema'
@@ -73,6 +79,19 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
// Stage C: floor-plan rendering. Legacy `slabPolygons` short-circuits
// to [] when slab is registered (see floorplan-panel.tsx).
floorplan: buildSlabFloorplan,
// 2D move handler — translates polygon by cursor delta from first
// pointer position. The 3D `MoveSlabTool` in `affordanceTools.move`
// skips events sourced from the 2D scene so the two paths don't
// double-write on commit.
floorplanMoveTarget: slabFloorplanMoveTarget,
// Sister to `affordanceTools['boundary-edit']` (the 3D `PolygonEditor`
// wrapper). The 2D version edits the same `polygon` field via SVG
// pointer events on the vertex handles emitted by `def.floorplan`.
floorplanAffordances: {
'move-vertex': slabMoveVertexAffordance,
'add-vertex': slabAddVertexAffordance,
'move-edge': slabMoveEdgeAffordance,
},
toolHints: [
{ key: 'Left click', label: 'Trace slab outline' },
@@ -0,0 +1,24 @@
import type { SlabNode } from '@pascal-app/core'
import {
createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance,
} from '../shared/polygon-vertex-affordance'
/**
* 2D drag affordances for slab. Three operations, each accepting an
* optional `holeIndex` in the payload so they target the boundary
* polygon or a specific hole:
*
* - `move-vertex` — drag an existing vertex.
* - `add-vertex` — insert a new vertex at a midpoint then drag.
* - `move-edge` — drag a whole edge perpendicular to itself.
*
* Holes are surfaced inline alongside the boundary in `def.floorplan`
* (no separate "hole edit mode" state machine like the legacy) — when
* the slab is selected, every hole's handles appear at the same time.
* Simpler model, no UX downside in practice.
*/
export const slabMoveVertexAffordance = createPolygonVertexAffordance<SlabNode>('slab')
export const slabAddVertexAffordance = createPolygonAddVertexAffordance<SlabNode>('slab')
export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance<SlabNode>('slab')
+131
View File
@@ -0,0 +1,131 @@
import {
type AnyNodeId,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
type SlabNode,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
/**
* 2D floor-plan move handler for slab — mirrors the 3D `MoveSlabTool`
* live-drag pattern so the visual stays smooth in split view.
*
* **Why not write the polygon every tick?** Per-tick `scene.update` on
* `polygon` triggers a CSG geometry rebuild in `GeometrySystem` every
* frame. Even with a synchronous `markDirty`, the rebuild dispose/add
* pair flickers in the 3D viewer and the slab visibly catches up to
* the cursor one frame late — the same regression `commit f4ea07e` was
* fixed for in the 3D mover. The fix there: don't touch `scene` during
* the drag at all. Translate the rendered `<group>` via the live-drag
* exception (`mesh.position` + `useLiveTransforms.position = delta`).
* On commit, write the polygon once.
*
* **Delta semantics** (see `wiki/architecture/tools.md` — "useLiveTransforms
* contract is per-kind, not generic"): polygon-based kinds carry their
* "position" in the polygon vertices, not a node.position field. The
* `useLiveTransforms.position` must be a translation **delta**
* (`[Δx, 0, Δz]`), which `ParametricNodeRenderer` consumes as the group
* position. Visual = group.position + group.children-in-original-coords
* = (delta) + (original polygon vertices) = translated, with no
* geometry rebuild.
*
* **Commit path**: `canCommit` is the only side-effectful write to
* `scene`. The dispatcher captured snapshots before the first apply,
* so its snapshot-diff after `canCommit` returns will see one update
* (the translated polygon) and run the single-undo dance against it.
* `MoveSlabTool`'s cleanup (fires when `setMovingNode(null)` runs after
* the commit) handles the `useLiveTransforms.clear` + the React-render
* that resets `group.position` to (0,0,0) — by then `GeometrySystem`
* has rebuilt with the new polygon, so the visual lands at the same
* world position with no teleport.
*/
const GRID_STEP = 0.5
function translatePolygon(
polygon: ReadonlyArray<readonly [number, number]>,
dx: number,
dz: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number])
}
export const slabFloorplanMoveTarget: FloorplanMoveTarget<SlabNode> = ({ node }) => {
const slabId = node.id as AnyNodeId
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
const originalHoles = (node.holes ?? []).map((hole) =>
hole.map(([x, z]) => [x, z] as [number, number]),
)
let anchor: [number, number] | null = null
let lastDelta: [number, number] = [0, 0]
const session: FloorplanMoveTargetSession = {
affectedIds: [slabId],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
if (!anchor) {
anchor = [snapped[0], snapped[1]]
return
}
const dx = snapped[0] - anchor[0]
const dz = snapped[1] - anchor[1]
lastDelta = [dx, dz]
// Live-drag exception (wiki/architecture/tools.md): write the
// delta to BOTH `mesh.position` (direct Three.js mutation) and
// `useLiveTransforms.position` (React-bound source of truth).
// They MUST match — `ParametricNodeRenderer` re-renders on every
// useLiveTransforms change and reconciles `<group position={...}>`,
// so a divergence makes the two writes fight every frame.
useLiveTransforms.getState().set(slabId, {
position: [dx, 0, dz],
rotation: 0,
})
const mesh = sceneRegistry.nodes.get(slabId) as THREE.Object3D | undefined
if (mesh) mesh.position.set(dx, 0, dz)
},
canCommit() {
const live = useScene.getState().nodes[slabId] as SlabNode | undefined
if (!live || live.type !== 'slab') return false
const [dx, dz] = lastDelta
if (dx === 0 && dz === 0) return false
// Side-effect commit sequence — mirrors `MoveSlabTool.onGridClick`
// so the React render that clears `group.position` (via the
// useLiveTransforms.clear below) and the `GeometrySystem` rebuild
// (via the sync `markDirty`) land in the same paint cycle. Order
// matters:
// 1. Write the translated polygon to `scene`. The dispatcher's
// snapshot-diff right after `canCommit` returns will pick
// this up as the single tracked change for undo.
// 2. `markDirty` directly — bypasses the rAF-deferred batch in
// `updateNodesAction`, so `GeometrySystem` sees the dirty
// flag synchronously and can rebuild this frame (without
// this the rebuild slides into the next frame and the slab
// visually pops to its original position for one paint).
// 3. Clear `useLiveTransforms` — `ParametricNodeRenderer` then
// re-renders `<group position={[0,0,0]}>` instead of the
// live delta. Without the rebuild from step 2 also landing
// this frame, the group would render at (0,0,0) over the
// *unrebuilt* (still-original) geometry → original-position
// blink. With step 2 in place, the rebuild and the React
// render commit together → smooth.
useScene.getState().updateNodes([
{
id: slabId,
data: {
polygon: translatePolygon(originalPolygon, dx, dz),
holes: originalHoles.map((h) => translatePolygon(h, dx, dz)),
},
},
])
useScene.getState().markDirty(slabId)
useLiveTransforms.getState().clear(slabId)
return true
},
}
return session
}
+25
View File
@@ -64,6 +64,29 @@ function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number): void {
if (mesh) mesh.position.set(deltaX, 0, deltaZ)
}
/**
* Distinguish 3D-canvas grid events (which this tool handles) from
* 2D floor-plan grid events (which `slabFloorplanMoveTarget` +
* `FloorplanRegistryMoveOverlay` Path 1 handle). The 2D scene wraps
* everything in `[data-floorplan-scene]`; if the native event's target
* is inside that subtree, the event belongs to the 2D mover. Without
* this guard, both paths would write the polygon on commit and produce
* two history entries / a double-translation.
*/
function isFloorplanSourcedEvent(event: GridEvent): boolean {
// ThreeEvent (3D) wraps the DOM PointerEvent under `.nativeEvent`;
// the 2D emitter passes the raw PointerEvent directly. Cover both.
const native: unknown = event.nativeEvent
const candidate =
(native as { target?: unknown; nativeEvent?: { target?: unknown } } | null) ?? null
const target =
(candidate?.target as Element | null | undefined) ??
(candidate?.nativeEvent as { target?: Element | null } | undefined)?.target ??
null
if (!target || typeof (target as Element).closest !== 'function') return false
return (target as Element).closest('[data-floorplan-scene]') != null
}
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]))
@@ -129,6 +152,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
}
const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
@@ -150,6 +174,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
}
const onGridClick = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
+164
View File
@@ -0,0 +1,164 @@
'use client'
import { type AnyNode, type SpawnNode, useLiveTransforms, useScene } from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
PanelSection,
PanelWrapper,
SliderControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
export default 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
triggerSFX('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId) return
triggerSFX('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>
)
}
+6
View File
@@ -17,4 +17,10 @@ export const spawnParametrics: ParametricDescriptor<SpawnNode> = {
],
},
],
// Stage E — kind-owned panel. Spawn has a derived position +
// rotation-degrees binding (legacy SpawnPanel converts radians to
// degrees in the slider and uses `useLiveTransforms` for smooth yaw
// dragging). Auto-inspector doesn't express the deg ↔ rad
// transform or the live-yaw preview yet — kept as a custom panel.
customPanel: () => import('./panel'),
}
@@ -0,0 +1,48 @@
import { type NodeDefinition, StairSegmentNode as StairSegmentNodeSchema } from '@pascal-app/core'
import { stairSegmentParametrics } from './parametrics'
import { StairSegmentNode } from './schema'
/**
* Stair segment — Stage A. Child of a stair node; per-flight geometry.
* Built by `StairSystem` registered on the parent stair definition.
*/
export const stairSegmentDefinition: NodeDefinition<typeof StairSegmentNode> = {
kind: 'stair-segment',
schemaVersion: 1,
schema: StairSegmentNode,
category: 'structure',
defaults: () => {
const stub = StairSegmentNodeSchema.parse({
id: 'stair-segment_default' as never,
type: 'stair-segment',
})
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: false,
deletable: true,
},
parametrics: stairSegmentParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
presentation: {
label: 'Stair Segment',
description: 'A single flight of a parent stair.',
icon: { kind: 'url', src: '/icons/stairs.png' },
paletteSection: 'structure',
paletteOrder: 111,
},
mcp: {
description: 'A single stair flight with run + rise + tread parameters.',
},
}
@@ -0,0 +1 @@
export { stairSegmentDefinition } from './definition'

Some files were not shown because too many files have changed in this diff Show More