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:
co-authored by
Claude Opus 4.7
parent
11015ea1ed
commit
d747d2f0ea
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { BuildingNode } from '@pascal-app/core'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 = () => {
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { columnDefinition } from './definition'
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
export { ColumnNode } from '@pascal-app/core'
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { elevatorDefinition } from './definition'
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
export { ElevatorNode } from '@pascal-app/core'
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { guideDefinition } from './definition'
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { GuideNode, ParametricDescriptor } from '@pascal-app/core'
|
||||
|
||||
export const guideParametrics: ParametricDescriptor<GuideNode> = {
|
||||
groups: [],
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { GuideNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { GuideSystem } from '@pascal-app/viewer'
|
||||
|
||||
export default GuideSystem
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { levelDefinition } from './definition'
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { LevelNode, ParametricDescriptor } from '@pascal-app/core'
|
||||
|
||||
export const levelParametrics: ParametricDescriptor<LevelNode> = {
|
||||
groups: [],
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { LevelNode } from '@pascal-app/core'
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { roofSegmentDefinition } from './definition'
|
||||
@@ -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'
|
||||
@@ -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).',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { roofDefinition } from './definition'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ParametricDescriptor, RoofNode } from '@pascal-app/core'
|
||||
|
||||
export const roofParametrics: ParametricDescriptor<RoofNode> = {
|
||||
groups: [],
|
||||
customPanel: () => import('./panel'),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
export { RoofNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { RoofSystem } from '@pascal-app/viewer'
|
||||
|
||||
export default RoofSystem
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { scanDefinition } from './definition'
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ParametricDescriptor, ScanNode } from '@pascal-app/core'
|
||||
|
||||
export const scanParametrics: ParametricDescriptor<ScanNode> = {
|
||||
groups: [],
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { ScanNode } from '@pascal-app/core'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { siteDefinition } from './definition'
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ParametricDescriptor, SiteNode } from '@pascal-app/core'
|
||||
|
||||
export const siteParametrics: ParametricDescriptor<SiteNode> = {
|
||||
groups: [],
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { SiteNode } from '@pascal-app/core'
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user