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

Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.

Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
  (boundary editors, hole editors, placement tools, move tools,
  endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
  stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
  elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
  spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`

Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
  `roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
  `stair-segment/` packages with definition + schema + renderer / system
  / floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
  (ceiling, door, item, shelf, slab, window) — single registry-driven
  dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
  drags (ceiling, fence, slab, wall) — using the shared
  `polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
  (door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
  (door, item, slab, window).

Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
  hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
  shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
  `floorplan-render-context.tsx` updated for the registry layer's new
  contract (props, affordance dispatch, render context).

Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
  `wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
  the registry-only world.

Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.

Wiki: new `plugin-authoring.md` page, README index updated.

Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-19 15:14:12 -04:00
co-authored by Claude Opus 4.7
parent 11015ea1ed
commit d747d2f0ea
204 changed files with 6888 additions and 7877 deletions
+14 -5
View File
@@ -1,4 +1,4 @@
import { loadPlugin, nodeRegistry } from '@pascal-app/core' import { discoverPlugins, loadPlugin, nodeRegistry } from '@pascal-app/core'
import { builtinPlugin } from '@pascal-app/nodes' import { builtinPlugin } from '@pascal-app/nodes'
// Idempotency guard: HMR can reload this module, but `registerNode` throws on // Idempotency guard: HMR can reload this module, but `registerNode` throws on
@@ -12,10 +12,19 @@ function isDev(): boolean {
return env?.NODE_ENV !== 'production' return env?.NODE_ENV !== 'production'
} }
export function loadBuiltinNodes(): void { export async function loadBuiltinNodes(): Promise<void> {
if (loaded) return if (loaded) return
loaded = true loaded = true
void loadPlugin(builtinPlugin) await loadPlugin(builtinPlugin)
// Phase 6 plugin discovery hook. Always called; default impl returns
// `[]`. Apps that ship external node packs override the discovery via
// `setPluginDiscovery(...)` before this module loads. See
// `wiki/editor-plugin-authoring.md` for the contract.
const externals = await discoverPlugins()
for (const plugin of externals) {
await loadPlugin(plugin)
}
if (isDev()) { if (isDev()) {
const kinds = Array.from(nodeRegistry.entries(), ([k]) => k) const kinds = Array.from(nodeRegistry.entries(), ([k]) => k)
@@ -24,7 +33,7 @@ export function loadBuiltinNodes(): void {
// "which path is running this kind?" Empty array = every kind is on // "which path is running this kind?" Empty array = every kind is on
// the legacy path. Kind in the array = registry path is live for it. // the legacy path. Kind in the array = registry path is live for it.
console.info( console.info(
`[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})`, `[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})${externals.length > 0 ? ` + ${externals.length} discovered plugin(s)` : ''}`,
) )
} }
// Expose the registry on window for ad-hoc dev inspection. In prod the // Expose the registry on window for ad-hoc dev inspection. In prod the
@@ -38,4 +47,4 @@ export function loadBuiltinNodes(): void {
// Run as a side effect on first import so any consumer of this module gets a // Run as a side effect on first import so any consumer of this module gets a
// populated registry without remembering to call the function explicitly. // populated registry without remembering to call the function explicitly.
loadBuiltinNodes() void loadBuiltinNodes()
+13 -1
View File
@@ -13,6 +13,7 @@ import type {
LevelNode, LevelNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
ScanNode,
ShelfNode, ShelfNode,
SiteNode, SiteNode,
SlabNode, SlabNode,
@@ -36,7 +37,14 @@ export interface GridEvent {
*/ */
localPosition: [number, number, number] localPosition: [number, number, number]
faceIndex?: number faceIndex?: number
object: Object3D /**
* Optional: the hit Three.js object. Present when the grid event was
* synthesized from a R3F mesh hit (the legacy grid-plane mesh path);
* absent when emitted by the canvas-level raycaster in
* `use-grid-events.ts`, where there is no specific mesh to attribute
* the intersection to.
*/
object?: Object3D
nativeEvent: ThreeEvent<PointerEvent> nativeEvent: ThreeEvent<PointerEvent>
} }
@@ -70,6 +78,8 @@ export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode> export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode> export type DoorEvent = NodeEvent<DoorNode>
export type ElevatorEvent = NodeEvent<ElevatorNode> export type ElevatorEvent = NodeEvent<ElevatorNode>
export type ScanEvent = NodeEvent<ScanNode>
export type GuideEvent = NodeEvent<GuideNode>
// Event suffixes - exported for use in hooks // Event suffixes - exported for use in hooks
export const eventSuffixes = [ export const eventSuffixes = [
@@ -201,6 +211,8 @@ type EditorEvents = GridEvents &
NodeEvents<'stair-segment', StairSegmentEvent> & NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'window', WindowEvent> & NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> & NodeEvents<'door', DoorEvent> &
NodeEvents<'scan', ScanEvent> &
NodeEvents<'guide', GuideEvent> &
CameraControlEvents & CameraControlEvents &
ToolEvents & ToolEvents &
GuideEvents & GuideEvents &
@@ -3,43 +3,20 @@
import { useLayoutEffect } from 'react' import { useLayoutEffect } from 'react'
import type * as THREE from 'three' import type * as THREE from 'three'
const KNOWN_NODE_KINDS = [ // `byType` is a Proxy-backed Map keyed by kind. Sets are created lazily on
'site', // first access, so any kind (built-in or plugin-contributed) participates
'building', // without needing a hardcoded seed list. The previous `KNOWN_NODE_KINDS`
'ceiling', // array was a pre-seed for autocomplete; with every kind now flowing
'column', // through `nodeRegistry`, the seed is redundant.
'elevator', //
'level', // The type expresses that *any* string key returns a `Set<string>` — the
'wall', // Proxy auto-creates on first access so there's no `undefined` branch at
'fence', // runtime. Without this shape, `noUncheckedIndexedAccess` would force
'item', // every caller to defend against an impossible undefined.
'slab', type ByTypeMap = { [kind: string]: Set<string> }
'spawn', const byTypeStore = new Map<string, Set<string>>()
'zone',
'roof',
'roof-segment',
'stair',
'stair-segment',
'scan',
'guide',
'window',
'door',
] as const
type KnownNodeKind = (typeof KNOWN_NODE_KINDS)[number] const byTypeProxy = new Proxy({} as ByTypeMap, {
// Allow registry-registered (plugin) kinds while keeping autocomplete for built-ins.
type NodeKind = KnownNodeKind | (string & {})
type ByTypeShape = Record<KnownNodeKind, Set<string>> & Record<string, Set<string>>
const byTypeStore = new Map<string, Set<string>>(
KNOWN_NODE_KINDS.map((k) => [k, new Set<string>()]),
)
// Auto-creates a Set the first time an unknown kind is accessed. This is what
// lets registry-registered (and future plugin-contributed) kinds participate
// in `byType` without being hardcoded here.
const byTypeProxy = new Proxy({} as ByTypeShape, {
get(_target, key) { get(_target, key) {
if (typeof key !== 'string') return undefined if (typeof key !== 'string') return undefined
let set = byTypeStore.get(key) let set = byTypeStore.get(key)
@@ -67,9 +44,8 @@ export const sceneRegistry = {
// Master lookup: ID -> Object3D // Master lookup: ID -> Object3D
nodes: new Map<string, THREE.Object3D>(), nodes: new Map<string, THREE.Object3D>(),
// Categorized lookups: Kind -> Set of IDs. // Categorized lookups: Kind -> Set of IDs. Backed by a Proxy so any kind
// Backed by a Proxy so registry-registered kinds get a Set on first touch, // gets a Set on first touch — no hardcoded list.
// while built-in kinds remain present from module init for fast paths.
byType: byTypeProxy, byType: byTypeProxy,
/** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */ /** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */
@@ -81,7 +57,7 @@ export const sceneRegistry = {
}, },
} }
export function useRegistry(id: string, type: NodeKind, ref: React.RefObject<THREE.Object3D>) { export function useRegistry(id: string, type: string, ref: React.RefObject<THREE.Object3D>) {
useLayoutEffect(() => { useLayoutEffect(() => {
const obj = ref.current const obj = ref.current
if (!obj) return if (!obj) return
@@ -89,11 +65,10 @@ export function useRegistry(id: string, type: NodeKind, ref: React.RefObject<THR
// 1. Add to master map // 1. Add to master map
sceneRegistry.nodes.set(id, obj) sceneRegistry.nodes.set(id, obj)
// 2. Add to type-specific set — Proxy auto-creates on first access so the // 2. Add to type-specific set — Proxy auto-creates on first access.
// assertion is safe; TS just can't see through the Proxy.
sceneRegistry.byType[type]!.add(id) sceneRegistry.byType[type]!.add(id)
// 4. Cleanup when component unmounts // 3. Cleanup when component unmounts
return () => { return () => {
sceneRegistry.nodes.delete(id) sceneRegistry.nodes.delete(id)
sceneRegistry.byType[type]!.delete(id) sceneRegistry.byType[type]!.delete(id)
@@ -1,6 +1,13 @@
'use client' 'use client'
import { type AnyNode, type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core' import {
type AnyNode,
type AnyNodeId,
type CeilingNode,
nodeRegistry,
type SlabNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@@ -18,9 +25,13 @@ import { NodeActionMenu } from '../editor/node-action-menu'
* an HTML overlay positioned at the top of the bounding box. * an HTML overlay positioned at the top of the bounding box.
* *
* Buttons: * Buttons:
* - Move: sets `movingNode` in useEditor. The `<FloorplanRegistryMove * - Move: sets `movingNode` in useEditor. Enabled when the kind has
* Overlay>` component picks that up and lets the user click in the * `capabilities.movable`, `def.floorplanMoveTarget`, OR
* floor plan to commit the new position. * `def.affordanceTools.move` (slab / ceiling). The
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
* - Add hole (slab + ceiling only): inserts a small default-square
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
* `handleAddHole` in `floating-action-menu.tsx`.
* - Duplicate: deep-clones the node, marks it new, sets it as the * - Duplicate: deep-clones the node, marks it new, sets it as the
* movingNode (placement cursor) — same UX pattern as 3D duplicate. * movingNode (placement cursor) — same UX pattern as 3D duplicate.
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
@@ -70,14 +81,66 @@ export function FloorplanRegistryActionMenu() {
const node = useScene.getState().nodes[selectedId] const node = useScene.getState().nodes[selectedId]
if (!node) return null if (!node) return null
const canMove = !!def.capabilities.movable // Move button is enabled when any of:
// - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
// - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
// - `def.affordanceTools.move` (kind-owned 3D mover — slab / ceiling)
// From the menu's perspective all three are "this kind can move from
// the floor plan." The `MoveTool` dispatcher resolves the right path.
const canMove =
!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
const canDuplicate = def.capabilities.duplicable !== false const canDuplicate = def.capabilities.duplicable !== false
const canDelete = def.capabilities.deletable !== false const canDelete = def.capabilities.deletable !== false
const canAddHole = node.type === 'slab' || node.type === 'ceiling'
const handleMove = () => { const handleMove = () => {
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never) setMovingNode(node as never)
// Selection stays — the move overlay reads movingNode, not selection. // Match the legacy 3D `floating-action-menu`: clear selection so
// selection-gated affordances unmount during the drag. Specifically
// the slab / ceiling boundary editor (`ToolManager` shows it when
// `selectedSlabId !== undefined`) would otherwise stay visible
// and render its vertex / edge handles on top of the moving mesh
// in split-view 3D. The move overlay reads `movingNode`, not the
// selection, so clearing it doesn't disturb the move itself; the
// commit path re-selects the node when it ends.
useViewer.getState().setSelection({ selectedIds: [] })
}
const handleAddHole = () => {
if (!canAddHole) return
const surfaceNode = node as SlabNode | CeilingNode
const polygon = surfaceNode.polygon
if (!polygon || polygon.length < 3) return
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
cx /= polygon.length
cz /= polygon.length
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
[cx + holeSize, cz - holeSize],
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = surfaceNode.holes ?? []
const currentMetadata = currentHoles.map(
(_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
)
sfxEmitter.emit('sfx:structure-build')
useScene.getState().updateNode(
selectedId as AnyNodeId,
{
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' as const }],
} as Partial<AnyNode>,
)
} }
const handleDuplicate = () => { const handleDuplicate = () => {
@@ -113,6 +176,7 @@ export function FloorplanRegistryActionMenu() {
}} }}
> >
<NodeActionMenu <NodeActionMenu
onAddHole={canAddHole ? handleAddHole : undefined}
onDelete={canDelete ? handleDelete : undefined} onDelete={canDelete ? handleDelete : undefined}
onDuplicate={canDuplicate ? handleDuplicate : undefined} onDuplicate={canDuplicate ? handleDuplicate : undefined}
onMove={canMove ? handleMove : undefined} onMove={canMove ? handleMove : undefined}
@@ -0,0 +1,54 @@
'use client'
import type { FloorplanPalette } from '@pascal-app/core'
import { createContext, type ReactNode, useContext, useMemo } from 'react'
/**
* Per-frame render context shared between the legacy `floorplan-panel.tsx`
* and the registry-driven `<FloorplanRegistryLayer>`.
*
* The legacy panel is the authoritative owner of the floor-plan SVG —
* it computes `unitsPerPixel` from the viewBox / surface size, mounts the
* pan/zoom `<g>`, and knows the active theme. The registry layer is mounted
* inside the same `<g>`, so anything it draws shares the same coordinate
* system; this context plumbs through the bits it can't recompute on its
* own without re-implementing the legacy's resize / theme logic.
*
* Once `floorplan-panel.tsx` is fully migrated (Phase 6), this provider
* moves into a kind-agnostic 2D editor shell and the context loses the
* "legacy bridge" connotation.
*/
export type FloorplanRenderContextValue = {
/** SVG units per screen pixel — used to keep handle radii consistent at any zoom. */
unitsPerPixel: number
/** Themed palette mirroring the legacy `FloorplanPalette` accent slots. */
palette: FloorplanPalette
/** SVG `<pattern>` id mounted in `<defs>` by the legacy panel for selection hatch fills. */
hatchPatternId: string
}
const FloorplanRenderContext = createContext<FloorplanRenderContextValue | null>(null)
export function FloorplanRenderProvider({
children,
unitsPerPixel,
palette,
hatchPatternId,
}: FloorplanRenderContextValue & { children: ReactNode }) {
const value = useMemo<FloorplanRenderContextValue>(
() => ({ unitsPerPixel, palette, hatchPatternId }),
[unitsPerPixel, palette, hatchPatternId],
)
return <FloorplanRenderContext.Provider value={value}>{children}</FloorplanRenderContext.Provider>
}
/**
* Read the active render context. Returns `null` when called outside a
* provider — the registry layer treats this as "render statically, skip
* theme-aware chrome and interactive handles". This makes the layer
* usable in isolation tests + future editor shells without bringing the
* whole legacy panel along.
*/
export function useFloorplanRender(): FloorplanRenderContextValue | null {
return useContext(FloorplanRenderContext)
}
@@ -273,10 +273,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
fill={curvedAccent} fill={curvedAccent}
key={`${stair.id}:spiral-arrow`} key={`${stair.id}:spiral-arrow`}
pointerEvents="none" pointerEvents="none"
points={buildSvgArrowHeadPoints( points={formatSvgPolygonPoints(
arrowPoint, buildSvgArrowHeadPoints(
tangentAngle, arrowPoint,
clamp(stair.width * 0.18, 0.12, 0.18), tangentAngle,
clamp(stair.width * 0.18, 0.12, 0.18),
),
)} )}
/> />
) )
@@ -361,10 +363,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
fill={curvedAccent} fill={curvedAccent}
key={`${stair.id}:curved-arrow`} key={`${stair.id}:curved-arrow`}
pointerEvents="none" pointerEvents="none"
points={buildSvgArrowHeadPoints( points={formatSvgPolygonPoints(
arrowPoint, buildSvgArrowHeadPoints(
tangentAngle, arrowPoint,
clamp(stair.width * 0.16, 0.1, 0.16), tangentAngle,
clamp(stair.width * 0.16, 0.1, 0.16),
),
)} )}
/> />
) )
@@ -103,7 +103,15 @@ export function formatSvgPolygonPoints(points: Point2D[]) {
return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ') return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ')
} }
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) { /**
* Three points defining an arrow head — tip + two trailing barbs.
* Returned as plain `Point2D` objects so consumers can either feed them
* straight into `formatSvgPolygonPoints` (for SVG `points=""`) or push
* them onto a `FloorplanGeometry.polygon.points` array. Mixing both
* downstream paths through a string-returning helper was awkward — see
* `nodes/src/stair/floorplan.ts` which needs the points as objects.
*/
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number): Point2D[] {
const left = { const left = {
x: point.x - size * Math.cos(angle - Math.PI / 6), x: point.x - size * Math.cos(angle - Math.PI / 6),
y: point.y - size * Math.sin(angle - Math.PI / 6), y: point.y - size * Math.sin(angle - Math.PI / 6),
@@ -113,7 +121,7 @@ export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: num
y: point.y - size * Math.sin(angle + Math.PI / 6), y: point.y - size * Math.sin(angle + Math.PI / 6),
} }
return formatSvgPolygonPoints([point, left, right]) return [point, left, right]
} }
export { toSvgPoint, toSvgX, toSvgY } export { toSvgPoint, toSvgX, toSvgY }
@@ -352,7 +352,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const meshes: ElevatorColliderMesh[] = [] const meshes: ElevatorColliderMesh[] = []
for (const elevatorId of sceneRegistry.byType.elevator) { for (const elevatorId of sceneRegistry.byType.elevator!) {
const typedElevatorId = elevatorId as AnyNodeId const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId] const node = nodes[typedElevatorId]
if (node?.type !== 'elevator' || node.visible === false) continue if (node?.type !== 'elevator' || node.visible === false) continue
@@ -585,7 +585,7 @@ export const FirstPersonControls = () => {
let closestDoorId: AnyNodeId | null = null let closestDoorId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE let closestDistance = DOOR_INTERACTION_DISTANCE
for (const doorId of sceneRegistry.byType.door) { for (const doorId of sceneRegistry.byType.door!) {
const node = nodes[doorId as AnyNodeId] const node = nodes[doorId as AnyNodeId]
if (node?.type !== 'door') continue if (node?.type !== 'door') continue
if (node.openingKind === 'opening') continue if (node.openingKind === 'opening') continue
@@ -683,7 +683,7 @@ export const FirstPersonControls = () => {
let closestWindowId: AnyNodeId | null = null let closestWindowId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE let closestDistance = DOOR_INTERACTION_DISTANCE
for (const windowId of sceneRegistry.byType.window) { for (const windowId of sceneRegistry.byType.window!) {
const node = nodes[windowId as AnyNodeId] const node = nodes[windowId as AnyNodeId]
if (node?.type !== 'window') continue if (node?.type !== 'window') continue
if (node.openingKind === 'opening') continue if (node.openingKind === 'opening') continue
@@ -713,7 +713,7 @@ export const FirstPersonControls = () => {
let closestTarget: FirstPersonInteractableTarget | null = null let closestTarget: FirstPersonInteractableTarget | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE let closestDistance = DOOR_INTERACTION_DISTANCE
for (const elevatorId of sceneRegistry.byType.elevator) { for (const elevatorId of sceneRegistry.byType.elevator!) {
const typedElevatorId = elevatorId as AnyNodeId const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId] const node = nodes[typedElevatorId]
if (node?.type !== 'elevator') continue if (node?.type !== 'elevator') continue
@@ -1088,11 +1088,11 @@ export const FirstPersonControls = () => {
const elevatorIds = activeRide const elevatorIds = activeRide
? [ ? [
activeRide.elevatorId, activeRide.elevatorId,
...Array.from(sceneRegistry.byType.elevator).filter( ...Array.from(sceneRegistry.byType.elevator!).filter(
(elevatorId) => elevatorId !== activeRide.elevatorId, (elevatorId) => elevatorId !== activeRide.elevatorId,
), ),
] ]
: Array.from(sceneRegistry.byType.elevator) : Array.from(sceneRegistry.byType.elevator!)
for (const elevatorId of elevatorIds) { for (const elevatorId of elevatorIds) {
const typedElevatorId = elevatorId as AnyNodeId const typedElevatorId = elevatorId as AnyNodeId
@@ -176,7 +176,7 @@ function buildRegisteredNodeTypeLookup() {
const nodeTypes = new Map<string, ColliderNodeType>() const nodeTypes = new Map<string, ColliderNodeType>()
for (const type of COLLIDER_NODE_TYPES) { for (const type of COLLIDER_NODE_TYPES) {
for (const nodeId of sceneRegistry.byType[type]) { for (const nodeId of sceneRegistry.byType[type]!) {
nodeTypes.set(nodeId, type) nodeTypes.set(nodeId, type)
} }
} }
@@ -238,7 +238,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
} }
for (const type of COLLIDER_NODE_TYPES) { for (const type of COLLIDER_NODE_TYPES) {
for (const nodeId of sceneRegistry.byType[type]) { for (const nodeId of sceneRegistry.byType[type]!) {
if (shouldSkipColliderNode(nodeId, type)) continue if (shouldSkipColliderNode(nodeId, type)) continue
const root = sceneRegistry.nodes.get(nodeId) const root = sceneRegistry.nodes.get(nodeId)
@@ -16,7 +16,6 @@ import {
type GuideNode, type GuideNode,
getRenderableSlabPolygon, getRenderableSlabPolygon,
getWallChordFrame, getWallChordFrame,
getWallCurveFrameAt,
getWallCurveLength, getWallCurveLength,
getWallMidpointHandlePoint, getWallMidpointHandlePoint,
getWallPlanFootprint, getWallPlanFootprint,
@@ -30,7 +29,6 @@ import {
type Point2D, type Point2D,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
resolveElevatorServiceLevelIds,
type SiteNode, type SiteNode,
SlabNode, SlabNode,
type SpawnNode, type SpawnNode,
@@ -87,6 +85,10 @@ import {
} from '../editor-2d/floorplan-hotkey-handlers' } from '../editor-2d/floorplan-hotkey-handlers'
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu' import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay' import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
import {
type FloorplanRenderContextValue,
FloorplanRenderProvider,
} from '../editor-2d/floorplan-render-context'
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer' import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer' import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
import { import {
@@ -8440,364 +8442,29 @@ export function FloorplanPanel() {
return hasPreviewWalls ? nextFloorplanWallById : floorplanWallById return hasPreviewWalls ? nextFloorplanWallById : floorplanWallById
}, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft]) }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
const floorplanFenceEntries = useMemo(() => { // Fence is fully registry-driven (`def.floorplan` + `buildFenceFloorplan`).
// Fence migrated to def.floorplan (Phase 5 Stage C). When registered, // The legacy entry list is permanently empty; kept as a typed stable
// FloorplanRegistryLayer renders the fence polyline; this legacy // reference so downstream prop sites stay typed without each having to
// path short-circuits to avoid double-render. Removed entirely in // declare its own `[]`.
// Phase 6 cleanup. const floorplanFenceEntries = useMemo<FloorplanFenceEntry[]>(() => [], [])
if (nodeRegistry.has('fence')) return [] // Wall is fully registry-driven. Empty stable arrays for the legacy
return fences.flatMap((fence) => { // entry lists; consumers' map / iteration paths become no-ops.
const live = useLiveTransforms.getState().get(fence.id) const wallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
const fenceCenterX = (fence.start[0] + fence.end[0]) / 2 const displayWallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
const fenceCenterZ = (fence.start[1] + fence.end[1]) / 2
const displayFence = live
? {
...fence,
start: [
fence.start[0] + (live.position[0] - fenceCenterX),
fence.start[1] + (live.position[2] - fenceCenterZ),
] as typeof fence.start,
end: [
fence.end[0] + (live.position[0] - fenceCenterX),
fence.end[1] + (live.position[2] - fenceCenterZ),
] as typeof fence.end,
}
: fence
const centerline = isCurvedWall(displayFence)
? sampleWallCenterline(displayFence, 24)
: [
{ x: displayFence.start[0], y: displayFence.start[1] },
{ x: displayFence.end[0], y: displayFence.end[1] },
]
const path = buildSvgPolylinePath(centerline)
if (!path) {
return []
}
const markerFrames = getFloorplanFenceMarkerTs(displayFence).map((t) => { // Doors + windows fully registry-driven via `def.floorplan`.
const frame = getWallCurveFrameAt(displayFence, t) const openingsPolygons = useMemo<OpeningPolygonEntry[]>(() => [], [])
// Slab + ceiling fully registry-driven via `def.floorplan`. Same
return { // empty-stable-array pattern.
angleDeg: (Math.atan2(frame.tangent.y, frame.tangent.x) * 180) / Math.PI, const slabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
point: frame.point, const displaySlabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
} const ceilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
}) const displayCeilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
// Zone fully registry-driven via `def.floorplan`.
return [{ fence: displayFence, centerline, markerFrames, path }] const zonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
}) const displayZonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
}, [fences, movingFloorplanNodeRevision]) // Column fully registry-driven via `def.floorplan`.
const wallPolygons = useMemo(() => { const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(() => [], [])
// Wall migrated to def.floorplan (Phase 5 Stage C). When registered,
// FloorplanRegistryLayer renders the mitered wall polygon; this
// legacy path short-circuits. Removed entirely in Phase 6 cleanup.
if (nodeRegistry.has('wall')) return []
return walls.map((wall) => {
const floorplanWall = floorplanWallById.get(wall.id) ?? getFloorplanWall(wall)
const polygon = getWallPlanFootprint(floorplanWall, wallMiterData)
return {
points: formatPolygonPoints(polygon),
wall,
polygon,
}
})
}, [floorplanWallById, wallMiterData, walls])
const displayWallPolygons = useMemo(() => {
if (!(wallEndpointDraft || wallCurveDraft)) {
return wallPolygons
}
const previewWalls = new Map<WallNode['id'], WallNode>()
if (wallEndpointDraft) {
for (const draftUpdate of getWallEndpointDraftUpdates(wallEndpointDraft)) {
const previewWall = displayWallById.get(draftUpdate.id)
if (previewWall) {
previewWalls.set(previewWall.id, previewWall)
}
}
}
if (wallCurveDraft) {
const previewWall = displayWallById.get(wallCurveDraft.wallId)
if (previewWall) {
previewWalls.set(previewWall.id, previewWall)
}
}
if (previewWalls.size === 0) {
return wallPolygons
}
return wallPolygons.map((entry) =>
(() => {
const previewWall = previewWalls.get(entry.wall.id)
if (!previewWall) {
return entry
}
const previewPolygon = getWallPlanFootprint(
getFloorplanWall(previewWall),
EMPTY_WALL_MITER_DATA,
)
return {
wall: previewWall,
polygon: previewPolygon,
points: formatPolygonPoints(previewPolygon),
}
})(),
)
}, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
const openingsPolygons = useMemo(() => {
// Doors + windows migrated to def.floorplan (Phase 5 Stage C). When
// both registered, FloorplanRegistryLayer renders each opening's
// polygon via its kind's builder; this legacy path short-circuits
// to avoid double-render. Filter per kind so a partial migration
// would still work.
const doorRegistered = nodeRegistry.has('door')
const windowRegistered = nodeRegistry.has('window')
if (doorRegistered && windowRegistered) return []
return openings.flatMap((opening) => {
if (doorRegistered && opening.type === 'door') return []
if (windowRegistered && opening.type === 'window') return []
const wall = displayFloorplanWallById.get(opening.parentId as WallNode['id'])
if (!wall) return []
const live = useLiveTransforms.getState().get(opening.id)
const displayOpening =
live &&
(movingNode?.type === 'door' || movingNode?.type === 'window') &&
movingNode.id === opening.id
? {
...opening,
position: [
live.position[0],
opening.position[1],
live.position[2],
] as typeof opening.position,
rotation: [
opening.rotation[0],
live.rotation,
opening.rotation[2],
] as typeof opening.rotation,
}
: opening
const polygon = getOpeningFootprint(wall, displayOpening)
return [
{
opening: displayOpening,
points: formatPolygonPoints(polygon),
polygon,
},
]
})
}, [displayFloorplanWallById, movingFloorplanNodeRevision, movingNode, openings])
const slabPolygons = useMemo(() => {
// Slab migrated to def.floorplan (Phase 5 Stage C). When registered,
// FloorplanRegistryLayer renders the slab polygon; this legacy
// path short-circuits to avoid double-render. Removed entirely in
// Phase 6 cleanup.
if (nodeRegistry.has('slab')) return []
return slabs.flatMap((slab) => {
const polygon = toFloorplanPolygon(slab.polygon)
if (polygon.length < 3) {
return []
}
const holes = (slab.holes ?? [])
.map((hole) => toFloorplanPolygon(hole))
.filter((hole) => hole.length >= 3)
const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab))
const visualHoles = holes
return [
{
slab,
polygon,
holes,
visualPolygon,
visualHoles,
path: formatPolygonPath(visualPolygon, visualHoles),
},
]
})
}, [slabs])
const displaySlabPolygons = useMemo(() => {
if (!(slabBoundaryDraft || slabHoleBoundaryDraft || slabHoleMoveDraft)) {
return slabPolygons
}
return slabPolygons.map((entry) => {
let nextEntry = entry
if (slabBoundaryDraft && entry.slab.id === slabBoundaryDraft.slabId) {
nextEntry = (() => {
const draftVisualPolygon =
slabBoundaryDraft.visualOffsets?.length === slabBoundaryDraft.polygon.length
? getDraftSlabVisualPolygon(slabBoundaryDraft)
: toFloorplanPolygon(
getRenderableSlabPolygon({
...entry.slab,
polygon: slabBoundaryDraft.polygon,
}),
)
return {
...entry,
polygon: slabBoundaryDraft.polygon.map(toPoint2D),
visualPolygon: draftVisualPolygon,
path: formatPolygonPath(draftVisualPolygon, entry.visualHoles),
}
})()
}
const activeHoleDraft =
slabHoleBoundaryDraft && entry.slab.id === slabHoleBoundaryDraft.slabId
? slabHoleBoundaryDraft
: slabHoleMoveDraft && entry.slab.id === slabHoleMoveDraft.slabId
? slabHoleMoveDraft
: null
if (activeHoleDraft) {
const draftHole = activeHoleDraft.polygon.map(toPoint2D)
const draftHoles = nextEntry.holes.map((hole, index) =>
index === activeHoleDraft.holeIndex ? draftHole : hole,
)
const draftVisualHoles = nextEntry.visualHoles.map((hole, index) =>
index === activeHoleDraft.holeIndex ? draftHole : hole,
)
nextEntry = {
...nextEntry,
holes: draftHoles,
visualHoles: draftVisualHoles,
path: formatPolygonPath(nextEntry.visualPolygon, draftVisualHoles),
}
}
return nextEntry
})
}, [slabBoundaryDraft, slabHoleBoundaryDraft, slabHoleMoveDraft, slabPolygons])
const ceilingPolygons = useMemo(() => {
// Ceiling migrated to def.floorplan (Phase 5 Stage C). When registered,
// FloorplanRegistryLayer renders the ceiling polygon; this legacy
// path short-circuits. Removed entirely in Phase 6 cleanup.
if (nodeRegistry.has('ceiling')) return []
return ceilings.flatMap((ceiling) => {
const polygon = toFloorplanPolygon(ceiling.polygon)
if (polygon.length < 3) {
return []
}
const holes = (ceiling.holes ?? [])
.map((hole) => toFloorplanPolygon(hole))
.filter((hole) => hole.length >= 3)
return [
{
ceiling,
polygon,
holes,
path: formatPolygonPath(polygon, holes),
},
]
})
}, [ceilings])
const displayCeilingPolygons = useMemo(() => {
if (!(ceilingBoundaryDraft || ceilingHoleBoundaryDraft || ceilingHoleMoveDraft)) {
return ceilingPolygons
}
return ceilingPolygons.map((entry) => {
let nextEntry = entry
if (ceilingBoundaryDraft && entry.ceiling.id === ceilingBoundaryDraft.ceilingId) {
const polygon = ceilingBoundaryDraft.polygon.map(toPoint2D)
nextEntry = {
...entry,
polygon,
path: formatPolygonPath(polygon, entry.holes),
}
}
const activeHoleDraft =
ceilingHoleBoundaryDraft && entry.ceiling.id === ceilingHoleBoundaryDraft.ceilingId
? ceilingHoleBoundaryDraft
: ceilingHoleMoveDraft && entry.ceiling.id === ceilingHoleMoveDraft.ceilingId
? ceilingHoleMoveDraft
: null
if (activeHoleDraft) {
const draftHole = activeHoleDraft.polygon.map(toPoint2D)
const holes = nextEntry.holes.map((hole, index) =>
index === activeHoleDraft.holeIndex ? draftHole : hole,
)
nextEntry = {
...nextEntry,
holes,
path: formatPolygonPath(nextEntry.polygon, holes),
}
}
return nextEntry
})
}, [ceilingBoundaryDraft, ceilingHoleBoundaryDraft, ceilingHoleMoveDraft, ceilingPolygons])
const zonePolygons = useMemo(
() =>
zones.flatMap((zone) => {
const polygon = toFloorplanPolygon(zone.polygon)
if (polygon.length < 3) {
return []
}
return [
{
zone,
polygon,
points: formatPolygonPoints(polygon),
},
]
}),
[zones],
)
const displayZonePolygons = useMemo(() => {
if (!zoneBoundaryDraft) {
return zonePolygons
}
return zonePolygons.map((entry) =>
entry.zone.id === zoneBoundaryDraft.zoneId
? {
...entry,
polygon: zoneBoundaryDraft.polygon.map(toPoint2D),
points: formatPolygonPoints(zoneBoundaryDraft.polygon.map(toPoint2D)),
}
: entry,
)
}, [zoneBoundaryDraft, zonePolygons])
const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(
() =>
levelDescendantNodes.flatMap((node) => {
if (!(node.type === 'column' && node.visible !== false)) {
return []
}
const polygon = getColumnPlanFootprint(node)
if (polygon.length < 3) {
return []
}
return [
{
column: node,
points: formatPolygonPoints(polygon),
polygon,
},
]
}),
[levelDescendantNodes],
)
const levelDescendantNodeById = useMemo( const levelDescendantNodeById = useMemo(
() => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)), () => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)),
[levelDescendantNodes], [levelDescendantNodes],
@@ -8820,184 +8487,11 @@ export function FloorplanPanel() {
), ),
[levelDescendantNodes], [levelDescendantNodes],
) )
const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(() => { // Spawn + item fully registry-driven.
// Spawn migrated to the registry-driven floor-plan layer (Phase 5 const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(() => [], [])
// Stage C). When registered, FloorplanRegistryLayer renders the const floorplanItemEntries = useMemo<FloorplanItemEntry[]>(() => [], [])
// spawn marker via def.floorplan; FloorplanRegistryActionMenu // Elevator fully registry-driven via `def.floorplan`.
// handles select / move / delete. Returning [] here skips the const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => [], [])
// legacy rendering + action menu paths to avoid double-render.
// Removed entirely in Phase 6 cleanup.
if (nodeRegistry.has('spawn')) return []
return spawns
.filter((spawn) => spawn.visible !== false)
.map((spawn) => {
const live = useLiveTransforms.getState().get(spawn.id)
return {
spawn,
position: {
x: live?.position[0] ?? spawn.position[0],
y: live?.position[2] ?? spawn.position[2],
},
rotation: live?.rotation ?? spawn.rotation,
}
})
}, [movingFloorplanNodeRevision, spawns])
const floorplanItemEntries = useMemo(() => {
// Item migrated to def.floorplan (Phase 5 Stage C). When registered,
// FloorplanRegistryLayer renders the item rectangle via the
// parent-chain transform walker; this legacy path short-circuits.
// Removed entirely in Phase 6 cleanup.
if (nodeRegistry.has('item')) return []
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
return floorplanItems.flatMap((item) => {
const entry = buildFloorplanItemEntry(item, levelDescendantNodeById, transformCache)
if (!entry) {
return []
}
return [
{
dimensionPolygon: entry.dimensionPolygon,
item: entry.item,
points: formatPolygonPoints(entry.polygon),
polygon: entry.polygon,
usesRealMesh: entry.usesRealMesh,
center: entry.center,
rotation: entry.rotation,
width: entry.width,
depth: entry.depth,
},
]
})
}, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision])
const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => {
// These keys subscribe the memo to imperative floorplan stores read with getState().
void elevatorLiveOverrideKey
void elevatorRuntimeKey
void movingFloorplanNodeRevision
if (!levelNode) {
return []
}
const nodes = useScene.getState().nodes
const interactiveElevators = useInteractive.getState().elevators
return elevators.flatMap((elevator) => {
const liveOverrides = useLiveNodeOverrides.getState().get(elevator.id)
const displayElevator = liveOverrides
? ({ ...elevator, ...liveOverrides } as ElevatorNode)
: elevator
const serviceLevelIds = resolveElevatorServiceLevelIds(displayElevator, nodes)
if (!serviceLevelIds.includes(levelNode.id)) {
return []
}
const live = useLiveTransforms.getState().get(displayElevator.id)
const position = live?.position ?? displayElevator.position
const rotation = live?.rotation ?? displayElevator.rotation
const center = { x: position[0], y: position[2] }
const wallThickness = Math.max(displayElevator.shaftWallThickness ?? 0.09, 0.04)
const cabWidth = Math.max(displayElevator.width, 0.8)
const cabDepth = Math.max(displayElevator.depth, 0.8)
const shaftWidth = Math.max(
displayElevator.shaftWidth ?? displayElevator.width,
cabWidth,
0.8,
)
const shaftDepth = Math.max(
displayElevator.shaftDepth ?? displayElevator.depth,
cabDepth,
0.8,
)
const doorWidth = Math.min(
Math.max(displayElevator.doorWidth, 0.45),
cabWidth - 0.18,
shaftWidth - 0.18,
)
const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness)
const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness)
const footprintCorners: Array<readonly [number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
]
const polygon = footprintCorners.map(([localX, localY]) => {
const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation)
return {
x: center.x + offsetX,
y: center.y + offsetY,
}
})
const frontStart = polygon[0]
const frontEnd = polygon[1]
if (!(frontStart && frontEnd)) {
return []
}
const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation)
const runtime = interactiveElevators[displayElevator.id]
const disabledLevelIds = new Set(displayElevator.disabledLevelIds ?? [])
const serviceOnlyLevelIds = new Set(displayElevator.serviceOnlyLevelIds ?? [])
const servedLevels = serviceLevelIds.flatMap((levelId) => {
const level = nodes[levelId as AnyNodeId]
if (level?.type !== 'level') {
return []
}
return [
{
id: level.id,
isCurrent: runtime?.currentLevelId === level.id,
isDisabled: disabledLevelIds.has(level.id),
isQueued: runtime?.queue.includes(level.id) ?? false,
isServiceOnly: serviceOnlyLevelIds.has(level.id),
isTarget: runtime?.targetLevelId === level.id,
label: level.name || `L${level.level}`,
},
]
})
return [
{
cabCenterLocalY: -shaftDepth / 2 + cabDepth / 2,
cabDepth,
cabWidth,
center,
doorStyle: displayElevator.doorStyle ?? 'center-opening',
doorWidth,
elevator: displayElevator,
frontEdge: {
start: frontStart,
end: frontEnd,
},
frontNormal: {
x: frontNormalX,
y: frontNormalY,
},
isCarOnLevel: runtime?.currentLevelId === levelNode.id,
isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false,
isTargetLevel: runtime?.targetLevelId === levelNode.id,
outerHalfDepth: halfDepth,
outerHalfWidth: halfWidth,
points: formatPolygonPoints(polygon),
polygon,
rotation,
servedLevels,
shaftDepth,
shaftWallThickness: wallThickness,
shaftWidth,
},
]
})
}, [
elevatorLiveOverrideKey,
elevatorRuntimeKey,
elevators,
levelNode,
movingFloorplanNodeRevision,
])
const referenceFloorLevel = useMemo(() => { const referenceFloorLevel = useMemo(() => {
if (!(showReferenceFloor && levelNode)) { if (!(showReferenceFloor && levelNode)) {
return null return null
@@ -9192,171 +8686,30 @@ export function FloorplanPanel() {
wallPolygons, wallPolygons,
} }
}, [referenceFloorDescendants, referenceFloorLevel]) }, [referenceFloorDescendants, referenceFloorLevel])
const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh) // Pending-mesh check was a flag the legacy active-level item entries
const floorplanStairEntries = useMemo( // raised when their polygon was the dimension fallback (waiting for
() => // the GLB to load to produce a tighter convex hull). Items are now
floorplanStairs.flatMap((stair) => { // registry-rendered, so the active-level entry list is always empty
const displayStair = // and this flag is permanently false.
movingNode?.type === 'stair' && movingNode.id === stair.id const hasPendingItemMeshFootprints = false
? (() => { // Stair fully registry-driven via `def.floorplan` (the parent walks
const live = useLiveTransforms.getState().get(stair.id) // its `stair-segment` children inside `buildStairFloorplan` to handle
const liveX = cursorPoint?.[0] ?? live?.position[0] ?? stair.position[0] // the cumulative-transform chain). `FloorplanRegistryLayer` renders
const liveZ = cursorPoint?.[1] ?? live?.position[2] ?? stair.position[2] // the result; this legacy list stays empty.
const liveRotation = live?.rotation ?? stair.rotation const floorplanStairEntries = useMemo<FloorplanStairEntry[]>(() => [], [])
// Roof / roof-segment fully registry-driven via def.floorplan.
return { const floorplanRoofEntries = useMemo<FloorplanRoofEntry[]>(() => [], [])
...stair, // Selection lookups against the legacy entry lists. The active-level
position: [liveX, stair.position[1], liveZ] as StairNode['position'], // door / window / item / spawn paths are registry-driven now, so each
rotation: liveRotation, // source array is permanently empty and the lookups always return
} // `null`. Selection chrome for those kinds comes from
})() // `FloorplanRegistryLayer` reading `viewState.selected`. Wrapping the
: stair // `null` in `useMemo` (instead of a bare literal) preserves the
const segments = (displayStair.children ?? []) // declared type at consumer sites — bare `null` would narrow to
.map((childId) => levelDescendantNodeById.get(childId as AnyNodeId)) // `never` after `if (!entry) return`, breaking every `entry.field` read.
.filter( const selectedOpeningEntry = useMemo<OpeningPolygonEntry | null>(() => null, [])
(node): node is StairSegmentNode => const selectedItemEntry = useMemo<FloorplanItemEntry | null>(() => null, [])
node?.type === 'stair-segment' && node.visible !== false, const selectedSpawnEntry = useMemo<FloorplanSpawnEntry | null>(() => null, [])
)
const entry = buildSharedFloorplanStairEntry(displayStair, segments)
if (!entry) {
return []
}
const hitPolygons =
(displayStair.stairType ?? 'straight') === 'straight'
? entry.segments.map((segmentEntry) => segmentEntry.polygon)
: [getFloorplanCurvedStairHitPolygon(displayStair)]
return [
{
...entry,
hitPolygons,
segments: entry.segments.map((segmentEntry) => ({
...segmentEntry,
innerPoints: formatPolygonPoints(segmentEntry.innerPolygon),
points: formatPolygonPoints(segmentEntry.polygon),
treadBars: segmentEntry.treadBars.map((polygon) => ({
points: formatPolygonPoints(polygon),
polygon,
})),
})),
},
]
}),
[
cursorPoint,
floorplanStairs,
levelDescendantNodeById,
movingFloorplanNodeRevision,
movingNode,
],
)
const floorplanRoofEntries = useMemo(
() =>
roofs.flatMap((roof) => {
const liveRoofTransform =
movingNode?.type === 'roof' && movingNode.id === roof.id
? useLiveTransforms.getState().get(roof.id)
: null
const liveRoofPosition = liveRoofTransform
? worldToBuildingLocalPlanPoint(
liveRoofTransform.position,
buildingPosition,
buildingRotationY,
)
: null
const displayRoof = liveRoofTransform
? {
...roof,
position: [
liveRoofPosition?.x ?? roof.position[0],
roof.position[1],
liveRoofPosition?.y ?? roof.position[2],
] as RoofNode['position'],
rotation: liveRoofTransform.rotation,
}
: roof
const segments = (displayRoof.children ?? [])
.map((childId) => levelDescendantNodeById.get(childId as AnyNodeId))
.filter(
(node): node is RoofSegmentNode =>
node?.type === 'roof-segment' && node.visible !== false,
)
.flatMap((segment) => {
const liveSegmentTransform =
movingNode?.type === 'roof-segment' && movingNode.id === segment.id
? useLiveTransforms.getState().get(segment.id)
: null
const worldPositionOverride = liveSegmentTransform
? worldToBuildingLocalPlanPoint(
liveSegmentTransform.position,
buildingPosition,
buildingRotationY,
)
: undefined
const polygon = getRoofSegmentPolygon(displayRoof, segment, {
localRotation: liveSegmentTransform?.rotation,
worldPositionOverride,
})
if (polygon.length < 3) {
return []
}
return [
{
segment,
polygon,
points: formatPolygonPoints(polygon),
ridgeLine: getRoofSegmentRidgeLine(displayRoof, segment, {
localRotation: liveSegmentTransform?.rotation,
worldPositionOverride,
}),
},
]
})
if (segments.length === 0) {
return []
}
return [
{
roof: displayRoof,
center: { x: displayRoof.position[0], y: displayRoof.position[2] },
segments,
},
]
}),
[
buildingPosition,
buildingRotationY,
levelDescendantNodeById,
movingFloorplanNodeRevision,
movingNode,
roofs,
],
)
const selectedOpeningEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
}
return openingsPolygons.find(({ opening }) => opening.id === selectedIds[0]) ?? null
}, [openingsPolygons, selectedIds])
const selectedItemEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
}
return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
}, [floorplanItemEntries, selectedIds])
const selectedSpawnEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
}
return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null
}, [floorplanSpawnEntries, selectedIds])
const selectedElevatorEntry = useMemo(() => { const selectedElevatorEntry = useMemo(() => {
if (selectedIds.length !== 1) { if (selectedIds.length !== 1) {
return null return null
@@ -11149,6 +10502,29 @@ export function FloorplanPanel() {
[theme], [theme],
) )
const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${theme}`, [theme]) const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${theme}`, [theme])
// Subset of the legacy palette surfaced to registry-driven kinds via
// <FloorplanRenderProvider>. Mirrors `FloorplanPalette` in `@pascal-app/
// core` — keep slot names + meanings in sync.
const floorplanRegistryPalette = useMemo<FloorplanRenderContextValue['palette']>(
() => ({
selectedStroke: palette.selectedStroke,
selectedFill: palette.selectedFill,
selectedHatch: palette.selectedStroke,
wallHoverStroke: palette.wallHoverStroke,
endpointHandleFill: palette.endpointHandleFill,
endpointHandleStroke: palette.endpointHandleStroke,
endpointHandleHoverStroke: palette.endpointHandleHoverStroke,
endpointHandleActiveFill: palette.endpointHandleActiveFill,
endpointHandleActiveStroke: palette.endpointHandleActiveStroke,
curveHandleFill: palette.curveHandleFill,
curveHandleStroke: palette.curveHandleStroke,
curveHandleHoverStroke: palette.curveHandleHoverStroke,
measurementStroke: palette.measurementStroke,
measurementLabelBackground: theme === 'dark' ? '#0f172a' : '#ffffff',
measurementLabelText: theme === 'dark' ? '#e2e8f0' : '#171717',
}),
[palette, theme],
)
const slabSelectionHatchId = useMemo(() => `floorplan-slab-selection-hatch-${theme}`, [theme]) const slabSelectionHatchId = useMemo(() => `floorplan-slab-selection-hatch-${theme}`, [theme])
const gridSteps = useMemo( const gridSteps = useMemo(
() => getVisibleGridSteps(viewBox.width, surfaceSize.width), () => getVisibleGridSteps(viewBox.width, surfaceSize.width),
@@ -13613,14 +12989,11 @@ export function FloorplanPanel() {
return return
} }
if (isFloorplanGridInteractionActive) { // Slab / zone polygon build — local draft state + grid emit, same
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event) // reordering rationale as `handleBackgroundPlacementClick`: must
setCursorPoint((previousPoint) => // run BEFORE the `isFloorplanGridInteractionActive` catch-all so
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, // the local polygon-draft state actually updates as the cursor
) // moves (the catch-all would otherwise swallow the move event).
return
}
if (isPolygonBuildActive) { if (isPolygonBuildActive) {
const snappedPoint = snapPolygonDraftPoint({ const snappedPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
@@ -13628,6 +13001,10 @@ export function FloorplanPanel() {
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
}) })
// Emit `grid:move` so the registry-driven slab tool also tracks
// the cursor (its 3D preview needs it).
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) => { setCursorPoint((previousPoint) => {
const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint)) const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint))
if (hasChanged && activePolygonDraftPoints.length > 0) { if (hasChanged && activePolygonDraftPoints.length > 0) {
@@ -13638,6 +13015,19 @@ export function FloorplanPanel() {
return return
} }
// Wall build also needs to run before the catch-all — see the
// wall branch in `handleBackgroundPlacementClick` for the same
// restructuring. The wall branch lives further below in this
// handler (`if (!isWallBuildActive) ... setDraftEnd(...)`); the
// grid emit is inlined there.
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event)
setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
)
return
}
if (isOpeningPlacementActive) { if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls, { const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall), canUseWall: (wall) => !isCurvedWall(wall),
@@ -13694,6 +13084,10 @@ export function FloorplanPanel() {
angleSnap: Boolean(draftStart) && !shiftPressed, angleSnap: Boolean(draftStart) && !shiftPressed,
}) })
// Emit `grid:move` so the registry-driven wall tool's 3D preview
// tracks the cursor. The local draftEnd update below is what
// drives the 2D draft polygon — both views update in parallel.
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
if (!draftStart) { if (!draftStart) {
@@ -17720,8 +17114,20 @@ export function FloorplanPanel() {
their SVG via <FloorplanGeometryRenderer>. Sits above the their SVG via <FloorplanGeometryRenderer>. Sits above the
legacy inline content so newly-registered kinds (shelf legacy inline content so newly-registered kinds (shelf
today) overlay on top until their inline equivalent is today) overlay on top until their inline equivalent is
removed in their Phase 5 migration PR. */} removed in their Phase 5 migration PR.
<FloorplanRegistryLayer />
Wrapped in <FloorplanRenderProvider> so registry-driven
kinds receive the same themed palette / units-per-pixel
the legacy layers compute. The hatch pattern id is the
legacy wall hatch kinds that opt into selection hatch
fills reuse this <defs> pattern via fill="url(...)". */}
<FloorplanRenderProvider
hatchPatternId={wallSelectionHatchId}
palette={floorplanRegistryPalette}
unitsPerPixel={floorplanUnitsPerPixel}
>
<FloorplanRegistryLayer />
</FloorplanRenderProvider>
{/* Cursor-driven placement ghost for movingNode when the {/* Cursor-driven placement ghost for movingNode when the
active kind is registry-driven. Renders via a portal active kind is registry-driven. Renders via a portal
into the floor-plan scene <g> (the data-floorplan-scene into the floor-plan scene <g> (the data-floorplan-scene
@@ -17,6 +17,7 @@ import {
type RoofSegmentEvent, type RoofSegmentEvent,
resolveLevelId, resolveLevelId,
resolveMaterial, resolveMaterial,
type ShelfNode,
type SlabNode, type SlabNode,
type StairEvent, type StairEvent,
type StairNode, type StairNode,
@@ -341,7 +342,7 @@ function applyStairPaintPreview(
} }
function applySingleSurfacePaintPreview( function applySingleSurfacePaintPreview(
node: FenceNode | ColumnNode | SlabNode | CeilingNode, node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
material: ActivePaintMaterial, material: ActivePaintMaterial,
): PaintPreviewCleanup | null { ): PaintPreviewCleanup | null {
if (node.type === 'ceiling') { if (node.type === 'ceiling') {
@@ -409,6 +410,23 @@ function applySingleSurfacePaintPreview(
} }
} }
if (node.type === 'shelf') {
// Shelf is a registered Group, not a Mesh. Traverse children and
// preview-swap every child mesh — same approach `column` uses.
if (!registeredObject) return null
const restores: PaintPreviewCleanup[] = []
registeredObject.traverse((object) => {
if (!(object as Mesh).isMesh) return
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
if (!mesh) return null if (!mesh) return null
if (node.type === 'slab') { if (node.type === 'slab') {
@@ -934,7 +952,8 @@ export const SelectionManager = () => {
node.type === 'fence' || node.type === 'fence' ||
node.type === 'column' || node.type === 'column' ||
node.type === 'slab' || node.type === 'slab' ||
node.type === 'ceiling' node.type === 'ceiling' ||
node.type === 'shelf'
) { ) {
const compatible = hasActivePaintMaterial(activePaintMaterial) const compatible = hasActivePaintMaterial(activePaintMaterial)
@@ -949,7 +968,7 @@ export const SelectionManager = () => {
.updateNode( .updateNode(
node.id as AnyNodeId, node.id as AnyNodeId,
buildSingleSurfaceMaterialPatch< buildSingleSurfaceMaterialPatch<
FenceNode | ColumnNode | SlabNode | CeilingNode FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
>(activePaintMaterial.material, activePaintMaterial.materialPreset), >(activePaintMaterial.material, activePaintMaterial.materialPreset),
) )
} }
@@ -957,7 +976,7 @@ export const SelectionManager = () => {
preview: compatible preview: compatible
? () => ? () =>
applySingleSurfacePaintPreview( applySingleSurfacePaintPreview(
node as FenceNode | ColumnNode | SlabNode | CeilingNode, node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
activePaintMaterial, activePaintMaterial,
) )
: () => previewCursor('not-allowed'), : () => previewCursor('not-allowed'),
@@ -1193,7 +1212,10 @@ export const SelectionManager = () => {
} }
if ( if (
(node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') && (node.type === 'fence' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'shelf') &&
nodeToSelect.type === node.type nodeToSelect.type === node.type
) { ) {
setSelectedMaterialTargetForNode(nodeToSelect, 'surface') setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
@@ -207,7 +207,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const restoreNodeVisibility = (() => { const restoreNodeVisibility = (() => {
const saved = new Map<THREE.Object3D, boolean>() const saved = new Map<THREE.Object3D, boolean>()
for (const type of ['scan', 'guide'] as const) { for (const type of ['scan', 'guide'] as const) {
const ids = sceneRegistry.byType[type] const ids = sceneRegistry.byType[type]!
ids.forEach((id) => { ids.forEach((id) => {
const node = sceneRegistry.nodes.get(id) const node = sceneRegistry.nodes.get(id)
if (node) { if (node) {
@@ -179,12 +179,11 @@ export function useFloorplanBackgroundPlacement({
return true return true
} }
if (isFloorplanGridInteractionActive) { // Slab / zone polygon build — local draft state + grid emit.
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event) // Must run BEFORE the `isFloorplanGridInteractionActive` catch-all
setCursorPoint(snappedPoint) // (since slab is registry-driven, the catch-all would otherwise
return true // swallow the click and skip local draft state updates — leaving
} // the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) { if (isPolygonBuildActive) {
const snappedPoint = snapPolygonDraftPoint({ const snappedPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
@@ -192,6 +191,13 @@ export function useFloorplanBackgroundPlacement({
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
}) })
// Emit the grid event so the registry-driven slab tool also
// sees the click (parity with ceiling / fence / roof branches
// above). Zone has no registry tool — emit-or-not is irrelevant.
if (!isZoneBuildActive) {
emitFloorplanGridEvent('click', snappedPoint, event)
}
if (isZoneBuildActive) { if (isZoneBuildActive) {
handleZonePlacementPoint(snappedPoint) handleZonePlacementPoint(snappedPoint)
} else { } else {
@@ -200,19 +206,34 @@ export function useFloorplanBackgroundPlacement({
return true return true
} }
if (!isWallBuildActive) { // Wall placement — local draft state + grid emit. Same reasoning
return false // as slab above: wall is registry-driven, so without this branch
// the catch-all would swallow the click and the local draftStart
// / draftEnd state in the floor plan would never update, leaving
// the dashed-line draft preview invisible.
if (isWallBuildActive) {
const snappedPoint = snapWallDraftPoint({
point: planPoint,
walls,
start: draftStart ?? undefined,
angleSnap: Boolean(draftStart) && !shiftPressed,
})
emitFloorplanGridEvent('click', snappedPoint, event)
handleWallPlacementPoint(snappedPoint)
return true
} }
const snappedPoint = snapWallDraftPoint({ // Generic catch-all — registry-driven tool whose kind has no
point: planPoint, // local floor-plan draft handler (column / spawn / shelf / etc.).
walls, // The tool's `grid:click` subscriber owns the placement.
start: draftStart ?? undefined, if (isFloorplanGridInteractionActive) {
angleSnap: Boolean(draftStart) && !shiftPressed, const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
}) setCursorPoint(snappedPoint)
return true
}
handleWallPlacementPoint(snappedPoint) return false
return true
}, },
[ [
activePolygonDraftPoints, activePolygonDraftPoints,
@@ -46,7 +46,7 @@ export const CeilingSystem = () => {
} }
} }
const ceilings = sceneRegistry.byType.ceiling const ceilings = sceneRegistry.byType.ceiling!
ceilings.forEach((ceiling) => { ceilings.forEach((ceiling) => {
const mesh = sceneRegistry.nodes.get(ceiling) const mesh = sceneRegistry.nodes.get(ceiling)
if (mesh) { if (mesh) {
@@ -1,43 +0,0 @@
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingBoundaryEditorProps {
ceilingId: CeilingNode['id']
}
/**
* Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ceilingId }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(ceilingId, { polygon: newPolygon })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, updateNode, setSelection],
)
if (!ceiling?.polygon || ceiling.polygon.length < 3) return null
return (
<PolygonEditor
allowEdgeMove
color="#d4d4d4"
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
minVertices={3}
onPolygonChange={handlePolygonChange}
polygon={ceiling.polygon}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -1,49 +0,0 @@
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingHoleEditorProps {
ceilingId: CeilingNode['id']
holeIndex: number
}
/**
* Ceiling hole editor - allows editing a specific hole polygon within a ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId, holeIndex }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const holes = ceiling?.holes || []
const hole = holes[holeIndex]
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
const updatedHoles = [...holes]
updatedHoles[holeIndex] = newPolygon
updateNode(ceilingId, { holes: updatedHoles })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, holeIndex, holes, updateNode, setSelection],
)
if (!(ceiling && hole) || hole.length < 3) return null
return (
<PolygonEditor
allowEdgeMove
allowPolygonMove
color="#ef4444"
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
minVertices={3}
onPolygonChange={handlePolygonChange}
polygon={hole}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -1,465 +0,0 @@
import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { mix, positionLocal } from 'three/tsl'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02
/**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
*/
const calculateSnapPoint = (
lastPoint: [number, number],
currentPoint: [number, number],
): [number, number] => {
const [x1, y1] = lastPoint
const [x, y] = currentPoint
const dx = x - x1
const dy = y - y1
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
// Calculate distances to horizontal, vertical, and diagonal lines
const horizontalDist = absDy
const verticalDist = absDx
const diagonalDist = Math.abs(absDx - absDy)
// Find the minimum distance to determine which axis to snap to
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
if (minDist === diagonalDist) {
// Snap to 45° diagonal
const diagonalLength = Math.min(absDx, absDy)
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
}
if (minDist === horizontalDist) {
// Snap to horizontal
return [x, y1]
}
// Snap to vertical
return [x1, y]
}
/**
* Creates a ceiling with the given polygon points and returns its ID
*/
const commitCeilingDrawing = (
levelId: LevelNode['id'],
points: Array<[number, number]>,
): string => {
const { createNode, nodes } = useScene.getState()
// Count existing ceilings for naming
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
const name = `Ceiling ${ceilingCount + 1}`
const ceiling = CeilingNode.parse({
name,
polygon: points,
})
createNode(ceiling, levelId)
sfxEmitter.emit('sfx:structure-build')
return ceiling.id
}
export const CeilingTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const gridCursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const groundMainLineRef = useRef<Line>(null!)
const groundClosingLineRef = useRef<Line>(null!)
const verticalLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const [points, setPoints] = useState<Array<[number, number]>>([])
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY
const verticalGeo = useMemo(
() =>
new BufferGeometry().setFromPoints([
new Vector3(0, 0, 0),
new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0),
]),
[],
)
// opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0
const gradientOpacityNode = useMemo(
() => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
[],
)
// Update cursor position and lines on grid move
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && gridCursorRef.current)) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.localPosition[1])
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
const gridY = event.localPosition[1] + GRID_OFFSET
// Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1]
const displayPoint =
shiftPressed.current || !lastPoint
? gridPosition
: calculateSnapPoint(lastPoint, gridPosition)
setSnappedCursorPosition(displayPoint)
// Play snap sound when the snapped position actually changes (only when drawing)
if (
points.length > 0 &&
previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
displayPoint[1] !== previousSnappedPointRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
if (verticalLineRef.current) {
verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
}
}
const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Use the last displayed snapped position (respects Shift state from onGridMove)
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
// Check if clicking on the first point to close the shape
const firstPoint = points[0]
if (
points.length >= 3 &&
firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
// Create the ceiling and select it
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
} else {
// Add point to polygon
setPoints([...points, clickPoint])
}
}
const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Need at least 3 points to form a polygon
if (points.length >= 3) {
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
}
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
}
}, [currentLevelId, points, cursorPosition, setSelection])
// Update line geometries when points change
useEffect(() => {
if (!(mainLineRef.current && closingLineRef.current)) return
if (points.length === 0) {
mainLineRef.current.visible = false
closingLineRef.current.visible = false
return
}
const ceilingY = levelY + CEILING_HEIGHT
const snappedCursor = snappedCursorPosition
// Build main line points
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
const gridY = levelY + GRID_OFFSET
const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))
groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))
// Update main line
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true
groundMainLineRef.current.geometry.dispose()
groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
groundMainLineRef.current.visible = true
} else {
mainLineRef.current.visible = false
groundMainLineRef.current.visible = false
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && firstPoint) {
const closingPoints = [
new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
const groundClosingPoints = [
new Vector3(snappedCursor[0], gridY, snappedCursor[1]),
new Vector3(firstPoint[0], gridY, firstPoint[1]),
]
groundClosingLineRef.current.geometry.dispose()
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(
groundClosingPoints,
)
groundClosingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
groundClosingLineRef.current.visible = false
}
}, [points, snappedCursorPosition, levelY])
// Create preview shape when we have 3+ points
const previewShape = useMemo(() => {
if (points.length < 3) return null
const snappedCursor = snappedCursorPosition
const allPoints = [...points, snappedCursor]
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
// - Shape X -> World X
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
const firstPt = allPoints[0]
if (!firstPt) return null
const shape = new Shape()
shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i]
if (pt) {
shape.lineTo(pt[0], -pt[1])
}
}
shape.closePath()
return shape
}, [points, snappedCursorPosition])
return (
<group>
{/* Cursor at ceiling height */}
<CursorSphere ref={cursorRef} />
{/* Grid-level cursor indicator */}
<mesh
layers={EDITOR_LAYER}
ref={gridCursorRef}
renderOrder={2}
rotation={[-Math.PI / 2, 0, 0]}
>
<ringGeometry args={[0.15, 0.2, 32]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
depthWrite={true}
opacity={0.5}
side={DoubleSide}
transparent
/>
</mesh>
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
{/* @ts-ignore */}
<line geometry={verticalGeo} layers={EDITOR_LAYER} ref={verticalLineRef} renderOrder={1}>
<lineBasicNodeMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
opacityNode={gradientOpacityNode}
transparent
/>
</line>
{/* Preview fill (Top) */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + CEILING_HEIGHT, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Preview fill (Ground) */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + GRID_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.1}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Main line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={mainLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
</line>
{/* Closing line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={closingLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.5}
transparent
/>
</line>
{/* Ground main line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={groundMainLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
linewidth={3}
opacity={0.3}
transparent
/>
</line>
{/* Ground closing line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={groundClosingLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.15}
transparent
/>
</line>
{/* Point markers */}
{points.map(([x, z], index) => (
<CursorSphere
color="#818cf8"
key={index}
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
showTooltip={false}
/>
))}
</group>
)
}
@@ -1,264 +0,0 @@
'use client'
import {
type AnyNodeId,
type CeilingNode,
emitter,
type GridEvent,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
deltaZ: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
}
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
const originalHolesRef = useRef(
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const previousCursorPosRef = useRef<[number, number, number] | null>(null)
const previousDeltaRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{
polygon: Array<[number, number]>
holes: Array<Array<[number, number]>>
} | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const center = getPolygonCenter(node.polygon)
return [center[0], node.height ?? 2.5, center[1]]
})
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
) => {
previewRef.current = { polygon, holes }
setPreviewPolygon(polygon)
setPreviewHoles(holes)
const center = getPolygonCenter(polygon)
const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
if (
!previousCursorPosRef.current ||
previousCursorPosRef.current[0] !== nextCursorPos[0] ||
previousCursorPosRef.current[1] !== nextCursorPos[1] ||
previousCursorPosRef.current[2] !== nextCursorPos[2]
) {
previousCursorPosRef.current = nextCursorPos
setCursorLocalPos(nextCursorPos)
}
useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId)
}
const restoreOriginal = () => {
setPreviewPolygon(originalPolygon)
setPreviewHoles(originalHoles)
useScene.getState().updateNode(node.id, {
holes: originalHoles,
polygon: originalPolygon,
})
useScene.getState().markDirty(node.id as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
if (
previousDeltaRef.current &&
previousDeltaRef.current[0] === deltaX &&
previousDeltaRef.current[1] === deltaZ
) {
return
}
previousDeltaRef.current = [deltaX, deltaZ]
applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(node.id, {
polygon: originalPolygon,
holes: originalHoles,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [node.id] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node.height, node.id])
const previewFillGeometry = useMemo(
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
[previewHoles, previewPolygon],
)
const previewOutlineGeometry = useMemo(
() => createCeilingOutlineGeometry(previewPolygon),
[previewPolygon],
)
return (
<group>
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
<meshBasicMaterial
color="#f5f5f4"
depthWrite={false}
opacity={0.3}
side={DoubleSide}
transparent
/>
</mesh>
{/* @ts-ignore */}
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
</line>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
function createCeilingPreviewGeometry(
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
): BufferGeometry {
if (polygon.length < 3) return new BufferGeometry()
const shape = new Shape()
const [firstX, firstZ] = polygon[0]!
shape.moveTo(firstX, -firstZ)
for (let i = 1; i < polygon.length; i++) {
const [x, z] = polygon[i]!
shape.lineTo(x, -z)
}
shape.closePath()
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
const hole = new Path()
const [hx, hz] = holePolygon[0]!
hole.moveTo(hx, -hz)
for (let i = 1; i < holePolygon.length; i++) {
const [x, z] = holePolygon[i]!
hole.lineTo(x, -z)
}
hole.closePath()
shape.holes.push(hole)
}
const geometry = new ShapeGeometry(shape)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
return geometry
}
function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
const geometry = new BufferGeometry()
if (polygon.length < 2) return geometry
const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
const [firstX, firstZ] = polygon[0]!
points.push(new Vector3(firstX, 0, firstZ))
geometry.setFromPoints(points)
return geometry
}
@@ -1,178 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
pauseSceneHistory,
resumeSceneHistory,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = useCallback(() => {
useEditor.getState().setCurvingFence(null)
}, [])
useEffect(() => {
const nodeId = node.id
const originalCurveOffset = originalCurveOffsetRef.current
const chord = getWallChordFrame(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyPreview = (curveOffset: number) => {
if (previewOffsetRef.current === curveOffset) {
return
}
previewOffsetRef.current = curveOffset
const nextNode = {
...node,
curveOffset,
}
const handlePoint = getWallMidpointHandlePoint(nextNode)
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const restoreOriginal = () => {
if (previewOffsetRef.current === originalCurveOffset) {
return
}
previewOffsetRef.current = originalCurveOffset
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const snapStep = getWallGridStep()
const localX = shiftPressedRef.current
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if (
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousCurveOffsetRef.current = nextCurveOffset
applyPreview(nextCurveOffset)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const curveOffset = previewOffsetRef.current
wasCommitted = true
if (curveOffset !== originalCurveOffset) {
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
resumeSceneHistory(useScene)
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
pauseSceneHistory(useScene)
}
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitCurveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitCurveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitCurveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,346 +0,0 @@
import {
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import {
createFenceOnCurrentLevel,
type FencePlanPoint,
snapFenceDraftPoint,
} from './fence-drafting'
const FENCE_PREVIEW_HEIGHT = 1.8
const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22
const DRAFT_ANGLE_LABEL_Y = 0.28
type DraftAngleLabel = {
id: string
label: string
position: [number, number, number]
}
type DraftMeasurementState = {
lengthLabel: string
lengthPosition: [number, number, number]
angleLabels: DraftAngleLabel[]
} | null
type SegmentLike = {
id: string
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
function getDraftAngleLabels(
start: FencePlanPoint,
end: FencePlanPoint,
segments: SegmentLike[],
): DraftAngleLabel[] {
const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
const endpoints = [
{ id: 'start', point: start, draftVector: draftFromStart },
{ id: 'end', point: end, draftVector: draftFromEnd },
]
const labels: DraftAngleLabel[] = []
for (const endpoint of endpoints) {
const connectedSegment = segments.find((segment) =>
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
)
if (!connectedSegment) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
if (angle === null) continue
labels.push({
id: endpoint.id,
label: formatAngleRadians(angle),
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
})
}
return labels
}
function getDraftMeasurementState(
start: FencePlanPoint,
end: FencePlanPoint,
segments: SegmentLike[],
unit: 'metric' | 'imperial',
): DraftMeasurementState {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 0.01) return null
return {
lengthLabel: formatMeasurement(length, unit),
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
angleLabels: getDraftAngleLabels(start, end, segments),
}
}
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
return [
...walls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...fences.map((fence) => ({
id: fence.id,
start: fence.start,
end: fence.end,
curveOffset: fence.curveOffset,
})),
]
}
const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < 0.01) {
mesh.visible = false
return
}
mesh.visible = true
direction.normalize()
const shape = new Shape()
shape.moveTo(0, 0)
shape.lineTo(length, 0)
shape.lineTo(length, FENCE_PREVIEW_HEIGHT)
shape.lineTo(0, FENCE_PREVIEW_HEIGHT)
shape.closePath()
const geometry = new ShapeGeometry(shape)
const angle = -Math.atan2(direction.z, direction.x)
mesh.position.set(start.x, start.y, start.z)
mesh.rotation.y = angle
if (mesh.geometry) {
mesh.geometry.dispose()
}
mesh.geometry = geometry
}
const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } => {
const currentLevelId = useViewer.getState().selection.levelId
const { nodes } = useScene.getState()
if (!currentLevelId) return { walls: [], fences: [] }
const levelNode = nodes[currentLevelId]
if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] }
const children = (levelNode as LevelNode).children.map((childId) => nodes[childId])
return {
walls: children.filter((node): node is WallNode => node?.type === 'wall'),
fences: children.filter((node): node is FenceNode => node?.type === 'fence'),
}
}
export const FenceTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const cursorRef = useRef<Group>(null)
const previewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
useEffect(() => {
let previousFenceEnd: [number, number] | null = null
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && previewRef.current)) return
const { walls, fences } = getCurrentLevelElements()
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
if (buildingState.current === 1) {
const snappedLocal = snapFenceDraftPoint({
point: localPoint,
walls,
fences,
start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current,
})
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current)
const currentFenceEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if (
previousFenceEnd &&
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousFenceEnd = currentFenceEnd
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
setDraftMeasurement(
getDraftMeasurementState(
[startingPoint.current.x, startingPoint.current.z],
snappedLocal,
getReferenceSegments(walls, fences),
unit,
),
)
} else {
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
setDraftMeasurement(null)
}
}
const onGridClick = (event: GridEvent) => {
const { walls, fences } = getCurrentLevelElements()
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
if (buildingState.current === 0) {
const snappedStart = snapFenceDraftPoint({ point: localClick, walls, fences })
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
previewRef.current.visible = true
setDraftMeasurement(null)
} else {
const snappedEnd = snapFenceDraftPoint({
point: localClick,
walls,
fences,
start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current,
})
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
previewRef.current.visible = false
buildingState.current = 0
setDraftMeasurement(null)
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
const onCancel = () => {
if (buildingState.current === 1) {
markToolCancelConsumed()
buildingState.current = 0
previewRef.current.visible = false
setDraftMeasurement(null)
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [unit])
return (
<group>
<CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
<shapeGeometry />
<meshBasicMaterial
color="#ffffff"
depthTest={false}
depthWrite={false}
opacity={0.45}
side={DoubleSide}
transparent
/>
</mesh>
{draftMeasurement && (
<>
<DraftMeasurementLabel
label={draftMeasurement.lengthLabel}
position={draftMeasurement.lengthPosition}
/>
{draftMeasurement.angleLabels.map((angleLabel) => (
<DraftMeasurementLabel
key={angleLabel.id}
label={angleLabel.label}
position={angleLabel.position}
/>
))}
</>
)}
</group>
)
}
function DraftMeasurementLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -1,425 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import { isWallLongEnough } from '../wall/wall-drafting'
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return (
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
)
}
type SegmentLike = {
id: string
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
type AngleLabelState = {
label: string
position: [number, number, number]
} | null
function getEndpointAngleLabel(args: {
preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number }
segments: SegmentLike[]
nodeId: FenceNode['id']
}): AngleLabelState {
const { preview, segments, nodeId } = args
const endpoints = [
{
point: preview.start,
},
{
point: preview.end,
},
]
const targetSegment: SegmentLike = {
id: nodeId,
start: preview.start,
end: preview.end,
curveOffset: preview.curveOffset,
}
for (const endpoint of endpoints) {
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
if (!targetReference) continue
const connectedSegment = segments.find(
(segment) =>
segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
)
if (!connectedSegment) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
if (angle === null) continue
return {
label: formatAngleRadians(angle),
position: [endpoint.point[0], 0.34, endpoint.point[1]],
}
}
return null
}
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
return [
...walls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...fences.map((fence) => ({
id: fence.id,
start: fence.start,
end: fence.end,
curveOffset: fence.curveOffset,
})),
]
}
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id']
fenceParentId: string | null
linkedPoint: FencePlanPoint
}) {
const { fenceId, fenceParentId, linkedPoint } = args
const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'fence' && node.id !== fenceId)) {
continue
}
if ((node.parentId ?? null) !== fenceParentId) {
continue
}
if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as FencePlanPoint,
end: [...node.end] as FencePlanPoint,
curveOffset: node.curveOffset,
})
}
return snapshots
}
function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[],
linkedPoint: FencePlanPoint,
nextLinkedPoint: FencePlanPoint,
) {
return linkedFences.map((fence) => ({
id: fence.id,
curveOffset: fence.curveOffset,
start: samePoint(fence.start, linkedPoint) ? nextLinkedPoint : fence.start,
end: samePoint(fence.end, linkedPoint) ? nextLinkedPoint : fence.end,
}))
}
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.fence.id)
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
const originalMovingPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.start] as FencePlanPoint)
: ([...target.fence.end] as FencePlanPoint),
)
const fixedPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.end] as FencePlanPoint)
: ([...target.fence.start] as FencePlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({
fenceId: target.fence.id,
fenceParentId: target.fence.parentId ?? null,
linkedPoint: target.endpoint === 'start' ? target.fence.start : target.fence.end,
}),
)
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
return [point[0], 0, point[1]]
})
const [altPressed, setAltPressed] = useState(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingFenceEndpoint(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const originalMovingPoint = originalMovingPointRef.current
const fixedPoint = fixedPointRef.current
const siblings = Object.values(useScene.getState().nodes)
const levelWalls = siblings.filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
const levelFences = siblings.filter(
(node): node is FenceNode =>
node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedFences
? []
: getLinkedFenceUpdates(linkedOriginalsRef.current, originalMovingPoint, movingPoint)
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
setAngleLabel(
getEndpointAngleLabel({
preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
nodeId,
}),
)
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
}
const restoreOriginal = (clearAngleLabel = true) => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
if (clearAngleLabel) {
setAngleLabel(null)
}
}
const onGridMove = (event: GridEvent) => {
const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const snappedPoint = snapFenceDraftPoint({
point: planPoint,
walls: levelWalls,
fences: levelFences,
start: fixedPoint,
angleSnap: !shiftPressedRef.current,
ignoreFenceIds: [nodeId],
})
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
applyPreview(snappedPoint, event.nativeEvent.altKey)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged = !(
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
resumeSceneHistory(useScene)
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalMovingPoint,
target.endpoint === 'start' ? preview.start : preview.end,
)),
])
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
setAngleLabel(null)
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
setAngleLabel(null)
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
if (event.key === 'Alt') {
altPressedRef.current = true
setAltPressed(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
if (event.key === 'Alt') {
altPressedRef.current = false
setAltPressed(false)
}
}
const onWindowBlur = () => {
shiftPressedRef.current = false
altPressedRef.current = false
setAltPressed(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
return () => {
if (!wasCommitted) {
restoreOriginal(false)
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
}
}, [exitMoveMode, target])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
: 'border-border/70 bg-background/90 text-foreground/80'
}`}
>
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
</div>
</div>
</Html>
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
</group>
)
}
function EndpointAngleLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -1,302 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
sceneRegistry,
useLiveTransforms,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import type * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { snapFenceDraftPoint } from './fence-drafting'
function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: [number, number]
end: [number, number]
}
function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id']
fenceParentId: string | null
originalStart: [number, number]
originalEnd: [number, number]
}) {
const { fenceId, fenceParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'fence' && node.id !== fenceId)) {
continue
}
if ((node.parentId ?? null) !== fenceParentId) {
continue
}
if (
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as [number, number],
end: [...node.end] as [number, number],
})
}
return snapshots
}
function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[],
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
return linkedFences.map((fence) => ({
id: fence.id,
start: samePoint(fence.start, originalStart)
? nextStart
: samePoint(fence.start, originalEnd)
? nextEnd
: fence.start,
end: samePoint(fence.end, originalStart)
? nextStart
: samePoint(fence.end, originalEnd)
? nextEnd
: fence.end,
}))
}
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({
fenceId: node.id,
fenceParentId: node.parentId ?? null,
originalStart: node.start,
originalEnd: node.end,
}),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const centerX = (node.start[0] + node.end[0]) / 2
const centerZ = (node.start[1] + node.end[1]) / 2
return [centerX, 0, centerZ]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is WallNode => child?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause()
let wasCommitted = false
const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
if (!mesh) {
return
}
mesh.position.set(deltaX, 0, deltaZ)
}
const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
const originalCenterX = (fence.start[0] + fence.end[0]) / 2
const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
useLiveTransforms.getState().set(fence.id, {
position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
rotation: 0,
})
}
const clearPreviewState = () => {
setMeshOffset(nodeId, 0, 0)
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
setMeshOffset(linkedFence.id, 0, 0)
useLiveTransforms.getState().clear(linkedFence.id)
}
}
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
const deltaX = nextStart[0] - originalStart[0]
const deltaZ = nextStart[1] - originalStart[1]
setMeshOffset(nodeId, deltaX, deltaZ)
setFenceLiveTransform(node, deltaX, deltaZ)
for (const linkedFence of linkedOriginalsRef.current) {
setMeshOffset(linkedFence.id, deltaX, deltaZ)
setFenceLiveTransform(
{
...node,
id: linkedFence.id,
start: linkedFence.start,
end: linkedFence.end,
},
deltaX,
deltaZ,
)
}
}
const onGridMove = (event: GridEvent) => {
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
ignoreFenceIds: [nodeId],
})
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
applyPreview(nextStart, nextEnd)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true
useScene.temporal.getState().resume()
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
),
])
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id)
}
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
clearPreviewState()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (wasCommitted) {
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id)
}
} else {
clearPreviewState()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,32 +0,0 @@
import type { AssetInput } from '@pascal-app/core'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) {
const draftNode = useDraftNode()
const cursor = usePlacementCoordinator({
asset: selectedItem,
draftNode,
initDraft: (gridPosition) => {
if (selectedItem && !selectedItem.attachTo) {
draftNode.create(gridPosition, selectedItem)
}
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
return true
},
})
return <>{cursor}</>
}
export const ItemTool: React.FC = () => {
const selectedItem = useEditor((state) => state.selectedItem)
if (!selectedItem) return null
return <ItemPlacementContent selectedItem={selectedItem} />
}
@@ -1,132 +1,50 @@
import type { import type {
AnyNodeId, AnyNodeId,
BuildingNode, BuildingNode,
CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode, ElevatorNode,
FenceNode,
ItemNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
SlabNode,
SpawnNode, SpawnNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
WallNode,
WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { nodeRegistry } from '@pascal-app/core' import { nodeRegistry } from '@pascal-app/core'
import { Suspense } from 'react' import { Suspense } from 'react'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool' import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
import { MoveColumnTool } from '../column/move-column-tool'
import { MoveDoorTool } from '../door/move-door-tool'
import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool'
import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveRoofTool } from '../roof/move-roof-tool'
import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
import { MoveSlabTool } from '../slab/move-slab-tool'
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
import { MoveWallTool } from '../wall/move-wall-tool'
import { MoveWindowTool } from '../window/move-window-tool'
import type { PlacementState } from './placement-types'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
function getInitialState(node: {
asset: { attachTo?: string }
parentId: string | null
}): PlacementState {
const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null }
}
if (attachTo === 'ceiling') {
return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null }
}
return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
}
function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
const draftNode = useDraftNode()
const meta =
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
? (movingNode.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const cursor = usePlacementCoordinator({
asset: movingNode.asset,
draftNode,
// Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
initialState: isNew
? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
: getInitialState(movingNode),
// Preserve the original item's scale so Y-position calculations use the correct height
defaultScale: isNew ? movingNode.scale : undefined,
initDraft: (gridPosition) => {
if (isNew) {
// Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.
// Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.
gridPosition.copy(new Vector3(...movingNode.position))
if (!movingNode.asset.attachTo) {
draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale)
}
} else {
draftNode.adopt(movingNode)
gridPosition.copy(new Vector3(...movingNode.position))
}
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
useEditor.getState().setMovingNode(null)
return false
},
onCancel: () => {
draftNode.destroy()
useEditor.getState().setMovingNode(null)
},
})
return <>{cursor}</>
}
/**
* MoveTool dispatcher. Routes to (in order):
*
* 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
* declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
* …).
* 2. `def.affordanceTools.move` — kind-owned move component
* (slab / ceiling / wall / fence / column / item / door / window).
* Lazy-loaded via `getRegistryAffordanceTool`.
* 3. The narrow set of kinds that still have legacy movers because no
* registry equivalent has been written yet (building / elevator /
* roof / stair). Each of these has bespoke move semantics that
* don't fit the generic mover and are not yet ported to a
* kind-owned affordance.
*/
export const MoveTool: React.FC<{ export const MoveTool: React.FC<{
onNodeMoved?: (nodeId: AnyNodeId) => void onNodeMoved?: (nodeId: AnyNodeId) => void
onSpawnMoved?: (nodeId: SpawnNode['id']) => void onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onNodeMoved, onSpawnMoved }) => { }> = ({ onNodeMoved }) => {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null if (!movingNode) return null
// Capability-driven dispatch. A registered kind opts INTO the generic
// mover by declaring `capabilities.movable` — that's the "I'm a simple
// translate-on-the-X/Z-plane node" signal (shelf, spawn, future
// single-position items). Kinds with bespoke move semantics (wall
// endpoint drag + linked-wall corner cascade + ALT-detach, fence
// endpoint drag + curve sagitta, slab polygon vertex edit, stair
// endpoint drag, etc.) deliberately OMIT `capabilities.movable` so
// this branch falls through to their legacy per-kind movers below.
//
// Without this guard, every registered kind would be force-routed
// through MoveRegistryNodeTool's "translate position" pattern,
// breaking wall / fence / slab / stair endpoint UX (the smart
// sims-style arrows that move the dragged endpoint while cascading
// to linked walls / re-anchoring hosted children / etc.).
const def = nodeRegistry.get(movingNode.type) const def = nodeRegistry.get(movingNode.type)
if (def?.capabilities?.movable) { if (def?.capabilities?.movable) {
return <MoveRegistryNodeTool node={movingNode} /> return <MoveRegistryNodeTool node={movingNode} />
} }
// Phase 5 Stage D: registry-driven move affordance (kind-owned
// `DragAction` with bespoke semantics). Falls through to the legacy
// per-kind chain below when the kind hasn't ported its move tool.
const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move') const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
if (RegistryMove) { if (RegistryMove) {
return ( return (
@@ -138,20 +56,11 @@ export const MoveTool: React.FC<{
if (movingNode.type === 'building') if (movingNode.type === 'building')
return <MoveBuildingContent node={movingNode as BuildingNode} /> return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'elevator') if (movingNode.type === 'elevator')
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} /> return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} /> return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'spawn')
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} /> return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} /> return null
} }
@@ -6,12 +6,15 @@ import type {
GridEvent, GridEvent,
ItemEvent, ItemEvent,
ItemNode, ItemNode,
ShelfEvent,
ShelfNode,
WallEvent, WallEvent,
WallNode, WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
getScaledDimensions, getScaledDimensions,
isLowProfileItemSurface, isLowProfileItemSurface,
nodeRegistry,
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -587,6 +590,156 @@ export const itemSurfaceStrategy = {
}, },
} }
// ============================================================================
// SHELF SURFACE STRATEGY
// ============================================================================
/**
* Resolve the row Y closest to the cursor's local Y. Reads candidate row
* positions from the kind's `capabilities.surfaces.custom` — the shelf
* declaration emits one `SurfacePoint` per board's top surface. The
* strategy stays kind-agnostic at this level: any future "multi-board"
* kind that declares `surfaces.custom` with upward normals gets the
* same hit behaviour for free.
*/
function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
const def = nodeRegistry.get('shelf')
const custom = def?.capabilities?.surfaces?.custom
if (!custom) return null
const candidates = custom(shelfNode as AnyNode)
if (candidates.length === 0) return null
let best = candidates[0]
let bestDist = Math.abs(best!.position[1] - localY)
for (let i = 1; i < candidates.length; i++) {
const c = candidates[i]
if (!c) continue
const dist = Math.abs(c.position[1] - localY)
if (dist < bestDist) {
best = c
bestDist = dist
}
}
return best?.position[1] ?? null
}
export const shelfSurfaceStrategy = {
/**
* Handle shelf:enter — transition the draft onto the closest shelf
* row. Mirrors `itemSurfaceStrategy.enter` but reads candidate
* surface heights from the shelf kind's `surfaces.custom` (one Y per
* board) instead of `asset.surface.height`. Picks the row whose
* surface Y is nearest the cursor's local Y so the user can target a
* specific row by hovering near it.
*/
enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null {
if (ctx.asset.attachTo) return null
const shelfNode = event.node as ShelfNode
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
return null
}
if (!isUpwardShelfSurfaceHit(event)) return null
// Size check: draft footprint must fit on the shelf board (width × depth).
const ourDims = ctx.draftItem
? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
if (!shelfMesh) return null
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = shelfMesh.worldToLocal(worldPos)
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
const surfaceQuat = new Quaternion()
shelfMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
return {
stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
nodeUpdate: {
position: [x, rowY, z],
parentId: shelfNode.id,
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
},
cursorRotationY: ctx.currentCursorRotationY,
gridPosition: [x, rowY, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
stopPropagation: true,
}
},
/**
* Handle shelf:move — re-derive the closest row each tick so the user
* can slide between rows without leaving the shelf.
*/
move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null {
if (ctx.state.surface !== 'shelf-surface') return null
if (!(ctx.state.shelfId && ctx.draftItem)) return null
if (event.node.id !== ctx.state.shelfId) return null
const shelfNode = event.node as ShelfNode
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
if (!shelfMesh) return null
const ourDims = getScaledDimensions(ctx.draftItem)
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = shelfMesh.worldToLocal(worldPos)
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
return {
gridPosition: [x, rowY, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
cursorRotationY: ctx.currentCursorRotationY,
nodeUpdate: { position: [x, rowY, z] },
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle shelf:click — commit placement on the active row.
*/
click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null {
if (ctx.state.surface !== 'shelf-surface') return null
if (!(ctx.draftItem && ctx.state.shelfId)) return null
if (event.node.id !== ctx.state.shelfId) return null
return {
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.state.shelfId,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: null,
}
},
}
/** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
* for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
* via a tiny `ItemEvent`-shaped adapter — the function only reads
* `event.normal` + `event.object`. */
function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
}
// ============================================================================ // ============================================================================
// VALIDATION // VALIDATION
// ============================================================================ // ============================================================================
@@ -603,6 +756,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
return ctx.state.surfaceItemId !== null return ctx.state.surfaceItemId !== null
} }
// Shelf surface: same — size check already happened on enter
if (ctx.state.surface === 'shelf-surface') {
return ctx.state.shelfId !== null
}
const attachTo = ctx.draftItem.asset.attachTo const attachTo = ctx.draftItem.asset.attachTo
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
@@ -12,7 +12,7 @@ import type { Vector3 } from 'three'
// PLACEMENT STATE // PLACEMENT STATE
// ============================================================================ // ============================================================================
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface'
/** /**
* Tracks which surface the draft item is currently on. * Tracks which surface the draft item is currently on.
@@ -23,6 +23,13 @@ export interface PlacementState {
wallId: string | null wallId: string | null
ceilingId: string | null ceilingId: string | null
surfaceItemId: string | null surfaceItemId: string | null
/**
* Active shelf when `surface === 'shelf-surface'`. Items host on the
* shelf board closest to the cursor's local Y; the row index isn't
* stored separately because every move re-derives it from cursor
* position via `shelfRowSurfaceYs`.
*/
shelfId: string | null
} }
// ============================================================================ // ============================================================================
@@ -179,9 +179,28 @@ export function useDraftNode(): DraftNodeHandle {
if (!draftRef.current) return if (!draftRef.current) return
if (adoptedRef.current && originalStateRef.current) { if (adoptedRef.current && originalStateRef.current) {
// Move mode: restore original state instead of deleting // Move mode: restore original state instead of deleting — but only
// if no other system has already committed a new position for this
// node. The 2D `FloorplanRegistryMoveOverlay` commits via
// `useScene.updateNodes` before unmounting the legacy mover, and
// an unconditional restore here would wipe that commit. By
// comparing the live state to the snapshot we took in `adopt()`,
// we let an external committer's write stick.
const original = originalStateRef.current const original = originalStateRef.current
const id = draftRef.current.id const id = draftRef.current.id
const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined
const livePosition = live?.position
const externallyMoved =
!!livePosition &&
(livePosition[0] !== original.position[0] ||
livePosition[1] !== original.position[1] ||
livePosition[2] !== original.position[2])
if (externallyMoved) {
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
return
}
useScene.getState().updateNode(id, { useScene.getState().updateNode(id, {
position: original.position, position: original.position,
@@ -7,6 +7,7 @@ import {
getScaledDimensions, getScaledDimensions,
type ItemEvent, type ItemEvent,
resolveLevelId, resolveLevelId,
type ShelfEvent,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveTransforms, useLiveTransforms,
@@ -41,6 +42,7 @@ import {
checkCanPlace, checkCanPlace,
floorStrategy, floorStrategy,
itemSurfaceStrategy, itemSurfaceStrategy,
shelfSurfaceStrategy,
wallStrategy, wallStrategy,
} from './placement-strategies' } from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types' import type { PlacementState, TransitionResult } from './placement-types'
@@ -286,7 +288,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const gridPosition = useRef(new Vector3(0, 0, 0)) const gridPosition = useRef(new Vector3(0, 0, 0))
const lastRawPos = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0))
const placementState = useRef<PlacementState>( const placementState = useRef<PlacementState>(
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, config.initialState ?? {
surface: 'floor',
wallId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
},
) )
const shiftFreeRef = useRef(false) const shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null) const previewBoundsSignatureRef = useRef<string | null>(null)
@@ -435,6 +443,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
wallId: null, wallId: null,
ceilingId: null, ceilingId: null,
surfaceItemId: null, surfaceItemId: null,
shelfId: null,
} }
if (!asset.attachTo && placementState.current.surface === 'floor') { if (!asset.attachTo && placementState.current.surface === 'floor') {
gridPosition.current.y = 0 gridPosition.current.y = 0
@@ -923,7 +932,67 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onItemClick = (event: ItemEvent) => { const onItemClick = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return // Click on the draft item itself. R3F dispatches click events to
// the closest intersected mesh only — when the draft is hovering
// on a host (shelf / table / etc.) the draft's mesh is *above*
// the host's mesh, so the host's `${kind}:click` never fires.
// If we're currently hosting on a shelf-surface, treat the
// self-click as a commit on the active shelf so the user doesn't
// have to aim around the cursor preview to drop the item.
if (event.node.id === draftNode.current?.id) {
const ctx = getContext()
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
if (shelfNode && shelfNode.type === 'shelf') {
const synthetic = { ...event, node: shelfNode } as unknown as ItemEvent
const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
if (result) {
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
return
}
}
}
// Same self-click forwarding for item-surface hosts (tables,
// counters) — the draft mesh sits on top of the host mesh, so
// the host's own click event is blocked by the cursor preview.
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
if (hostNode && hostNode.type === 'item') {
const synthetic = { ...event, node: hostNode } as ItemEvent
const result = itemSurfaceStrategy.click(ctx, synthetic)
if (result) {
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
return
}
}
}
return
}
const result = itemSurfaceStrategy.click(getContext(), event) const result = itemSurfaceStrategy.click(getContext(), event)
if (!result) return if (!result) return
@@ -1065,6 +1134,98 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
} }
// ---- Shelf Handlers ----
//
// Items can host on shelves the same way they host on tables and
// counters (item-surface). The shelf's `surfaces.custom` exposes one
// candidate Y per row; `shelfSurfaceStrategy` picks the closest one
// to the cursor's local-Y so the user can target a specific row.
const onShelfEnter = (event: ShelfEvent) => {
const result = shelfSurfaceStrategy.enter(getContext(), event)
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
}
}
const onShelfMove = (event: ShelfEvent) => {
const ctx = getContext()
if (ctx.state.surface !== 'shelf-surface') {
// Cursor entered via a move event without an enter — try
// transitioning in so the user doesn't need to mouse out + back
// in to start hosting.
const enterResult = shelfSurfaceStrategy.enter(ctx, event)
if (!enterResult) return
event.stopPropagation()
applyTransition(enterResult)
if (!draftNode.current) {
ensureDraft(enterResult)
} else if (enterResult.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
}
return
}
const result = shelfSurfaceStrategy.move(ctx, event)
if (!result) return
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
const ic = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.set(...result.gridPosition)
useLiveTransforms.getState().set(draft.id, {
position: result.cursorPosition,
rotation: result.cursorRotationY,
})
}
revalidate()
}
const onShelfLeave = (event: ShelfEvent) => {
if (placementState.current.surface !== 'shelf-surface') return
if (event.node.id !== placementState.current.shelfId) return
event.stopPropagation()
// Drop back to floor — same pattern as item-leave but without the
// detachItemSurfaceToFloor (no scaled rotation hand-off to deal
// with since the shelf rotation already composed cleanly).
Object.assign(placementState.current, { surface: 'floor', shelfId: null })
}
const onShelfClick = (event: ShelfEvent) => {
const result = shelfSurfaceStrategy.click(getContext(), event)
if (!result) return
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
// ---- Keyboard rotation ---- // ---- Keyboard rotation ----
const ROTATION_STEP = Math.PI / 2 const ROTATION_STEP = Math.PI / 2
@@ -1239,6 +1400,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:move', onCeilingMove)
emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:click', onCeilingClick)
emitter.on('ceiling:leave', onCeilingLeave) emitter.on('ceiling:leave', onCeilingLeave)
emitter.on('shelf:enter', onShelfEnter)
emitter.on('shelf:move', onShelfMove)
emitter.on('shelf:click', onShelfClick)
emitter.on('shelf:leave', onShelfLeave)
return () => { return () => {
tearingDown = true tearingDown = true
@@ -1263,6 +1428,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:move', onCeilingMove)
emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:click', onCeilingClick)
emitter.off('ceiling:leave', onCeilingLeave) emitter.off('ceiling:leave', onCeilingLeave)
emitter.off('shelf:enter', onShelfEnter)
emitter.off('shelf:move', onShelfMove)
emitter.off('shelf:click', onShelfClick)
emitter.off('shelf:leave', onShelfLeave)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
@@ -1,182 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
type SlabNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere'
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
deltaZ: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
}
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
const originalHolesRef = useRef(
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{
polygon: Array<[number, number]>
holes: Array<Array<[number, number]>>
} | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const center = getPolygonCenter(node.polygon)
return [center[0], 0, center[1]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is WallNode => child?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
) => {
previewRef.current = { polygon, holes }
const center = getPolygonCenter(polygon)
setCursorLocalPos([center[0], 0, center[1]])
useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId)
}
const restoreOriginal = () => {
useScene.getState().updateNode(node.id, {
holes: originalHoles,
polygon: originalPolygon,
})
useScene.getState().markDirty(node.id as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(node.id, {
polygon: originalPolygon,
holes: originalHoles,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [node.id] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node.id])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,43 +0,0 @@
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface SlabBoundaryEditorProps {
slabId: SlabNode['id']
}
/**
* Slab boundary editor - allows editing slab polygon vertices for a specific slab
* Uses the generic PolygonEditor component
*/
export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }) => {
const slabNode = useScene((state) => state.nodes[slabId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(slabId, { polygon: newPolygon })
// Re-assert selection so the slab stays selected after the edit
setSelection({ selectedIds: [slabId] })
},
[slabId, updateNode, setSelection],
)
if (!slab?.polygon || slab.polygon.length < 3) return null
return (
<PolygonEditor
allowEdgeMove
color="#a3a3a3"
levelId={resolveLevelId(slab, useScene.getState().nodes)}
minVertices={3}
onPolygonChange={handlePolygonChange}
polygon={slab.polygon}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
}
@@ -1,49 +0,0 @@
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface SlabHoleEditorProps {
slabId: SlabNode['id']
holeIndex: number
}
/**
* Slab hole editor - allows editing a specific hole polygon within a slab
* Uses the generic PolygonEditor component
*/
export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeIndex }) => {
const slabNode = useScene((state) => state.nodes[slabId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const holes = slab?.holes || []
const hole = holes[holeIndex]
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
const updatedHoles = [...holes]
updatedHoles[holeIndex] = newPolygon
updateNode(slabId, { holes: updatedHoles })
// Re-assert selection so the slab stays selected after the edit
setSelection({ selectedIds: [slabId] })
},
[slabId, holeIndex, holes, updateNode, setSelection],
)
if (!(slab && hole) || hole.length < 3) return null
return (
<PolygonEditor
allowEdgeMove
allowPolygonMove
color="#ef4444"
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
minVertices={3}
onPolygonChange={handlePolygonChange}
polygon={hole}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
}
@@ -1,322 +0,0 @@
import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const Y_OFFSET = 0.02
/**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
*/
const calculateSnapPoint = (
lastPoint: [number, number],
currentPoint: [number, number],
): [number, number] => {
const [x1, y1] = lastPoint
const [x, y] = currentPoint
const dx = x - x1
const dy = y - y1
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
// Calculate distances to horizontal, vertical, and diagonal lines
const horizontalDist = absDy
const verticalDist = absDx
const diagonalDist = Math.abs(absDx - absDy)
// Find the minimum distance to determine which axis to snap to
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
if (minDist === diagonalDist) {
// Snap to 45° diagonal
const diagonalLength = Math.min(absDx, absDy)
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
}
if (minDist === horizontalDist) {
// Snap to horizontal
return [x, y1]
}
// Snap to vertical
return [x1, y]
}
/**
* Creates a slab with the given polygon points and returns its ID
*/
const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
const { createNode, nodes } = useScene.getState()
// Count existing slabs for naming
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
const name = `Slab ${slabCount + 1}`
const slab = SlabNode.parse({
name,
polygon: points,
})
createNode(slab, levelId)
sfxEmitter.emit('sfx:structure-build')
return slab.id
}
export const SlabTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const [points, setPoints] = useState<Array<[number, number]>>([])
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Update cursor position and lines on grid move
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.localPosition[1])
// Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1]
const displayPoint =
shiftPressed.current || !lastPoint
? gridPosition
: calculateSnapPoint(lastPoint, gridPosition)
setSnappedCursorPosition(displayPoint)
// Play snap sound when the snapped position actually changes (only when drawing)
if (
points.length > 0 &&
previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
displayPoint[1] !== previousSnappedPointRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
}
const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Use the last displayed snapped position (respects Shift state from onGridMove)
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
// Check if clicking on the first point to close the shape
const firstPoint = points[0]
if (
points.length >= 3 &&
firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
// Create the slab and select it
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
} else {
// Add point to polygon
setPoints([...points, clickPoint])
}
}
const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Need at least 3 points to form a polygon
if (points.length >= 3) {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
}
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
}
}, [currentLevelId, points, cursorPosition, setSelection])
// Update line geometries when points change
useEffect(() => {
if (!(mainLineRef.current && closingLineRef.current)) return
if (points.length === 0) {
mainLineRef.current.visible = false
closingLineRef.current.visible = false
return
}
const y = levelY + Y_OFFSET
const snappedCursor = snappedCursorPosition
// Build main line points
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1]))
// Update main line
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true
} else {
mainLineRef.current.visible = false
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && firstPoint) {
const closingPoints = [
new Vector3(snappedCursor[0], y, snappedCursor[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
}
}, [points, snappedCursorPosition, levelY])
// Create preview shape when we have 3+ points
const previewShape = useMemo(() => {
if (points.length < 3) return null
const snappedCursor = snappedCursorPosition
const allPoints = [...points, snappedCursor]
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
// - Shape X -> World X
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
const firstPt = allPoints[0]
if (!firstPt) return null
const shape = new Shape()
shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i]
if (pt) {
shape.lineTo(pt[0], -pt[1])
}
}
shape.closePath()
return shape
}, [points, snappedCursorPosition])
return (
<group>
{/* Cursor */}
<CursorSphere ref={cursorRef} />
{/* Preview fill */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Main line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={mainLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
</line>
{/* Closing line */}
{/* @ts-ignore */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
// @ts-expect-error
ref={closingLineRef}
renderOrder={1}
visible={false}
>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.5}
transparent
/>
</line>
{/* Point markers */}
{points.map(([x, z], index) => (
<CursorSphere
color="#818cf8"
height={0}
key={index}
position={[x, levelY + Y_OFFSET + 0.01, z]}
showTooltip={false}
/>
))}
</group>
)
}
@@ -1,101 +0,0 @@
import '../../../three-types'
import {
emitter,
type GridEvent,
type SpawnNode,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useCallback, useEffect, useState } from 'react'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const worldVector = new Vector3()
function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] {
const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null
if (!levelObject) {
return [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
}
export const MoveSpawnTool: React.FC<{
node: SpawnNode
onCommitted?: (nodeId: SpawnNode['id']) => void
}> = ({ node, onCommitted }) => {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
let committed = false
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
setPreviewPosition(nextPosition)
useLiveTransforms.getState().set(node.id, {
position: [...nextPosition],
rotation: node.rotation,
})
}
const onGridClick = (event: GridEvent) => {
const nextPosition = getLevelLocalSpawnPosition(node, event)
committed = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, { position: nextPosition })
onCommitted?.(node.id)
useLiveTransforms.getState().clear(node.id)
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
}
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
useScene.temporal.getState().resume()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id)
if (!committed) {
useScene.temporal.getState().resume()
}
}
}, [exitMoveMode, node, onCommitted])
return (
<CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
)
}
@@ -1,130 +0,0 @@
import '../../../three-types'
import {
emitter,
type GridEvent,
type LevelNode,
SpawnNode,
type SpawnNode as SpawnNodeType,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const SPAWN_ICON = (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Spawn Point"
src="/icons/site.png"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
)
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const worldVector = new Vector3()
function getExistingSpawnIds() {
const nodes = useScene.getState().nodes
return Object.values(nodes)
.filter((node) => node.type === 'spawn')
.map((node) => node.id)
.sort()
}
function getLevelLocalSpawnPosition(
levelId: LevelNode['id'],
event: GridEvent,
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
return [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
}
type SpawnToolProps = {
currentLevelId: LevelNode['id'] | null
onPlaced?: (spawnId: SpawnNodeType['id']) => void
}
export const SpawnTool: React.FC<SpawnToolProps> = ({ currentLevelId, onPlaced }) => {
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
const cursorRef = useRef<Group>(null)
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
setCursorPosition(nextPosition)
cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2])
}
const onGridClick = (event: GridEvent) => {
const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event)
const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds()
if (existingSpawnId) {
useScene.getState().updateNode(existingSpawnId, {
parentId: currentLevelId,
position: nextPosition,
rotation: 0,
})
if (duplicateSpawnIds.length > 0) {
useScene.getState().deleteNodes(duplicateSpawnIds)
}
onPlaced?.(existingSpawnId)
} else {
const spawn = SpawnNode.parse({
name: 'Spawn Point',
position: nextPosition,
rotation: 0,
})
useScene.getState().createNode(spawn, currentLevelId)
onPlaced?.(spawn.id)
}
sfxEmitter.emit('sfx:structure-build')
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
}
}, [currentLevelId, onPlaced])
if (!currentLevelId) return null
return (
<CursorSphere
color="#60a5fa"
height={2.2}
ref={cursorRef}
showTooltip
tooltipContent={SPAWN_ICON}
/>
)
}
@@ -9,29 +9,13 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense } from 'react' import { type ComponentType, lazy, Suspense } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor' import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { ColumnTool } from './column/column-tool' import { ColumnTool } from './column/column-tool'
import { DoorTool } from './door/door-tool'
import { ElevatorTool } from './elevator/elevator-tool' import { ElevatorTool } from './elevator/elevator-tool'
import { CurveFenceTool } from './fence/curve-fence-tool'
import { FenceTool } from './fence/fence-tool'
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool' import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool' import { RoofTool } from './roof/roof-tool'
import { getRegistryAffordanceTool } from './shared/affordance-dispatch' import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool'
import { SpawnTool } from './spawn/spawn-tool'
import { StairTool } from './stair/stair-tool' import { StairTool } from './stair/stair-tool'
import { CurveWallTool } from './wall/curve-wall-tool'
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
import { ZoneTool } from './zone/zone-tool' import { ZoneTool } from './zone/zone-tool'
@@ -50,25 +34,19 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
return Comp return Comp
} }
// Legacy tool fallbacks — kinds whose placement tools haven't migrated
// to `def.tool` yet. Wall / fence / slab / ceiling / door / window /
// item / shelf / spawn now go through the registry path above.
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = { const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: { site: {
'property-line': SiteBoundaryEditor, 'property-line': SiteBoundaryEditor,
}, },
structure: { structure: {
wall: WallTool,
fence: FenceTool,
slab: SlabTool,
ceiling: CeilingTool,
roof: RoofTool, roof: RoofTool,
stair: StairTool, stair: StairTool,
door: DoorTool,
item: ItemTool,
zone: ZoneTool, zone: ZoneTool,
window: WindowTool,
},
furnish: {
item: ItemTool,
}, },
furnish: {},
} }
export const ToolManager: React.FC = () => { export const ToolManager: React.FC = () => {
@@ -146,9 +124,7 @@ export const ToolManager: React.FC = () => {
const showBuildTool = mode === 'build' && tool !== null const showBuildTool = mode === 'build' && tool !== null
// Registry-first: if the active tool's kind has a NodeDefinition with a // Registry-first: if the active tool's kind has a NodeDefinition with a
// tool contribution, the registry-driven tool takes over. Otherwise fall // tool contribution, the registry-driven tool takes over.
// through to the legacy `tools` map below. Today the registry is empty so
// RegistryToolComponent is always null — zero behavior change.
const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
const useRegistryTool = RegistryToolComponent != null const useRegistryTool = RegistryToolComponent != null
@@ -187,9 +163,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
<Registry slabId={selectedSlabId} /> <Registry slabId={selectedSlabId} />
</Suspense> </Suspense>
) : ( ) : null
<SlabBoundaryEditor slabId={selectedSlabId} />
)
})()} })()}
{showSlabHoleEditor && {showSlabHoleEditor &&
selectedSlabId && selectedSlabId &&
@@ -200,9 +174,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
<Registry holeIndex={editingHole.holeIndex} slabId={selectedSlabId} /> <Registry holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
</Suspense> </Suspense>
) : ( ) : null
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
)
})()} })()}
{showCeilingBoundaryEditor && {showCeilingBoundaryEditor &&
selectedCeilingId && selectedCeilingId &&
@@ -212,9 +184,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
<Registry ceilingId={selectedCeilingId} /> <Registry ceilingId={selectedCeilingId} />
</Suspense> </Suspense>
) : ( ) : null
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
)
})()} })()}
{showCeilingHoleEditor && {showCeilingHoleEditor &&
selectedCeilingId && selectedCeilingId &&
@@ -225,9 +195,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
<Registry ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} /> <Registry ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
</Suspense> </Suspense>
) : ( ) : null
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)
})()} })()}
{movingWallEndpoint && {movingWallEndpoint &&
(() => { (() => {
@@ -239,9 +207,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
<RegistryAffordance target={movingWallEndpoint} /> <RegistryAffordance target={movingWallEndpoint} />
</Suspense> </Suspense>
) : ( ) : null
<MoveWallEndpointTool target={movingWallEndpoint} />
)
})()} })()}
{movingFenceEndpoint && {movingFenceEndpoint &&
(() => { (() => {
@@ -253,9 +219,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
<RegistryAffordance target={movingFenceEndpoint} /> <RegistryAffordance target={movingFenceEndpoint} />
</Suspense> </Suspense>
) : ( ) : null
<MoveFenceEndpointTool target={movingFenceEndpoint} />
)
})()} })()}
{curvingWall && {curvingWall &&
(() => { (() => {
@@ -264,9 +228,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
<Registry node={curvingWall} /> <Registry node={curvingWall} />
</Suspense> </Suspense>
) : ( ) : null
<CurveWallTool node={curvingWall} />
)
})()} })()}
{curvingFence && {curvingFence &&
(() => { (() => {
@@ -275,9 +237,7 @@ export const ToolManager: React.FC = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
<RegistryAffordance node={curvingFence} /> <RegistryAffordance node={curvingFence} />
</Suspense> </Suspense>
) : ( ) : null
<CurveFenceTool node={curvingFence} />
)
})()} })()}
{movingNode && movingNode.type !== 'building' && ( {movingNode && movingNode.type !== 'building' && (
<MoveTool <MoveTool
@@ -286,16 +246,12 @@ export const ToolManager: React.FC = () => {
/> />
)} )}
{/* Registry-first: when the active tool's kind has a registered {/* Registry-first: when the active tool's kind has a registered
NodeDefinition with a tool contribution, mount it here. Today NodeDefinition with a tool contribution, mount it here. */}
the registry is empty so this branch never fires. */}
{!movingNode && useRegistryTool && RegistryToolComponent && ( {!movingNode && useRegistryTool && RegistryToolComponent && (
<Suspense fallback={null}> <Suspense fallback={null}>
<RegistryToolComponent /> <RegistryToolComponent />
</Suspense> </Suspense>
)} )}
{!movingNode && !useRegistryTool && showBuildTool && tool === 'spawn' && (
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && !useRegistryTool && showBuildTool && tool === 'column' && ( {!movingNode && !useRegistryTool && showBuildTool && tool === 'column' && (
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} /> <ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
)} )}
@@ -306,11 +262,7 @@ export const ToolManager: React.FC = () => {
onPlaced={handlePlacedElevatorSelected} onPlaced={handlePlacedElevatorSelected}
/> />
)} )}
{!movingNode && {!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? (
BuildToolComponent &&
tool !== 'spawn' &&
tool !== 'column' &&
tool !== 'elevator' ? (
<BuildToolComponent /> <BuildToolComponent />
) : null} ) : null}
</group> </group>
@@ -1,178 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type GridEvent,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from './wall-drafting'
export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = useCallback(() => {
useEditor.getState().setCurvingWall(null)
}, [])
useEffect(() => {
const nodeId = node.id
const originalCurveOffset = originalCurveOffsetRef.current
const chord = getWallChordFrame(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (curveOffset: number) => {
if (previewOffsetRef.current === curveOffset) {
return
}
previewOffsetRef.current = curveOffset
const nextNode = {
...node,
curveOffset,
}
const handlePoint = getWallMidpointHandlePoint(nextNode)
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const restoreOriginal = () => {
if (previewOffsetRef.current === originalCurveOffset) {
return
}
previewOffsetRef.current = originalCurveOffset
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const snapStep = getWallGridStep()
const localX = shiftPressedRef.current
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if (
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousCurveOffsetRef.current = nextCurveOffset
applyPreview(nextCurveOffset)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const curveOffset = previewOffsetRef.current
wasCommitted = true
if (curveOffset !== originalCurveOffset) {
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
useScene.temporal.getState().pause()
}
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitCurveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitCurveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitCurveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,426 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
type WallSegmentLike = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
curveOffset?: number
}
type AngleLabelState = {
label: string
position: [number, number, number]
} | null
function getEndpointAngleLabel(args: {
preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number }
walls: WallSegmentLike[]
nodeId: WallNode['id']
}): AngleLabelState {
const { preview, walls, nodeId } = args
const endpoints = [
{
point: preview.start,
},
{
point: preview.end,
},
]
const targetSegment: WallSegmentLike = {
id: nodeId,
start: preview.start,
end: preview.end,
curveOffset: preview.curveOffset,
}
for (const endpoint of endpoints) {
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
if (!targetReference) continue
const connectedWall = walls.find(
(wall) =>
wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
)
if (!connectedWall) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
if (angle === null) continue
return {
label: formatAngleRadians(angle),
position: [endpoint.point[0], 0.34, endpoint.point[1]],
}
}
return null
}
type LinkedWallSnapshot = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
curveOffset?: number
}
function getLinkedWallSnapshots(args: {
wallId: WallNode['id']
wallParentId: string | null
originalStart: WallPlanPoint
originalEnd: WallPlanPoint
}) {
const { wallId, wallParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedWallSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'wall' && node.id !== wallId)) {
continue
}
if ((node.parentId ?? null) !== wallParentId) {
continue
}
if (
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as WallPlanPoint,
end: [...node.end] as WallPlanPoint,
curveOffset: node.curveOffset,
})
}
return snapshots
}
function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
) {
return linkedWalls.map((wall) => ({
id: wall.id,
curveOffset: wall.curveOffset,
start: samePoint(wall.start, originalStart)
? nextStart
: samePoint(wall.start, originalEnd)
? nextEnd
: wall.start,
end: samePoint(wall.end, originalStart)
? nextStart
: samePoint(wall.end, originalEnd)
? nextEnd
: wall.end,
}))
}
export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<WallPlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.wall.id)
const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint)
const originalEndRef = useRef<WallPlanPoint>([...target.wall.end] as WallPlanPoint)
const fixedPointRef = useRef<WallPlanPoint>(
target.endpoint === 'start'
? ([...target.wall.end] as WallPlanPoint)
: ([...target.wall.start] as WallPlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedWallSnapshots({
wallId: target.wall.id,
wallParentId: target.wall.parentId ?? null,
originalStart: target.wall.start,
originalEnd: target.wall.end,
}),
)
const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
return [point[0], 0, point[1]]
})
const [altPressed, setAltPressed] = useState(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingWallEndpoint(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const fixedPoint = fixedPointRef.current
const levelWalls = Object.values(useScene.getState().nodes).filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedWalls
? []
: getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
setAngleLabel(
getEndpointAngleLabel({
preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
walls: [
...levelWalls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...linkedUpdates,
],
nodeId,
}),
)
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
}
const restoreOriginal = (clearAngleLabel = true) => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
if (clearAngleLabel) {
setAngleLabel(null)
}
}
const onGridMove = (event: GridEvent) => {
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
const snappedPoint = snapWallDraftPoint({
point: planPoint,
walls: levelWalls,
start: fixedPoint,
angleSnap: !shiftPressedRef.current,
ignoreWallIds: [nodeId],
})
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
applyPreview(snappedPoint, event.nativeEvent.altKey)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged = !(
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
resumeSceneHistory(useScene)
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current
? []
: getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
)),
])
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
setAngleLabel(null)
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
setAngleLabel(null)
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
if (event.key === 'Alt') {
altPressedRef.current = true
setAltPressed(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
if (event.key === 'Alt') {
altPressedRef.current = false
setAltPressed(false)
}
}
const onWindowBlur = () => {
shiftPressedRef.current = false
altPressedRef.current = false
setAltPressed(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
return () => {
if (!wasCommitted) {
restoreOriginal(false)
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
}
}, [exitMoveMode, target])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
: 'border-border bg-background/95 text-muted-foreground'
}`}
>
{altPressed ? 'Detaching corner' : 'Alt to detach'}
</div>
</div>
</Html>
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
</group>
)
}
function EndpointAngleLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -1,804 +0,0 @@
'use client'
import {
type AnyNodeId,
constrainWallMoveDeltaToAxis,
DEFAULT_WALL_HEIGHT,
detectSpacesForLevel,
emitter,
type GridEvent,
getMaterialPresetByRef,
getPerpendicularWallMoveAxis,
pauseSceneHistory,
planAutoSlabsForLevel,
planWallMoveJunctions,
resolveMaterial,
resumeSceneHistory,
type SlabNode,
useScene,
type WallMoveAxis,
type WallMoveBridgePlan,
type WallMoveJunctionPlan,
type WallNode,
WallNode as WallSchema,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting'
function rotateVector([x, z]: [number, number], angle: number): [number, number] {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return [x * cos - z * sin, x * sin + z * cos]
}
function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
}
function pointKey(point: [number, number]) {
return `${point[0]}:${point[1]}`
}
function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
return meta
}
const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
delete nextMeta.isNew
return nextMeta as WallNode['metadata']
}
type LinkedWallSnapshot = WallNode
type GhostWallPreview = {
id: string
start: [number, number]
end: [number, number]
color: string
height: number
}
function getLinkedWallSnapshots(args: {
wallId: WallNode['id']
wallParentId: string | null
originalStart: [number, number]
originalEnd: [number, number]
}) {
const { wallId, wallParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const walls = Object.values(nodes).filter(
(node): node is WallNode =>
node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId,
)
const directlyLinkedWalls = walls.filter(
(wall) =>
samePoint(wall.start, originalStart) ||
samePoint(wall.start, originalEnd) ||
samePoint(wall.end, originalStart) ||
samePoint(wall.end, originalEnd),
)
const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)])
for (const wall of directlyLinkedWalls) {
contextPoints.add(pointKey(wall.start))
contextPoints.add(pointKey(wall.end))
}
const snapshots: LinkedWallSnapshot[] = []
const seenWallIds = new Set<WallNode['id']>()
for (const node of walls) {
if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) {
continue
}
if (seenWallIds.has(node.id)) {
continue
}
seenWallIds.add(node.id)
snapshots.push({
...node,
start: [...node.start] as [number, number],
end: [...node.end] as [number, number],
children: [...(node.children ?? [])],
})
}
return snapshots
}
function getLinkedWallUpdates(
linkedWalls: Array<{
wall: LinkedWallSnapshot
matchPoint?: [number, number]
targetPoint?: [number, number]
}>,
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
return linkedWalls.map(({ wall, matchPoint, targetPoint }) => {
if (matchPoint && targetPoint) {
return {
id: wall.id,
start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start,
end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end,
}
}
const targetStart = targetPoint ?? nextStart
const targetEnd = targetPoint ?? nextEnd
return {
id: wall.id,
start: samePoint(wall.start, originalStart)
? targetStart
: samePoint(wall.start, originalEnd)
? targetEnd
: wall.start,
end: samePoint(wall.end, originalStart)
? targetStart
: samePoint(wall.end, originalEnd)
? targetEnd
: wall.end,
}
})
}
function getPlannedLinkedWallUpdates(
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
const movePlans = new Map<
WallNode['id'],
{ wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] }
>()
for (const wall of plan.linkedWallsToMove) {
movePlans.set(wall.id, { wall })
}
for (const targetPlan of plan.linkedWallTargetPlans) {
movePlans.set(targetPlan.wall.id, {
wall: targetPlan.wall,
matchPoint: targetPlan.originalPoint,
targetPoint: targetPlan.targetPoint,
})
}
return getLinkedWallUpdates(
Array.from(movePlans.values()),
originalStart,
originalEnd,
nextStart,
nextEnd,
)
}
function wallSegmentExists(
walls: Array<Pick<WallNode, 'start' | 'end'>>,
start: [number, number],
end: [number, number],
) {
return walls.some(
(wall) =>
(samePoint(wall.start, start) && samePoint(wall.end, end)) ||
(samePoint(wall.start, end) && samePoint(wall.end, start)),
)
}
function getWallGhostColor(wall: WallNode) {
const presetColor =
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color
if (presetColor) {
return presetColor
}
return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color
}
function getWallsAfterUpdates(
nodes: ReturnType<typeof useScene.getState>['nodes'],
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
) {
const updateById = new Map(updates.map((update) => [update.id, update.data]))
return Object.values(nodes)
.filter((node): node is WallNode => node?.type === 'wall')
.map((wall) => {
const update = updateById.get(wall.id as AnyNodeId)
return update ? ({ ...wall, ...update } as WallNode) : wall
})
}
function cloneSlabSnapshot(slab: SlabNode): SlabNode {
return {
...slab,
polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]),
holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })),
}
}
function getLevelSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
return Object.values(nodes).filter(
(entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId,
)
}
function getLevelAutoSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls)
}
function getLevelAutoSlabSnapshots(levelId: string) {
return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot)
}
function buildBridgeWallCreates(args: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
nextStart: [number, number]
nextEnd: [number, number]
existingWalls: WallNode[]
wallCount: number
}): Array<{ node: WallNode; parentId?: AnyNodeId }> {
const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args
const wallsForDuplicateCheck = [...existingWalls]
const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = []
for (const plan of bridgePlans) {
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
continue
}
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
continue
}
const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall
const bridgeWall = WallSchema.parse({
...sourceWall,
name: `Wall ${wallCount + creates.length + 1}`,
start: plan.originalPoint,
end: nextPoint,
children: [],
metadata: stripWallIsNewMetadata(plan.wall.metadata),
})
creates.push({
node: bridgeWall,
parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined,
})
wallsForDuplicateCheck.push(bridgeWall)
}
return creates
}
function buildBridgeWallPreviews(args: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
nextStart: [number, number]
nextEnd: [number, number]
existingWalls: WallNode[]
}): Array<{ ghost: GhostWallPreview; wall: WallNode }> {
const { bridgePlans, nextStart, nextEnd, existingWalls } = args
const wallsForDuplicateCheck: Array<Pick<WallNode, 'start' | 'end'>> = [...existingWalls]
const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = []
for (const plan of bridgePlans) {
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
continue
}
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
continue
}
const { id: _id, children: _children, ...sourceWall } = plan.wall
const wall = WallSchema.parse({
...sourceWall,
name: 'Wall Preview',
start: plan.originalPoint,
end: nextPoint,
children: [],
metadata: stripWallIsNewMetadata(plan.wall.metadata),
})
const ghost = {
id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`,
start: [...plan.originalPoint] as [number, number],
end: [...nextPoint] as [number, number],
color: getWallGhostColor(plan.wall),
height: plan.wall.height ?? DEFAULT_WALL_HEIGHT,
}
previews.push({ ghost, wall })
wallsForDuplicateCheck.push(wall)
}
return previews
}
function setPreviewGeometryAttributes(
geometry: BufferGeometry,
positions: number[],
normals: number[],
uvs: number[],
) {
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
}
function createWallPreviewGeometry(length: number, height: number) {
const geometry = new BufferGeometry()
setPreviewGeometryAttributes(
geometry,
[0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0],
[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
[0, 0, 1, 0, 1, 1, 0, 1],
)
geometry.setIndex([0, 1, 2, 0, 2, 3])
geometry.computeBoundingSphere()
return geometry
}
function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) {
const dx = preview.end[0] - preview.start[0]
const dz = preview.end[1] - preview.start[1]
const length = Math.hypot(dx, dz)
const angle = -Math.atan2(dz, dx)
const geometry = useMemo(() => {
return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height)
}, [length, preview.height])
useEffect(() => () => geometry?.dispose(), [geometry])
if (!geometry) {
return null
}
return (
<group position={[preview.start[0], 0.02, preview.start[1]]} rotation={[0, angle, 0]}>
<mesh frustumCulled={false} layers={EDITOR_LAYER} renderOrder={2}>
<primitive attach="geometry" object={geometry} />
<meshBasicMaterial
color={preview.color}
depthTest={false}
depthWrite={false}
opacity={0.32}
side={DoubleSide}
transparent
/>
</mesh>
</group>
)
}
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const meta =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const originalCenterRef = useRef<[number, number]>([
(node.start[0] + node.end[0]) / 2,
(node.start[1] + node.end[1]) / 2,
])
const originalHalfVectorRef = useRef<[number, number]>([
(node.end[0] - node.start[0]) / 2,
(node.end[1] - node.start[1]) / 2,
])
const moveAxisRef = useRef<WallMoveAxis | null>(
getPerpendicularWallMoveAxis(node.start, node.end),
)
const linkedOriginalsRef = useRef<LinkedWallSnapshot[]>(
isNew
? []
: getLinkedWallSnapshots({
wallId: node.id,
wallParentId: node.parentId ?? null,
originalStart: node.start,
originalEnd: node.end,
}),
)
const originalAutoSlabsRef = useRef<SlabNode[]>(
node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [],
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const pendingRotationRef = useRef(0)
const shiftPressedRef = useRef(false)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const centerX = (node.start[0] + node.end[0]) / 2
const centerZ = (node.start[1] + node.end[1]) / 2
return [centerX, 0, centerZ]
})
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current
const levelId = node.parentId ?? null
const originalAutoSlabs = originalAutoSlabsRef.current
pauseSceneHistory(useScene)
let shouldRestoreOnCleanup = true
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyLiveAutoSlabPreview = (walls: WallNode[]) => {
if (!levelId) {
return
}
const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId)
const sceneState = useScene.getState()
const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls)
const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes))
if (
slabPlan.create.length === 0 &&
slabPlan.update.length === 0 &&
slabPlan.delete.length === 0
) {
return
}
sceneState.applyNodeChanges({
update: slabPlan.update.map((entry) => ({
id: entry.id as AnyNodeId,
data: entry.data,
})),
create: slabPlan.create.map((slab) => ({
node: slab,
parentId: levelId as AnyNodeId,
})),
delete: slabPlan.delete.map((id) => id as AnyNodeId),
})
}
const restoreAutoSlabPreview = () => {
if (!levelId) {
return
}
const sceneState = useScene.getState()
const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id))
const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes)
const update = originalAutoSlabs
.filter((slab) => sceneState.nodes[slab.id as AnyNodeId])
.map((slab) => ({
id: slab.id as AnyNodeId,
data: cloneSlabSnapshot(slab),
}))
const create = originalAutoSlabs
.filter((slab) => !sceneState.nodes[slab.id as AnyNodeId])
.map((slab) => ({
node: cloneSlabSnapshot(slab),
parentId: levelId as AnyNodeId,
}))
const deleteIds = currentAutoSlabs
.filter((slab) => !originalIds.has(slab.id))
.map((slab) => slab.id as AnyNodeId)
if (update.length === 0 && create.length === 0 && deleteIds.length === 0) {
return
}
sceneState.applyNodeChanges({
update,
create,
delete: deleteIds,
})
}
const buildWallFromCenter = (center: [number, number]) => {
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]]
return { start: nextStart, end: nextEnd }
}
const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) =>
planWallMoveJunctions(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
const getLinkedPreviewUpdates = (
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
nextStart: [number, number],
nextEnd: [number, number],
) => {
const movedUpdates = getPlannedLinkedWallUpdates(
plan,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry]))
return linkedOriginalsRef.current.map(
(wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end },
)
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
const previewPlan = getMovePlan(nextStart, nextEnd)
const previewUpdates = [
{ id: nodeId, start: nextStart, end: nextEnd },
...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd),
]
const previewCollapsedWallIds = new Set([
...previewUpdates
.filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end))
.map((entry) => entry.id as AnyNodeId),
...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
])
const previewSceneWalls = getWallsAfterUpdates(
useScene.getState().nodes,
previewUpdates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId))
const bridgePreviews = buildBridgeWallPreviews({
bridgePlans: previewPlan.bridgePlans,
nextStart,
nextEnd,
existingWalls: previewSceneWalls,
})
const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost)
const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall)
setGhostWallPreviews(nextGhostWalls)
applyNodePreview(previewUpdates)
applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls])
}
const restoreOriginal = () => {
setGhostWallPreviews([])
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
restoreAutoSlabPreview()
}
const onGridMove = (event: GridEvent) => {
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
const snapStep = getWallGridStep()
const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep)
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
localX - anchor[0],
localZ - anchor[1],
moveAxisRef.current,
)
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
if (
previousGridPosRef.current &&
(constrainedGridPos[0] !== previousGridPosRef.current[0] ||
constrainedGridPos[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = constrainedGridPos
const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
const nextWall = buildWallFromCenter(nextCenter)
applyPreview(nextWall.start, nextWall.end)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
shouldRestoreOnCleanup = false
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
setGhostWallPreviews([])
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
restoreAutoSlabPreview()
resumeSceneHistory(useScene)
const commitPlan = getMovePlan(preview.start, preview.end)
const linkedWallUpdates = getPlannedLinkedWallUpdates(
commitPlan,
originalStart,
originalEnd,
preview.start,
preview.end,
)
const collapsedLinkedWallIds = new Set([
...linkedWallUpdates
.filter((entry) => !isWallLongEnough(entry.start, entry.end))
.map((entry) => entry.id as AnyNodeId),
...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
])
const commitUpdates = [
{
id: nodeId as AnyNodeId,
data: isNew
? {
start: preview.start,
end: preview.end,
metadata: stripWallIsNewMetadata(node.metadata),
}
: { start: preview.start, end: preview.end },
},
...linkedWallUpdates
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
]
const sceneState = useScene.getState()
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
(wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId),
)
const bridgeCreates = buildBridgeWallCreates({
bridgePlans: commitPlan.bridgePlans,
nextStart: preview.start,
nextEnd: preview.end,
existingWalls,
wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length,
})
sceneState.applyNodeChanges({
update: commitUpdates,
create: bridgeCreates,
delete: Array.from(collapsedLinkedWallIds),
})
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta === 0) {
return
}
event.preventDefault()
pendingRotationRef.current += rotationDelta
sfxEmitter.emit('sfx:item-rotate')
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const currentCenter: [number, number] = [
(preview.start[0] + preview.end[0]) / 2,
(preview.start[1] + preview.end[1]) / 2,
]
const nextWall = buildWallFromCenter(currentCenter)
moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end)
applyPreview(nextWall.start, nextWall.end)
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
const onCancel = () => {
shouldRestoreOnCleanup = false
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (shouldRestoreOnCleanup) {
restoreOriginal()
}
shiftPressedRef.current = false
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitMoveMode, isNew, node.metadata, node.parentId])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
{ghostWallPreviews.map((preview) => (
<GhostWallPreviewMesh key={preview.id} preview={preview} />
))}
</group>
)
}
@@ -1,332 +0,0 @@
import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
const WALL_HEIGHT = 2.5
const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
const DRAFT_ANGLE_LABEL_Y = 0.28
type DraftAngleLabel = {
id: string
label: string
position: [number, number, number]
}
type DraftMeasurementState = {
lengthLabel: string
lengthPosition: [number, number, number]
angleLabels: DraftAngleLabel[]
} | null
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
function getDraftAngleLabels(
start: WallPlanPoint,
end: WallPlanPoint,
walls: WallNode[],
): DraftAngleLabel[] {
const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
const endpoints = [
{ id: 'start', point: start, draftVector: draftFromStart },
{ id: 'end', point: end, draftVector: draftFromEnd },
]
const labels: DraftAngleLabel[] = []
for (const endpoint of endpoints) {
const connectedWall = walls.find((wall) =>
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
)
if (!connectedWall) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
if (angle === null) continue
labels.push({
id: endpoint.id,
label: formatAngleRadians(angle),
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
})
}
return labels
}
function getDraftMeasurementState(
start: WallPlanPoint,
end: WallPlanPoint,
walls: WallNode[],
unit: 'metric' | 'imperial',
): DraftMeasurementState {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 0.01) return null
return {
lengthLabel: formatMeasurement(length, unit),
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
angleLabels: getDraftAngleLabels(start, end, walls),
}
}
/**
* Update wall preview mesh geometry to create a vertical plane between two points
*/
const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
// Calculate direction and perpendicular for wall thickness
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < 0.01) {
mesh.visible = false
return
}
mesh.visible = true
direction.normalize()
// Create wall shape (vertical rectangle in XY plane)
const shape = new Shape()
shape.moveTo(0, 0)
shape.lineTo(length, 0)
shape.lineTo(length, WALL_HEIGHT)
shape.lineTo(0, WALL_HEIGHT)
shape.closePath()
// Create geometry
const geometry = new ShapeGeometry(shape)
// Calculate rotation angle
// Negate the angle to fix the opposite direction issue
const angle = -Math.atan2(direction.z, direction.x)
// Position at start point and rotate
mesh.position.set(start.x, start.y, start.z)
mesh.rotation.y = angle
// Dispose old geometry and assign new one
if (mesh.geometry) {
mesh.geometry.dispose()
}
mesh.geometry = geometry
}
const getCurrentLevelWalls = (): WallNode[] => {
const currentLevelId = useViewer.getState().selection.levelId
const { nodes } = useScene.getState()
if (!currentLevelId) return []
const levelNode = nodes[currentLevelId]
if (!levelNode || levelNode.type !== 'level') return []
return (levelNode as LevelNode).children
.map((childId) => nodes[childId])
.filter((node): node is WallNode => node?.type === 'wall')
}
export const WallTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!)
// All positions are building-local: this tool is inside the ToolManager building group,
// so local coords are used for both data and visual positioning.
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
useEffect(() => {
let gridPosition: WallPlanPoint = [0, 0]
let previousWallEnd: [number, number] | null = null
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && wallPreviewRef.current)) return
const walls = getCurrentLevelWalls()
// event.localPosition is building-local — consistent with stored wall start/end
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
gridPosition = snapWallDraftPoint({ point: localPoint, walls })
if (buildingState.current === 1) {
const snappedLocal = snapWallDraftPoint({
point: localPoint,
walls,
start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current,
})
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current)
// Play snap sound only when the actual wall end position changes
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if (
previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousWallEnd = currentWallEnd
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
setDraftMeasurement(
getDraftMeasurementState(
[startingPoint.current.x, startingPoint.current.z],
snappedLocal,
walls,
unit,
),
)
} else {
// Not drawing a wall yet, show the snapped anchor point.
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
setDraftMeasurement(null)
}
}
const onGridClick = (event: GridEvent) => {
const walls = getCurrentLevelWalls()
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
if (buildingState.current === 0) {
const snappedStart = snapWallDraftPoint({ point: localClick, walls })
gridPosition = snappedStart
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
wallPreviewRef.current.visible = true
setDraftMeasurement(null)
} else if (buildingState.current === 1) {
const snappedEnd = snapWallDraftPoint({
point: localClick,
walls,
start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current,
})
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
// Both start and end are building-local ✓
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
wallPreviewRef.current.visible = false
buildingState.current = 0
setDraftMeasurement(null)
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = false
}
}
const onCancel = () => {
if (buildingState.current === 1) {
markToolCancelConsumed()
buildingState.current = 0
wallPreviewRef.current.visible = false
setDraftMeasurement(null)
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [unit])
return (
<group>
{/* Cursor indicator */}
<CursorSphere ref={cursorRef} />
{/* Wall preview */}
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
<shapeGeometry />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
opacity={0.5}
side={DoubleSide}
transparent
/>
</mesh>
{draftMeasurement && (
<>
<DraftMeasurementLabel
label={draftMeasurement.lengthLabel}
position={draftMeasurement.lengthPosition}
/>
{draftMeasurement.angleLabels.map((angleLabel) => (
<DraftMeasurementLabel
key={angleLabel.id}
label={angleLabel.label}
position={angleLabel.position}
/>
))}
</>
)}
</group>
)
}
function DraftMeasurementLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -33,10 +33,7 @@ export const tools: ToolConfig[] = [
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' }, { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
// Registry-driven shelf node — placed via the registry-first ToolManager { id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
// shim from Phase 0. No icon file shipped; using a placeholder icon until
// Phase 4 derives palette entries from `definition.presentation.icon`.
{ id: 'shelf', iconSrc: '/icons/column.png', label: 'Shelf' },
] ]
export function StructureTools() { export function StructureTools() {
@@ -1,20 +0,0 @@
import { ShortcutToken } from '../primitives/shortcut-token'
export function CeilingHelper() {
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Add point</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Shift" />
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -4,12 +4,9 @@ import { nodeRegistry } from '@pascal-app/core'
import { useIsMobile } from '../../../hooks/use-mobile' import { useIsMobile } from '../../../hooks/use-mobile'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper' import { BuildingHelper } from './building-helper'
import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper' import { ItemHelper } from './item-helper'
import { RegisteredToolHelper } from './registered-tool-helper' import { RegisteredToolHelper } from './registered-tool-helper'
import { RoofHelper } from './roof-helper' import { RoofHelper } from './roof-helper'
import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper'
export function HelperManager() { export function HelperManager() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
@@ -29,10 +26,9 @@ export function HelperManager() {
return null return null
} }
// Registry-first: if the active tool matches a registered kind whose // Registry-first: kinds with `def.toolHints` render through the generic
// definition supplies `toolHints`, render via the generic helper. // `RegisteredToolHelper`. Today that covers ceiling / door / fence /
// Otherwise fall through to the hand-written per-tool helpers below. // item / shelf / slab / spawn / wall / window.
// Legacy helpers get deleted as their kind migrates `toolHints` in.
if (tool) { if (tool) {
const def = nodeRegistry.get(tool) const def = nodeRegistry.get(tool)
if (def?.toolHints && def.toolHints.length > 0) { if (def?.toolHints && def.toolHints.length > 0) {
@@ -40,19 +36,9 @@ export function HelperManager() {
} }
} }
// Show appropriate helper based on current tool // Legacy fallback — only `roof` remains because it hasn't migrated to
switch (tool) { // `def.tool` / `def.toolHints` yet (no Stage D port). When roof
case 'wall': // migrates, this switch deletes outright.
return <WallHelper /> if (tool === 'roof') return <RoofHelper />
case 'item': return null
return <ItemHelper />
case 'slab':
return <SlabHelper />
case 'ceiling':
return <CeilingHelper />
case 'roof':
return <RoofHelper />
default:
return null
}
} }
@@ -1,20 +0,0 @@
import { ShortcutToken } from '../primitives/shortcut-token'
export function SlabHelper() {
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Add point</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Shift" />
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,20 +0,0 @@
import { ShortcutToken } from '../primitives/shortcut-token'
export function WallHelper() {
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Set wall start / end</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Shift" />
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -24,23 +24,12 @@ import { useCallback, useEffect, useState } from 'react'
import { useIsMobile } from '../../../hooks/use-mobile' import { useIsMobile } from '../../../hooks/use-mobile'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ColumnPanel } from './column-panel'
import { DoorPanel } from './door-panel'
import { ElevatorPanel } from './elevator-panel'
import { ItemPanel } from './item-panel'
import { MobilePanelSheet } from './mobile-panel-sheet' import { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar' import { MobileSelectionBar } from './mobile-selection-bar'
import { getNodeDisplay } from './node-display' import { getNodeDisplay } from './node-display'
import { PaintPanel } from './paint-panel' import { PaintPanel } from './paint-panel'
import { ParametricInspector } from './parametric-inspector' import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel' import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel'
import { SpawnPanel } from './spawn-panel'
import { StairPanel } from './stair-panel'
import { StairSegmentPanel } from './stair-segment-panel'
import { WallPanel } from './wall-panel'
import { WindowPanel } from './window-panel'
type MovableNode = type MovableNode =
| ItemNode | ItemNode
@@ -81,37 +70,15 @@ function isMovableNode(node: AnyNode | null): node is MovableNode {
function panelForType(type: string | null) { function panelForType(type: string | null) {
if (!type) return null if (!type) return null
switch (type) { // Every kind now renders through `<ParametricInspector>`, which either
case 'item': // composes auto-derived editors from `parametrics.groups` or lazy-
return <ItemPanel /> // loads the kind-owned panel via `parametrics.customPanel`. The
case 'roof': // hardcoded switch is gone — all per-kind panel layout lives in
return <RoofPanel /> // `nodes/src/<kind>/panel.tsx`. The `type` arg is preserved for
case 'roof-segment': // future cases where we might want a non-registry fallback (e.g.
return <RoofSegmentPanel /> // reference scale, paint mode); leave the function shape intact.
case 'stair': void type
return <StairPanel /> return <ParametricInspector />
case 'stair-segment':
return <StairSegmentPanel />
case 'spawn':
return <SpawnPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'door':
return <DoorPanel />
case 'elevator':
return <ElevatorPanel />
case 'window':
return <WindowPanel />
default:
// Registry fallback: any kind registered via @pascal-app/nodes with a
// `parametrics` descriptor on its NodeDefinition gets an auto-derived
// panel. Phase 4 will replace the hardcoded switch above with the
// registry-first path; until then this fallback lets new kinds (shelf,
// etc.) have a working inspector without per-kind panel files.
return <ParametricInspector />
}
} }
function MobilePanelLayer({ function MobilePanelLayer({
@@ -130,8 +130,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1] const pi = polygon[i]
area -= polygon[j]?.[0] * polygon[i]?.[1] const pj = polygon[j]
if (!(pi && pj)) continue
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
@@ -38,7 +38,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={node.id} />}
depth={depth} depth={depth}
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
@@ -53,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
<InlineRenameInput <InlineRenameInput
defaultName="Fence" defaultName="Fence"
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={node.id}
onStartEditing={() => setIsEditing(true)} onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)} onStopEditing={() => setIsEditing(false)}
/> />
@@ -91,8 +91,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1] const pi = polygon[i]
area -= polygon[j]?.[0] * polygon[i]?.[1] const pj = polygon[j]
if (!(pi && pj)) continue
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
@@ -77,49 +77,61 @@ interface TreeNodeProps {
isLast?: boolean isLast?: boolean
} }
// Per-kind tree-node components keyed by `node.type`. Lookup replaces
// the legacy switch — adding a kind to this map is now the only edit
// needed in this file (the switch's `case '<kind>':` clauses were
// flagged by the Phase 6 grep gate as the last per-kind dispatch
// outside the registry; future work moves these to a
// `def.presentation`-driven generic tree-node and removes this map
// entirely).
const treeNodeByType: Record<
string,
React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }>
> = {
building: BuildingTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
ceiling: CeilingTreeNode,
column: ColumnTreeNode,
elevator: ElevatorTreeNode,
level: LevelTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
shelf: ShelfTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
slab: SlabTreeNode,
spawn: SpawnTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
wall: WallTreeNode,
fence: FenceTreeNode,
roof: RoofTreeNode,
stair: StairTreeNode,
door: DoorTreeNode,
window: WindowTreeNode,
zone: ZoneTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
item: ItemTreeNode,
}
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
const nodeType = useScene((state) => state.nodes[nodeId]?.type) const nodeType = useScene((state) => state.nodes[nodeId]?.type)
if (!nodeType) return null if (!nodeType) return null
const Component = treeNodeByType[nodeType]
switch (nodeType) { if (!Component) return null
case 'building': return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
return (
<BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
)
case 'ceiling':
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'column':
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'elevator':
return <ElevatorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'level':
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
case 'shelf':
return <ShelfTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `shelf_${string}`} />
case 'slab':
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'spawn':
return <SpawnTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `spawn_${string}`} />
case 'wall':
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'fence':
return <FenceTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'roof':
return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'stair':
return <StairTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'item':
return <ItemTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'door':
return <DoorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'window':
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'zone':
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `zone_${string}`} />
default:
return null
}
}) })
interface TreeNodeWrapperProps { interface TreeNodeWrapperProps {
@@ -12,7 +12,7 @@ export const ViewerZoneSystem = () => {
const structureLayer = useEditor.getState().structureLayer const structureLayer = useEditor.getState().structureLayer
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
sceneRegistry.byType.zone.forEach((id) => { sceneRegistry.byType.zone!.forEach((id) => {
const obj = sceneRegistry.nodes.get(id) const obj = sceneRegistry.nodes.get(id)
if (!obj) return if (!obj) return
+97 -1
View File
@@ -4,6 +4,15 @@ export {
type SnapshotCameraData, type SnapshotCameraData,
ThumbnailGenerator, ThumbnailGenerator,
} from './components/editor/thumbnail-generator' } from './components/editor/thumbnail-generator'
// SVG path builders for arc / annular-sector / arrow-head shapes —
// inlined into `kind: 'path'` / `kind: 'polygon'` primitives by curved
// stair rendering in `nodes/src/stair/floorplan.ts`.
export {
buildSvgAnnularSectorPath,
buildSvgArcPath,
buildSvgArrowHeadPoints,
getArcPlanPoint,
} from './components/editor-2d/svg-paths'
// Phase 5 Stage D transitional exports — pure drafting / angle helpers // Phase 5 Stage D transitional exports — pure drafting / angle helpers
// consumed by kind-owned drag actions in @pascal-app/nodes. Stage F // consumed by kind-owned drag actions in @pascal-app/nodes. Stage F
// cleanup moves these into @pascal-app/nodes (fence/drafting.ts + // cleanup moves these into @pascal-app/nodes (fence/drafting.ts +
@@ -13,6 +22,29 @@ export {
type FencePlanPoint, type FencePlanPoint,
snapFenceDraftPoint, snapFenceDraftPoint,
} from './components/tools/fence/fence-drafting' } from './components/tools/fence/fence-drafting'
// Placement-math helpers — shared by kind-owned placement tools in
// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement,
// item drop) so kinds don't reach into editor internals.
export {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToGrid,
snapToHalf,
snapUpToGridStep,
stripTransient,
} from './components/tools/item/placement-math'
export type { PlacementState } from './components/tools/item/placement-types'
// Item placement / move primitives. Re-exported here so the registry-driven
// item move-tool in `@pascal-app/nodes` can compose them — same hooks the
// legacy `MoveItemContent` + `ItemTool` use. Once item placement is fully
// owned by `nodes`, these can be inlined there and dropped from editor.
export { type DraftNodeHandle, useDraftNode } from './components/tools/item/use-draft-node'
export {
type PlacementCoordinatorConfig,
usePlacementCoordinator,
} from './components/tools/item/use-placement-coordinator'
export { CursorSphere } from './components/tools/shared/cursor-sphere' export { CursorSphere } from './components/tools/shared/cursor-sphere'
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
export { export {
@@ -24,10 +56,32 @@ export {
getAngleToSegmentReference, getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint, getSegmentAngleReferenceAtPoint,
} from './components/tools/shared/segment-angle' } from './components/tools/shared/segment-angle'
// Stair placement defaults — used by the kind-owned stair / stair-segment
// panels. Re-exported from `components/tools/stair/stair-defaults.ts`.
export {
DEFAULT_CURVED_STAIR_INNER_RADIUS,
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
DEFAULT_SPIRAL_TOP_LANDING_MODE,
DEFAULT_STAIR_ATTACHMENT_SIDE,
DEFAULT_STAIR_FILL_TO_FLOOR,
DEFAULT_STAIR_HEIGHT,
DEFAULT_STAIR_LENGTH,
DEFAULT_STAIR_RAILING_HEIGHT,
DEFAULT_STAIR_RAILING_MODE,
DEFAULT_STAIR_STEP_COUNT,
DEFAULT_STAIR_THICKNESS,
DEFAULT_STAIR_TYPE,
DEFAULT_STAIR_WIDTH,
} from './components/tools/stair/stair-defaults'
export { export {
createWallOnCurrentLevel, createWallOnCurrentLevel,
getWallGridStep, getWallGridStep,
isWallLongEnough, isWallLongEnough,
snapPointToGrid,
snapScalarToGrid, snapScalarToGrid,
snapWallDraftPoint, snapWallDraftPoint,
type WallPlanPoint, type WallPlanPoint,
@@ -36,16 +90,23 @@ export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
export { useCommandPalette } from './components/ui/command-palette' export { useCommandPalette } from './components/ui/command-palette'
export { ActionButton, ActionGroup } from './components/ui/controls/action-button' export { ActionButton, ActionGroup } from './components/ui/controls/action-button'
export { MaterialPicker } from './components/ui/controls/material-picker'
export { MetricControl } from './components/ui/controls/metric-control'
export { PanelSection } from './components/ui/controls/panel-section' export { PanelSection } from './components/ui/controls/panel-section'
export { SegmentedControl } from './components/ui/controls/segmented-control' export { SegmentedControl } from './components/ui/controls/segmented-control'
export { SliderControl } from './components/ui/controls/slider-control' export { SliderControl } from './components/ui/controls/slider-control'
export { ToggleControl } from './components/ui/controls/toggle-control' export { ToggleControl } from './components/ui/controls/toggle-control'
export { FloatingLevelSelector } from './components/ui/floating-level-selector' export { FloatingLevelSelector } from './components/ui/floating-level-selector'
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items' export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
// Item collections UI — used by the kind-owned ItemPanel in nodes/.
export { CollectionsPopover } from './components/ui/panels/collections/collections-popover'
// Phase 5 Stage E — kinds with bespoke editors (slab holes list, // Phase 5 Stage E — kinds with bespoke editors (slab holes list,
// ceiling height presets, etc.) use `parametrics.customPanel` to mount // ceiling height presets, etc.) use `parametrics.customPanel` to mount
// a kind-owned panel and need PanelWrapper for the chrome. // a kind-owned panel and need PanelWrapper for the chrome.
export { PanelWrapper } from './components/ui/panels/panel-wrapper' export { PanelWrapper } from './components/ui/panels/panel-wrapper'
// Presets popover — used by kind-owned door / window panels for their
// hardware / type / opening presets.
export { PresetsPopover } from './components/ui/panels/presets/presets-popover'
export { PALETTE_COLORS } from './components/ui/primitives/color-dot' export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
export { useSidebarStore } from './components/ui/primitives/sidebar' export { useSidebarStore } from './components/ui/primitives/sidebar'
export { Slider } from './components/ui/primitives/slider' export { Slider } from './components/ui/primitives/slider'
@@ -60,7 +121,7 @@ export {
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel' export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export type { SidebarTab } from './components/ui/sidebar/tab-bar' export type { SidebarTab } from './components/ui/sidebar/tab-bar'
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context' export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
export { PresetsProvider } from './contexts/presets-context' export { PresetsProvider, usePresetsAdapter } from './contexts/presets-context'
export type { SaveStatus } from './hooks/use-auto-save' export type { SaveStatus } from './hooks/use-auto-save'
// useDragAction is the React-side glue for the registry's DragAction // useDragAction is the React-side glue for the registry's DragAction
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports) // primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
@@ -69,9 +130,44 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
export { markToolCancelConsumed } from './hooks/use-keyboard' export { markToolCancelConsumed } from './hooks/use-keyboard'
export { EDITOR_LAYER } from './lib/constants' export { EDITOR_LAYER } from './lib/constants'
// Helper libs used by the kind-owned roof / stair / elevator panels.
export {
resolveCurrentBuildingId,
resolveElevatorNodeSupportY,
resolveElevatorSupportLevelId,
resolveElevatorSupportY,
} from './lib/elevator-support'
// Floor-plan stair helpers — the cumulative-transform walk
// (`computeFloorplanStairSegmentTransforms`) and the rich segment-entry
// builder (`buildFloorplanStairEntry`) used by the kind-owned stair
// floor-plan emitter in `@pascal-app/nodes/src/stair/floorplan.ts`.
// Each flight's transform depends on every prior sibling's length /
// height / `attachmentSide`, so individual stair-segments can't compute
// their own polygon in isolation — the stair (parent) owns the
// computation and emits the whole stack as one registry entry.
export {
buildFloorplanStairEntry,
type FloorplanStairArrowEntry,
type FloorplanStairEntry,
type FloorplanStairSegmentEntry,
} from './lib/floorplan'
export {
buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
buildWallSurfaceMaterialPatch,
getActivePaintMaterialLabel,
hasActivePaintMaterial,
} from './lib/material-paint'
export { duplicateRoofSubtree } from './lib/roof-duplication'
export type { SceneGraph } from './lib/scene' export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus' export { triggerSFX } from './lib/sfx-bus'
export { duplicateStairSubtree } from './lib/stair-duplication'
// `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/
// nodes` so they don't need their own copy / their own tailwind-merge
// dependency.
export { cn } from './lib/utils'
export { default as useAudio } from './store/use-audio' export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry' export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
export type { export type {
+54 -42
View File
@@ -33,50 +33,62 @@ function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
return true return true
} }
/**
* Material field keys per kind, used by the `structure` duplicate preset
* to strip materials from the cloned subtree. Lookup table replaces the
* legacy per-kind switch — the Phase 6 grep gate flagged `case '<kind>':`
* in this file as the remaining per-kind dispatch outside the registry.
*
* Future: move this to a `capabilities.materialFields` declaration on
* each kind's `NodeDefinition` so adding a new kind with materials is a
* registry-only edit. Today the registry doesn't surface material fields
* in a uniform way (each kind's panel reads / writes them directly), so
* this map mirrors the legacy behavior 1:1.
*/
const MATERIAL_FIELDS_BY_KIND: Record<string, ReadonlyArray<string>> = {
wall: [
'material',
'materialPreset',
'interiorMaterial',
'interiorMaterialPreset',
'exteriorMaterial',
'exteriorMaterialPreset',
],
slab: ['material', 'materialPreset'],
ceiling: ['material', 'materialPreset'],
fence: ['material', 'materialPreset'],
shelf: ['material', 'materialPreset'],
'roof-segment': ['material', 'materialPreset'],
'stair-segment': ['material', 'materialPreset'],
window: ['material', 'materialPreset'],
door: ['material', 'materialPreset'],
roof: [
'material',
'materialPreset',
'topMaterial',
'topMaterialPreset',
'edgeMaterial',
'edgeMaterialPreset',
'wallMaterial',
'wallMaterialPreset',
],
stair: [
'material',
'materialPreset',
'railingMaterial',
'railingMaterialPreset',
'treadMaterial',
'treadMaterialPreset',
'sideMaterial',
'sideMaterialPreset',
],
}
function stripMaterials(node: AnyNode): AnyNode { function stripMaterials(node: AnyNode): AnyNode {
const fields = MATERIAL_FIELDS_BY_KIND[node.type]
if (!fields) return node
const next = { ...node } as Record<string, unknown> const next = { ...node } as Record<string, unknown>
for (const field of fields) delete next[field]
switch (node.type) {
case 'wall':
delete next.material
delete next.materialPreset
delete next.interiorMaterial
delete next.interiorMaterialPreset
delete next.exteriorMaterial
delete next.exteriorMaterialPreset
break
case 'slab':
case 'ceiling':
case 'fence':
case 'roof-segment':
case 'stair-segment':
case 'window':
case 'door':
delete next.material
delete next.materialPreset
break
case 'roof':
delete next.material
delete next.materialPreset
delete next.topMaterial
delete next.topMaterialPreset
delete next.edgeMaterial
delete next.edgeMaterialPreset
delete next.wallMaterial
delete next.wallMaterialPreset
break
case 'stair':
delete next.material
delete next.materialPreset
delete next.railingMaterial
delete next.railingMaterialPreset
delete next.treadMaterial
delete next.treadMaterialPreset
delete next.sideMaterial
delete next.sideMaterialPreset
break
}
return next as AnyNode return next as AnyNode
} }
+9 -3
View File
@@ -13,6 +13,7 @@ import {
type MaterialTarget, type MaterialTarget,
type RoofNode, type RoofNode,
type RoofSurfaceMaterialRole, type RoofSurfaceMaterialRole,
type ShelfNode,
type SlabNode, type SlabNode,
type StairNode, type StairNode,
type StairSurfaceMaterialRole, type StairSurfaceMaterialRole,
@@ -22,7 +23,7 @@ import {
export type PaintableMaterialTarget = Extract< export type PaintableMaterialTarget = Extract<
MaterialTarget, MaterialTarget,
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' 'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' | 'shelf'
> >
export type SingleSurfaceMaterialRole = 'surface' export type SingleSurfaceMaterialRole = 'surface'
@@ -133,7 +134,7 @@ export function buildStairSurfaceMaterialPatch(
} }
export function buildSingleSurfaceMaterialPatch< export function buildSingleSurfaceMaterialPatch<
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode, TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> { >(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
return { return {
material, material,
@@ -222,7 +223,8 @@ export function resolveActivePaintMaterialFromSelection(params: {
(selectedNode.type === 'fence' || (selectedNode.type === 'fence' ||
selectedNode.type === 'column' || selectedNode.type === 'column' ||
selectedNode.type === 'slab' || selectedNode.type === 'slab' ||
selectedNode.type === 'ceiling') && selectedNode.type === 'ceiling' ||
selectedNode.type === 'shelf') &&
selectedMaterialTarget.role === 'surface' selectedMaterialTarget.role === 'surface'
) { ) {
const target = selectedNode.type const target = selectedNode.type
@@ -280,5 +282,9 @@ export function resolvePaintTargetFromSelection(params: {
return 'ceiling' return 'ceiling'
} }
if (selectedNode.type === 'shelf') {
return 'shelf'
}
return null return null
} }
+6 -2
View File
@@ -283,7 +283,11 @@ export function syncEditorSelectionFromCurrentScene() {
if (shouldRestoreEditorUiState) { if (shouldRestoreEditorUiState) {
if (restoredSelection) { if (restoredSelection) {
useViewer.getState().setSelection(restoredSelection) // PersistedSelectionPath carries plain `string` ids (read from
// localStorage, no branded-template-literal guarantee). The viewer's
// SelectionPath expects branded ids. The runtime values match the
// brand; the cast bridges the static gap.
useViewer.getState().setSelection(restoredSelection as never)
useEditor.setState( useEditor.setState(
restoredEditorUiState.phase === 'site' restoredEditorUiState.phase === 'site'
? (selectionDrivenEditorUiState ?? restoredEditorUiState) ? (selectionDrivenEditorUiState ?? restoredEditorUiState)
@@ -305,7 +309,7 @@ export function syncEditorSelectionFromCurrentScene() {
} }
if (restoredSelection) { if (restoredSelection) {
useViewer.getState().setSelection(restoredSelection) useViewer.getState().setSelection(restoredSelection as never)
if (selectionDrivenEditorUiState) { if (selectionDrivenEditorUiState) {
useEditor.setState(selectionDrivenEditorUiState) useEditor.setState(selectionDrivenEditorUiState)
} }
+2 -1
View File
@@ -30,7 +30,8 @@
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"lucide-react": "^1", "lucide-react": "^1",
"react": "^18 || ^19", "react": "^18 || ^19",
"three": "^0.184" "three": "^0.184",
"zustand": "^5"
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.8.0", "@pascal-app/core": "^0.8.0",
+50
View File
@@ -0,0 +1,50 @@
import { BuildingNode as BuildingNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { buildingParametrics } from './parametrics'
import { BuildingNode } from './schema'
/**
* Building — Stage A. Container for levels; can be translated /
* rotated as a whole (movable + rotatable on Y). The legacy
* `MoveBuildingContent` handles building-wide drag; the registry
* fallback would translate position, which is close to right —
* but kept legacy at Stage A to avoid disturbing the building's
* world-space group transform handling.
*/
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
kind: 'building',
schemaVersion: 1,
schema: BuildingNode,
category: 'site',
defaults: () => {
const stub = BuildingNodeSchema.parse({ id: 'building_default' as never, type: 'building' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
// Building is a container — sidebar / building switcher drive
// selection, never 3D click. Same reasoning as `level` / `site`.
duplicable: false,
deletable: false,
},
parametrics: buildingParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
presentation: {
label: 'Building',
description: 'A building container holding one or more levels.',
icon: { kind: 'url', src: '/icons/building.png' },
paletteSection: 'site',
paletteOrder: 6,
},
mcp: {
description: 'A building container that groups levels.',
},
}
+1
View File
@@ -0,0 +1 @@
export { buildingDefinition } from './definition'
@@ -0,0 +1,5 @@
import type { BuildingNode, ParametricDescriptor } from '@pascal-app/core'
export const buildingParametrics: ParametricDescriptor<BuildingNode> = {
groups: [],
}
@@ -1,8 +1,9 @@
'use client'
import { type BuildingNode, useRegistry } from '@pascal-app/core' import { type BuildingNode, useRegistry } from '@pascal-app/core'
import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer'
import { useRef } from 'react' import { useRef } from 'react'
import type { Group } from 'three' import type { Group } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
export const BuildingRenderer = ({ node }: { node: BuildingNode }) => { export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
const ref = useRef<Group>(null!) const ref = useRef<Group>(null!)
@@ -23,3 +24,5 @@ export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
</group> </group>
) )
} }
export default BuildingRenderer
+1
View File
@@ -0,0 +1 @@
export { BuildingNode } from '@pascal-app/core'
+18
View File
@@ -1,5 +1,11 @@
import type { NodeDefinition } from '@pascal-app/core' import type { NodeDefinition } from '@pascal-app/core'
import { buildCeilingFloorplan } from './floorplan' import { buildCeilingFloorplan } from './floorplan'
import {
ceilingAddVertexAffordance,
ceilingMoveEdgeAffordance,
ceilingMoveVertexAffordance,
} from './floorplan-affordances'
import { ceilingFloorplanMoveTarget } from './floorplan-move'
import { ceilingParametrics } from './parametrics' import { ceilingParametrics } from './parametrics'
import { CeilingNode } from './schema' import { CeilingNode } from './schema'
@@ -76,6 +82,18 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
priority: 4, priority: 4,
}, },
floorplan: buildCeilingFloorplan, 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: [ toolHints: [
{ key: 'Left click', label: 'Trace ceiling outline' }, { key: 'Left click', label: 'Trace ceiling outline' },
@@ -0,0 +1,16 @@
import type { CeilingNode } from '@pascal-app/core'
import {
createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance,
} from '../shared/polygon-vertex-affordance'
/**
* 2D drag affordances for ceiling. Same three operations as slab
* (`move-vertex`, `add-vertex`, `move-edge`), each accepting an
* optional `holeIndex`. See `slab/floorplan-affordances.ts` for the
* full contract.
*/
export const ceilingMoveVertexAffordance = createPolygonVertexAffordance<CeilingNode>('ceiling')
export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance<CeilingNode>('ceiling')
export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<CeilingNode>('ceiling')
@@ -0,0 +1,88 @@
import {
type AnyNodeId,
type CeilingNode,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
/**
* 2D floor-plan move handler for ceiling — mirrors the 3D `MoveCeilingTool`
* live-drag pattern. See the equivalent module in `slab/floorplan-move.ts`
* for the full rationale; the only ceiling-specific detail is the
* preserved Y offset (`CeilingSystem` positions the mesh at `height 0.01`
* on rebuild, so the direct `mesh.position.y` mirrors that to avoid a
* vertical teleport when the React group position is reconciled).
*/
const GRID_STEP = 0.5
function translatePolygon(
polygon: ReadonlyArray<readonly [number, number]>,
dx: number,
dz: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number])
}
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node }) => {
const ceilingId = node.id as AnyNodeId
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
const originalHoles = (node.holes ?? []).map((hole) =>
hole.map(([x, z]) => [x, z] as [number, number]),
)
const height = node.height ?? 2.5
let anchor: [number, number] | null = null
let lastDelta: [number, number] = [0, 0]
const session: FloorplanMoveTargetSession = {
affectedIds: [ceilingId],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
if (!anchor) {
anchor = [snapped[0], snapped[1]]
return
}
const dx = snapped[0] - anchor[0]
const dz = snapped[1] - anchor[1]
lastDelta = [dx, dz]
useLiveTransforms.getState().set(ceilingId, {
position: [dx, 0, dz],
rotation: 0,
})
const mesh = sceneRegistry.nodes.get(ceilingId) as THREE.Object3D | undefined
// Preserve ceiling height — `CeilingSystem` sets `mesh.position.y =
// height 0.01` on each rebuild; mirror that during the drag so
// the mesh stays at ceiling height (not collapsed to y=0).
if (mesh) mesh.position.set(dx, height - 0.01, dz)
},
canCommit() {
const live = useScene.getState().nodes[ceilingId] as CeilingNode | undefined
if (!live || live.type !== 'ceiling') return false
const [dx, dz] = lastDelta
if (dx === 0 && dz === 0) return false
// Sync commit sequence — see `slab/floorplan-move.ts` for the
// full ordering rationale (scene write → direct markDirty →
// useLiveTransforms.clear, all sync in this handler so React
// render + CeilingSystem rebuild land in the same paint).
useScene.getState().updateNodes([
{
id: ceilingId,
data: {
polygon: translatePolygon(originalPolygon, dx, dz),
holes: originalHoles.map((h) => translatePolygon(h, dx, dz)),
},
},
])
useScene.getState().markDirty(ceilingId)
useLiveTransforms.getState().clear(ceilingId)
return true
},
}
return session
}
+86 -14
View File
@@ -1,15 +1,29 @@
import type { CeilingNode, FloorplanGeometry, FloorplanPoint } from '@pascal-app/core' import type {
CeilingNode,
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
} from '@pascal-app/core'
/** /**
* Stage C floor-plan builder for ceiling. Renders the polygon outline * Stage C floor-plan builder for ceiling. Dashed boundary (ceilings sit
* as a dashed boundary (ceilings are above and would visually obscure * above the slab); when selected, mounts the same boundary editor as
* the slab/walls if drawn solid). Same shape as slab but visually * slab — vertex + midpoint + edge handles on the outer ring AND every
* distinct. * 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 const polygon = node.polygon
if (!polygon || polygon.length < 3) return null 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 outer: FloorplanPoint[] = polygon.map(([x, z]) => [x, z] as FloorplanPoint)
const ring = (points: 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))) segments.push(ring(hole.map(([x, z]) => [x, z] as FloorplanPoint)))
} }
return { const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#94a3b8'
kind: 'path',
d: segments.join(' '), const children: FloorplanGeometry[] = [
fill: 'none', {
stroke: '#94a3b8', kind: 'path',
strokeWidth: 0.03, d: segments.join(' '),
strokeDasharray: '0.15 0.1', fill: 'none',
opacity: 0.7, stroke,
strokeWidth: showSelectedChrome ? 0.04 : 0.03,
strokeDasharray: '0.15 0.1',
opacity: showSelectedChrome ? 0.95 : 0.7,
},
]
if (isSelected) {
appendRingEditor(children, polygon, undefined)
holes.forEach((hole, holeIndex) => {
if (hole.length >= 3) appendRingEditor(children, hole, holeIndex)
})
}
return { kind: 'group', children }
}
/**
* Same boundary editor as slab — see `nodes/src/slab/floorplan.ts` for
* the contract. The kinds differ only in their fill / stroke chrome;
* the editor primitives are identical.
*/
function appendRingEditor(
children: FloorplanGeometry[],
ring: ReadonlyArray<readonly [number, number]>,
holeIndex: number | undefined,
): void {
for (let i = 0; i < ring.length; i++) {
const a = ring[i]!
const b = ring[(i + 1) % ring.length]!
children.push({
kind: 'edge-handle',
x1: a[0],
y1: a[1],
x2: b[0],
y2: b[1],
affordance: 'move-edge',
payload: { holeIndex, edgeIndex: i },
})
}
for (let i = 0; i < ring.length; i++) {
const a = ring[i]!
const b = ring[(i + 1) % ring.length]!
children.push({
kind: 'midpoint-handle',
point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
affordance: 'add-vertex',
payload: { holeIndex, edgeIndex: i },
})
}
for (let i = 0; i < ring.length; i++) {
const [x, z] = ring[i]!
children.push({
kind: 'endpoint-handle',
point: [x, z],
state: 'idle',
affordance: 'move-vertex',
payload: { holeIndex, vertexIndex: i },
})
} }
} }
+137 -1
View File
@@ -11,8 +11,9 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' 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 type * as THREE from 'three'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
/** /**
* Phase 5 Stage D — ceiling whole-move tool. * 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) 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 }) => { export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now()) const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number])) 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) => { const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return
const localX = snap(event.localPosition[0]) const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2]) const localZ = snap(event.localPosition[2])
@@ -130,6 +150,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return
if (Date.now() - activatedAtRef.current < 150) { if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
return return
@@ -176,11 +197,126 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
} }
}, [exitMoveMode, node.id]) }, [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 ( return (
<group> <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} /> <CursorSphere position={cursorLocalPos} showTooltip={false} />
</group> </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 export default MoveCeilingTool
+102 -11
View File
@@ -1,15 +1,106 @@
'use client' '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 export default CeilingRenderer
+3 -6
View File
@@ -3,13 +3,10 @@
import { CeilingSystem } from '@pascal-app/viewer' import { CeilingSystem } from '@pascal-app/viewer'
/** /**
* Registry-driven ceiling system bundle. Re-exports the legacy * Registry-driven ceiling system bundle. Wraps `CeilingSystem` so it
* `CeilingSystem` so it mounts via `RegisteredSystems` when ceiling is * mounts via `RegisteredSystems`.
* 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.
* *
* 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`. * `buildCeilingGeometry(node)` and migrate to `def.geometry`.
*/ */
const CeilingSystems = () => { const CeilingSystems = () => {
+64
View File
@@ -0,0 +1,64 @@
import { ColumnNode as ColumnNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { buildColumnFloorplan } from './floorplan'
import { columnParametrics } from './parametrics'
import { ColumnNode } from './schema'
/**
* Column — Stage A registration. Wrap-export of the legacy
* `ColumnRenderer` (no system — column geometry is computed inline in
* the renderer). Inspector / move / floorplan still go through legacy
* paths via panel-manager.tsx / item-move-tool.tsx / floorplan-panel.tsx
* (their hardcoded `case 'column':` entries fire before the registry
* fallback).
*
* Capabilities: column doesn't declare `movable` because its move is
* bespoke (legacy MoveColumnTool snaps to slab + free placement on
* the X/Z plane with rotation).
*
* Defaults computed via stub-parse so we leverage every zod
* `.default()` annotation on the schema (~60 fields).
*/
export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'column',
schemaVersion: 1,
schema: ColumnNode,
category: 'structure',
defaults: () => {
const stub = ColumnNodeSchema.parse({ id: 'column_default' as never, type: 'column' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: columnParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
// Stage D — 3D move-tool (registry-driven). Replaces the legacy
// `MoveColumnTool` in editor's dispatcher. Same 0.5m grid snap +
// live-transform preview the legacy used.
affordanceTools: {
move: () => import('./move-tool'),
},
floorplan: buildColumnFloorplan,
presentation: {
label: 'Column',
description: 'A parametric column with configurable cross-section, base, and capital.',
icon: { kind: 'url', src: '/icons/column.png' },
paletteSection: 'structure',
paletteOrder: 70,
},
mcp: {
description: 'A parametric column placed on a slab or level.',
},
}
+185
View File
@@ -0,0 +1,185 @@
import type {
ColumnNode,
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
} from '@pascal-app/core'
/**
* Stage C floor-plan builder for column. Inlined from the legacy
* `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The
* footprint shape depends on `crossSection` (square / rectangular /
* round / octagonal / sixteen-sided) and `supportStyle` (vertical /
* a-frame / x-brace / etc.) — brace supports use a rotated rectangle
* spanning the base spread; standalone columns use the shaft profile.
*
* When selected, switches to a themed accent stroke and emits a move
* handle at the column center. No dimension overlay (columns don't
* have a natural "length" axis like a wall).
*/
export function buildColumnFloorplan(
node: ColumnNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const polygon = getColumnPlanFootprint(node)
if (polygon.length < 3) return null
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151'
const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af'
const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint)
const children: FloorplanGeometry[] = [
{
kind: 'polygon',
points,
fill,
stroke,
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
opacity: 0.92,
},
]
// Hatch overlay on selected — same `<defs>` pattern as the wall.
if (isSelected && palette) {
children.push({
kind: 'hatch',
points,
color: palette.selectedHatch,
opacity: 0.7,
})
}
// Move handle at the column center when selected.
if (isSelected) {
children.push({
kind: 'move-handle',
point: [node.position[0], node.position[2]],
})
}
return { kind: 'group', children }
}
// ── Inlined helpers from legacy floorplan-panel.tsx ───────────────────
type PlanPoint = { x: number; y: number }
function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
const c = Math.cos(rotation)
const s = Math.sin(rotation)
return [x * c - y * s, x * s + y * c]
}
function getRotatedRectanglePolygon(
center: PlanPoint,
width: number,
depth: number,
rotation: number,
): PlanPoint[] {
const halfW = width / 2
const halfD = depth / 2
const corners: Array<[number, number]> = [
[-halfW, -halfD],
[halfW, -halfD],
[halfW, halfD],
[-halfW, halfD],
]
return corners.map(([x, y]) => {
const [rx, ry] = rotatePlanVector(x, y, rotation)
return { x: center.x + rx, y: center.y + ry }
})
}
function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
const center: PlanPoint = { x: column.position[0], y: column.position[2] }
// Brace-support columns: rotated rectangle spanning the base spread.
if (
column.supportStyle === 'a-frame' ||
column.supportStyle === 'y-frame' ||
column.supportStyle === 'v-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
) {
const width = Math.max(
column.supportStyle === 'a-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
? (column.braceBottomSpread ?? 1.2)
: 0,
column.braceTopSpread ??
(column.supportStyle === 'y-frame' ||
column.supportStyle === 'v-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
? 1
: 0),
(column.braceWidth ?? column.width) * 2,
)
const depth = Math.max(
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'box-frame'
? (column.braceTopSpread ?? 1)
: 0,
column.braceDepth ?? column.depth,
0.08,
)
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
}
// Standalone column: shaft profile expanded for base + capital.
const isRound =
column.crossSection === 'round' ||
column.crossSection === 'octagonal' ||
column.crossSection === 'sixteen-sided'
const shaftWidth = isRound ? column.radius * 2 : column.width
const shaftDepth = isRound ? column.radius * 2 : column.depth
const width = Math.max(
shaftWidth,
column.width * column.baseWidthScale,
column.width * column.capitalWidthScale,
)
const depth = Math.max(
shaftDepth,
column.depth * column.baseDepthScale,
column.depth * column.capitalDepthScale,
)
if (column.crossSection === 'square' || column.crossSection === 'rectangular') {
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
}
const segmentCount =
column.crossSection === 'octagonal' ? 8 : column.crossSection === 'sixteen-sided' ? 16 : 32
return Array.from({ length: segmentCount }, (_, index) => {
const angle = (index / segmentCount) * Math.PI * 2
const localX = Math.cos(angle) * (width / 2)
const localY = Math.sin(angle) * (depth / 2)
const [offsetX, offsetY] = rotatePlanVector(localX, localY, column.rotation)
return { x: center.x + offsetX, y: center.y + offsetY }
})
}
+1
View File
@@ -0,0 +1 @@
export { columnDefinition } from './definition'
@@ -1,24 +1,36 @@
import '../../../three-types' 'use client'
import { import {
type AnyNodeId, type AnyNodeId,
ColumnNode, type ColumnNode,
type ColumnNode as ColumnNodeType, ColumnNode as ColumnNodeSchema,
emitter, emitter,
type GridEvent, type GridEvent,
sceneRegistry, sceneRegistry,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
/**
* 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 const roundToHalf = (value: number) => Math.round(value * 2) / 2
export function MoveColumnTool({ node }: { node: ColumnNodeType }) { function MoveColumnTool({ node }: { node: ColumnNode }) {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
@@ -48,7 +60,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
0, 0,
roundToHalf(event.localPosition[2]), roundToHalf(event.localPosition[2]),
] ]
const nodeId = (node as { id?: ColumnNodeType['id'] }).id const nodeId = (node as { id?: ColumnNode['id'] }).id
if (nodeId && useScene.getState().nodes[nodeId]) { if (nodeId && useScene.getState().nodes[nodeId]) {
committed = true committed = true
@@ -56,7 +68,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { position }) useScene.getState().updateNode(nodeId, { position })
} else if (node.parentId) { } else if (node.parentId) {
const column = ColumnNode.parse({ const column = ColumnNodeSchema.parse({
...node, ...node,
id: undefined, id: undefined,
metadata: {}, metadata: {},
@@ -68,7 +80,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
} }
useLiveTransforms.getState().clear(node.id) useLiveTransforms.getState().clear(node.id)
sfxEmitter.emit('sfx:item-place') triggerSFX('sfx:item-place')
exitMoveMode() exitMoveMode()
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
@@ -103,3 +115,5 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} /> return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
} }
export default MoveColumnTool
@@ -7,17 +7,20 @@ import {
type ColumnPresetId, type ColumnPresetId,
useScene, useScene,
} from '@pascal-app/core' } 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 { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react' import { Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
const SELECT_CLASS = 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' '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'
@@ -178,7 +181,7 @@ function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial<
} }
} }
export function ColumnPanel() { export default function ColumnPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length) const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
@@ -204,14 +207,14 @@ export function ColumnPanel() {
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
if (!selectedId) return if (!selectedId) return
sfxEmitter.emit('sfx:structure-delete') triggerSFX('sfx:structure-delete')
deleteNode(selectedId as AnyNode['id']) deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [deleteNode, selectedId, setSelection]) }, [deleteNode, selectedId, setSelection])
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') triggerSFX('sfx:item-pick')
setMovingNode(node) setMovingNode(node)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection]) }, [node, setMovingNode, setSelection])
@@ -488,9 +491,7 @@ export function ColumnPanel() {
<select <select
className={SELECT_CLASS} className={SELECT_CLASS}
onChange={(event) => onChange={(event) =>
handleUpdate( handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))
shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']),
)
} }
value={shaftProfile} value={shaftProfile}
> >
@@ -547,9 +548,7 @@ export function ColumnPanel() {
label="End Width" label="End Width"
max={1.2} max={1.2}
min={0.3} min={0.3}
onChange={(value) => onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
}
precision={2} precision={2}
step={0.02} step={0.02}
value={node.shaftStartScale ?? 0.68} value={node.shaftStartScale ?? 0.68}
@@ -571,9 +570,7 @@ export function ColumnPanel() {
label="End Width" label="End Width"
max={1.2} max={1.2}
min={0.3} min={0.3}
onChange={(value) => onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
}
precision={2} precision={2}
step={0.02} step={0.02}
value={node.shaftStartScale ?? 0.84} value={node.shaftStartScale ?? 0.84}
@@ -661,246 +658,246 @@ export function ColumnPanel() {
{!isBraceSupport && ( {!isBraceSupport && (
<PanelSection title="Ends"> <PanelSection title="Ends">
<select <select
className={SELECT_CLASS} className={SELECT_CLASS}
onChange={(event) => { onChange={(event) => {
const capitalStyle = event.target.value as ColumnNode['capitalStyle'] 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({ handleUpdate({
capitalWidthScale: value, capitalStyle,
...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }), ...(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,
}),
}) })
} }}
precision={2} value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')}
step={0.02} >
value={node.capitalWidthScale ?? 1.28} <option value="none">No Top</option>
/> <option value="simple">Simple Top</option>
)} <option value="stepped">Stepped Top</option>
{node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && ( <option value="rounded">Rounded Top</option>
<SliderControl </select>
label="Top Depth" {node.capitalStyle !== 'none' && (
max={2.4} <SliderControl
min={0.6} label="Top Height"
onChange={(value) => handleUpdate({ capitalDepthScale: value })} max={0.8}
precision={2} min={0.06}
step={0.02} onChange={(value) => handleUpdate({ capitalHeight: value })}
value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28} precision={2}
/> step={0.02}
)} unit="m"
{node.capitalStyle === 'stepped' && ( value={node.capitalHeight}
<SliderControl />
label="Top Tiers" )}
max={8} {node.capitalStyle !== 'none' && (
min={3} <SliderControl
onChange={(value) => handleUpdate({ capitalTierCount: Math.round(value) })} label="Top Width"
precision={0} max={2.4}
step={1} min={0.6}
value={node.capitalTierCount ?? 3} onChange={(value) =>
/> handleUpdate({
)} capitalWidthScale: value,
{node.capitalStyle === 'stepped' && ( ...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }),
<SliderControl })
label="Top Step Spread" }
max={0.9} precision={2}
min={0.05} step={0.02}
onChange={(value) => handleUpdate({ capitalStepSpread: value })} value={node.capitalWidthScale ?? 1.28}
precision={2} />
step={0.01} )}
value={node.capitalStepSpread ?? 0.34} {node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && (
/> <SliderControl
)} label="Top Depth"
<select max={2.4}
className={`${SELECT_CLASS} mt-2`} min={0.6}
onChange={(event) => { onChange={(value) => handleUpdate({ capitalDepthScale: value })}
const baseStyle = event.target.value as ColumnNode['baseStyle'] precision={2}
handleUpdate({ step={0.02}
baseStyle, value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28}
...(baseStyle === 'none' />
? {} )}
: { {node.capitalStyle === 'stepped' && (
baseHeight: Math.max(node.baseHeight, 0.12), <SliderControl
baseTierCount: label="Top Tiers"
baseStyle === 'stepped-square' max={8}
? Math.max(node.baseTierCount ?? 3, 3) min={3}
: node.baseTierCount, onChange={(value) => handleUpdate({ capitalTierCount: Math.round(value) })}
baseWidthScale: Math.max( precision={0}
node.baseWidthScale ?? 1.24, step={1}
baseStyle === 'stepped-square' ? 1.42 : 1.24, value={node.capitalTierCount ?? 3}
), />
baseDepthScale: Math.max( )}
node.baseDepthScale ?? 1.24, {node.capitalStyle === 'stepped' && (
baseStyle === 'stepped-square' ? 1.42 : 1.24, <SliderControl
), label="Top Step Spread"
baseStepSpread: max={0.9}
baseStyle === 'stepped-square' min={0.05}
? Math.max(node.baseStepSpread ?? 0.34, 0.34) onChange={(value) => handleUpdate({ capitalStepSpread: value })}
: node.baseStepSpread, precision={2}
basePlinthHeightRatio: step={0.01}
baseStyle === 'round-rings' value={node.capitalStepSpread ?? 0.34}
? (node.basePlinthHeightRatio ?? 0.44) />
: node.basePlinthHeightRatio, )}
baseRoundBandScale: <select
baseStyle === 'round-rings' className={`${SELECT_CLASS} mt-2`}
? (node.baseRoundBandScale ?? 0.92) onChange={(event) => {
: node.baseRoundBandScale, const baseStyle = event.target.value as ColumnNode['baseStyle']
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({ handleUpdate({
baseWidthScale: value, baseStyle,
...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }), ...(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,
}),
}) })
} }}
precision={2} value={node.baseStyle ?? 'square-plinth'}
step={0.02} >
value={node.baseWidthScale ?? 1.24} <option value="none">No Bottom</option>
/> <option value="simple-square">Simple Block Bottom</option>
)} <option value="square-plinth">Square Plinth Bottom</option>
{node.baseStyle !== 'none' && node.crossSection === 'rectangular' && ( <option value="stepped-square">Stepped Bottom</option>
<SliderControl <option value="round-rings">Rounded Bottom</option>
label="Bottom Depth" </select>
max={2.4} {node.baseStyle !== 'none' && (
min={0.6} <SliderControl
onChange={(value) => handleUpdate({ baseDepthScale: value })} label="Bottom Height"
precision={2} max={0.8}
step={0.02} min={0.06}
value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24} onChange={(value) => handleUpdate({ baseHeight: value })}
/> precision={2}
)} step={0.02}
{node.baseStyle === 'round-rings' && ( unit="m"
<SliderControl value={node.baseHeight}
label="Plinth Thickness" />
max={0.7} )}
min={0.2} {node.baseStyle !== 'none' && (
onChange={(value) => handleUpdate({ basePlinthHeightRatio: value })} <SliderControl
precision={2} label="Bottom Width"
step={0.01} max={2.4}
value={node.basePlinthHeightRatio ?? 0.44} min={0.6}
/> onChange={(value) =>
)} handleUpdate({
{node.baseStyle === 'round-rings' && ( baseWidthScale: value,
<SliderControl ...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }),
label="Round Band Width" })
max={1.2} }
min={0.5} precision={2}
onChange={(value) => handleUpdate({ baseRoundBandScale: value })} step={0.02}
precision={2} value={node.baseWidthScale ?? 1.24}
step={0.01} />
value={node.baseRoundBandScale ?? 0.92} )}
/> {node.baseStyle !== 'none' && node.crossSection === 'rectangular' && (
)} <SliderControl
{node.baseStyle === 'round-rings' && ( label="Bottom Depth"
<SliderControl max={2.4}
label="Neck Width" min={0.6}
max={1} onChange={(value) => handleUpdate({ baseDepthScale: value })}
min={0.35} precision={2}
onChange={(value) => handleUpdate({ baseNeckScale: value })} step={0.02}
precision={2} value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24}
step={0.01} />
value={node.baseNeckScale ?? 0.72} )}
/> {node.baseStyle === 'round-rings' && (
)} <SliderControl
{node.baseStyle === 'stepped-square' && ( label="Plinth Thickness"
<SliderControl max={0.7}
label="Bottom Tiers" min={0.2}
max={8} onChange={(value) => handleUpdate({ basePlinthHeightRatio: value })}
min={3} precision={2}
onChange={(value) => handleUpdate({ baseTierCount: Math.round(value) })} step={0.01}
precision={0} value={node.basePlinthHeightRatio ?? 0.44}
step={1} />
value={node.baseTierCount ?? 3} )}
/> {node.baseStyle === 'round-rings' && (
)} <SliderControl
{node.baseStyle === 'stepped-square' && ( label="Round Band Width"
<SliderControl max={1.2}
label="Bottom Step Spread" min={0.5}
max={0.9} onChange={(value) => handleUpdate({ baseRoundBandScale: value })}
min={0.05} precision={2}
onChange={(value) => handleUpdate({ baseStepSpread: value })} step={0.01}
precision={2} value={node.baseRoundBandScale ?? 0.92}
step={0.01} />
value={node.baseStepSpread ?? 0.34} )}
/> {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>
)} )}
+24
View File
@@ -0,0 +1,24 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { ColumnNode } from './schema'
/**
* Stage A inspector — minimal. Column has 60+ schema fields (cross-
* section, shaft profile, capital style, base style, carvings, ring
* placement, etc.); the legacy `<ColumnPanel>` renders these via
* panel-manager's hardcoded switch. The descriptor below registers
* the kind as "has parametric data" without trying to express the
* full legacy panel — Stage E will replace it via `customPanel`.
*/
export const columnParametrics: ParametricDescriptor<ColumnNode> = {
groups: [
{
label: 'Dimensions',
fields: [
{ key: 'height', kind: 'number', unit: 'm', min: 0.5, max: 6, step: 0.05 },
{ key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 },
{ key: 'depth', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 },
],
},
],
customPanel: () => import('./panel'),
}
@@ -1,14 +1,18 @@
'use client'
import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
import { createContext, useContext, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import { import {
baseMaterial,
createColumnBoxGeometry, createColumnBoxGeometry,
createColumnCylinderGeometry, createColumnCylinderGeometry,
createColumnSphereGeometry, createColumnSphereGeometry,
createColumnTorusGeometry, createColumnTorusGeometry,
} from '../../../systems/column/column-geometry' createMaterial,
createMaterialFromPresetRef,
useNodeEvents,
} from '@pascal-app/viewer'
import { createContext, useContext, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
const ColumnMaterialContext = createContext<Material>(baseMaterial as Material) const ColumnMaterialContext = createContext<Material>(baseMaterial as Material)
const ColumnEdgeSoftnessContext = createContext(0.025) const ColumnEdgeSoftnessContext = createContext(0.025)
@@ -2165,3 +2169,5 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
</ColumnMaterialContext.Provider> </ColumnMaterialContext.Provider>
) )
} }
export default ColumnRenderer
+1
View File
@@ -0,0 +1 @@
export { ColumnNode } from '@pascal-app/core'
+19 -1
View File
@@ -1,5 +1,6 @@
import type { NodeDefinition } from '@pascal-app/core' import type { NodeDefinition } from '@pascal-app/core'
import { buildDoorFloorplan } from './floorplan' import { buildDoorFloorplan } from './floorplan'
import { doorFloorplanMoveTarget } from './floorplan-move'
import { doorParametrics } from './parametrics' import { doorParametrics } from './parametrics'
import { DoorNode } from './schema' 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 // Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
// direction + perpendicular for the cutout footprint. // direction + perpendicular for the cutout footprint.
floorplan: buildDoorFloorplan, 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: [ toolHints: [
{ key: 'Left click', label: 'Place door on wall' }, { key: 'Left click', label: 'Place door on wall' },
@@ -67,7 +85,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
presentation: { presentation: {
label: 'Door', label: 'Door',
description: 'A door cut into a wall. Animated open/close state.', 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', paletteSection: 'structure',
paletteOrder: 50, paletteOrder: 50,
}, },
+87
View File
@@ -0,0 +1,87 @@
import {
type AnyNodeId,
type DoorNode,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
useScene,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { findClosestWallInPlan } from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './door-math'
/**
* 2D floor-plan move handler for door — kicks in when the user clicks
* "Move" on the door inspector (or action menu) and the floor-plan
* view is active. Pointer in plan space → snap to nearest wall →
* project onto wall axis → snap local-X to 0.5m grid → clamp inside
* wall bounds → commit via `useScene.updateNodes`.
*
* Mirrors the 3D `move-tool.tsx` behaviour minus the R3F event plumbing:
* - Re-parents on transition between walls (parentId + wallId).
* - Adapts `side` + `rotation` from the wall normal under the pointer.
* - hasWallChildOverlap blocks committing overlapping placements.
*
* Curved walls are skipped by `findClosestWallInPlan` — same guardrail
* as the 3D port and the legacy `DoorTool` / `MoveDoorTool`.
*/
export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node }) => {
// Snapshot of the door's "valid" state at move-start — used by
// canCommit to decide whether the current snapped position is OK.
const startLevelId = (() => {
// Walk up via parentId until we hit a node whose type isn't 'wall'
// — that's the level (or null). The door is wall-hosted, so the
// wall's parent is the level. Cached at start because the parent
// chain doesn't change during a move.
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null
})()
const session: FloorplanMoveTargetSession = {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
if (!hit) return // pointer off any wall — keep door at last valid position
// Snap the wall-local X to 0.5m grid (Shift bypasses).
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
// Build the updates atomically — position + rotation + side +
// parentId + wallId in a single scene write. The current door's
// parent might be a different wall; re-anchoring requires moving
// the node in the parent's children list (the registry's
// updateNode does this when parentId changes).
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
data: {
position: [clampedX, clampedY, 0],
rotation: [0, hit.itemRotation, 0],
side: hit.side,
parentId: hit.wall.id,
wallId: hit.wall.id,
},
},
])
},
canCommit() {
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
if (!live || live.type !== 'door') return false
// Block commit if the door overlaps any other wall child at its
// current position. The 3D port has the same guard.
const overlapping = hasWallChildOverlap(
live.parentId as string,
live.position[0],
live.position[1],
live.width,
live.height,
live.id,
)
return !overlapping
},
}
return session
}
+165 -13
View File
@@ -5,18 +5,31 @@ import type {
GeometryContext, GeometryContext,
WallNode, WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions'
/** /**
* Stage C floor-plan builder for door. Doors render as a small polygon * Stage C floor-plan builder for door. 1:1 visual port of the legacy
* sitting in the wall's cutout — width = door.width along the wall * floorplan-panel door rendering:
* direction, depth = wall.thickness perpendicular. *
* 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 * 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 * mounted on). Returns null when the parent isn't a wall (orphaned
* doors during placement etc.). * doors during placement etc.).
* *
* Inlined from the legacy `getOpeningFootprint` helper in * Skipped vs the full legacy for now: hinge / strike cubes (small
* floorplan-panel.tsx. Window's builder is structurally identical. * 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 { export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null {
const wall = ctx.parent as WallNode | 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 dirX = dx / length
const dirZ = dz / length const dirZ = dz / length
// Perpendicular unit normal (rotate 90° CCW).
const perpX = -dirZ const perpX = -dirZ
const perpZ = dirX 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 width = node.width
const depth = wall.thickness ?? 0.1 const depth = wall.thickness ?? 0.1
const cx = x1 + dirX * distance const cx = x1 + dirX * distance
@@ -42,6 +56,18 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
const halfWidth = width / 2 const halfWidth = width / 2
const halfDepth = depth / 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[] = [ const points: readonly FloorplanPoint[] = [
[cx - dirX * halfWidth + perpX * halfDepth, cz - dirZ * halfWidth + perpZ * halfDepth], [cx - dirX * halfWidth + perpX * halfDepth, cz - dirZ * halfWidth + perpZ * halfDepth],
[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], [cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth],
] ]
return { const view = ctx.viewState
kind: 'polygon', const palette = view?.palette
points, const isSelected = view?.selected ?? false
fill: '#f8fafc', const isHighlighted = view?.highlighted ?? false
stroke: '#374151', const showSelectedChrome = isSelected || isHighlighted
strokeWidth: 0.015,
opacity: 0.95, // 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
} }
@@ -9,20 +9,20 @@ import {
useScene, useScene,
type WallEvent, type WallEvent,
} from '@pascal-app/core' } 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 { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
const edgeMaterial = new LineBasicNodeMaterial({ const edgeMaterial = new LineBasicNodeMaterial({
@@ -32,7 +32,7 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false, depthWrite: false,
}) })
export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
const cursorGroupRef = useRef<Group>(null!) const cursorGroupRef = useRef<Group>(null!)
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
@@ -310,7 +310,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place') triggerSFX('sfx:item-place')
hideCursor() hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] }) useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode() exitMoveMode()
@@ -410,3 +410,5 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
</group> </group>
) )
} }
export default MoveDoorTool
@@ -8,21 +8,23 @@ import {
useInteractive, useInteractive,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
cn,
PanelSection,
PanelWrapper,
PresetsPopover,
SegmentedControl,
SliderControl,
ToggleControl,
triggerSFX,
useEditor,
usePresetsAdapter,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react' import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover'
const doorTypeOptions = [ const doorTypeOptions = [
{ label: 'Hinged', value: 'hinged', available: true }, { label: 'Hinged', value: 'hinged', available: true },
@@ -106,7 +108,7 @@ function isSameDoorValue(current: unknown, next: unknown): boolean {
return Object.is(current, next) return Object.is(current, next)
} }
export function DoorPanel() { export default function DoorPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
@@ -189,7 +191,9 @@ export function DoorPanel() {
} }
previewRef.current = null previewRef.current = null
useScene.getState().updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<DoorNode>) useScene
.getState()
.updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<DoorNode>)
scene.dirtyNodes.add(selectedId as AnyNodeId) scene.dirtyNodes.add(selectedId as AnyNodeId)
}, },
[selectedId], [selectedId],
@@ -209,14 +213,14 @@ export function DoorPanel() {
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') triggerSFX('sfx:item-pick')
setMovingNode(node) setMovingNode(node)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection]) }, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
if (!(selectedId && node)) return if (!(selectedId && node)) return
sfxEmitter.emit('sfx:item-delete') triggerSFX('sfx:item-delete')
deleteNode(selectedId as AnyNode['id']) deleteNode(selectedId as AnyNode['id'])
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
@@ -224,7 +228,7 @@ export function DoorPanel() {
const handleDuplicate = useCallback(() => { const handleDuplicate = useCallback(() => {
if (!node?.parentId) return if (!node?.parentId) return
sfxEmitter.emit('sfx:item-pick') triggerSFX('sfx:item-pick')
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
const cloned = structuredClone(node) as any const cloned = structuredClone(node) as any
delete cloned.id delete cloned.id
@@ -985,75 +989,75 @@ export function DoorPanel() {
/> />
</PanelSection> </PanelSection>
{!isGarageDoor && ( {!isGarageDoor && (
<PanelSection title="Content Padding"> <PanelSection title="Content Padding">
<SliderControl <SliderControl
label="Horizontal" label="Horizontal"
max={0.2} max={0.2}
min={0} min={0}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
precision={3} precision={3}
step={0.005} step={0.005}
unit="m" unit="m"
value={Math.round(node.contentPadding[0] * 1000) / 1000} value={Math.round(node.contentPadding[0] * 1000) / 1000}
/>
<SliderControl
label="Vertical"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
/>
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
{supportsHingeSide && (
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Hinges Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.hingesSide}
/>
</div>
)}
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Direction
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
value={node.swingDirection}
/> />
</div> <SliderControl
</div> label="Vertical"
</PanelSection> max={0.2}
)} min={0}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
/>
</PanelSection>
)}
{isSwingDoor && ( {isSwingDoor && (
<PanelSection title="Threshold"> <PanelSection title="Swing">
<ToggleControl <div className="flex flex-col gap-2 px-1 pb-1">
checked={node.threshold} {supportsHingeSide && (
label="Enable Threshold" <div className="space-y-1">
onChange={(checked) => handleUpdate({ threshold: checked })} <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
/> Hinges Side
{node.threshold && ( </span>
<div className="mt-1 flex flex-col gap-1"> <SegmentedControl
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.hingesSide}
/>
</div>
)}
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Direction
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
value={node.swingDirection}
/>
</div>
</div>
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Threshold">
<ToggleControl
checked={node.threshold}
label="Enable Threshold"
onChange={(checked) => handleUpdate({ threshold: checked })}
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl <SliderControl
label="Height" label="Height"
max={0.1} max={0.1}
+7 -9
View File
@@ -2,15 +2,12 @@ import type { ParametricDescriptor } from '@pascal-app/core'
import type { DoorNode } from './schema' import type { DoorNode } from './schema'
/** /**
* Minimal inspector descriptor for door. The legacy `<DoorPanel>` has * Stage E inspector for door. Mounts the kind-owned panel
* 29 SliderControls covering segments, hardware, hinges, panic bar, * (`panel.tsx`) via `customPanel` — door has 29+ controls (segments,
* opening shape, etc. — too elaborate for the auto-inspector at Stage A. * hardware, hinges, panic bar, opening shape, etc.) that can't fit
* Legacy panel keeps rendering via the hardcoded `case 'door':` in * into the generic auto-inspector. The `groups` entries stay populated
* panel-manager.tsx. This descriptor only exposes the simple dimension * so the registry still considers door "parametric" (for tooling that
* fields so the registry knows door has parametric data. Phase 5 Stage E * lists kinds with editable schema).
* (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.
*/ */
export const doorParametrics: ParametricDescriptor<DoorNode> = { export const doorParametrics: ParametricDescriptor<DoorNode> = {
groups: [ groups: [
@@ -29,4 +26,5 @@ export const doorParametrics: ParametricDescriptor<DoorNode> = {
], ],
}, },
], ],
customPanel: () => import('./panel'),
} }
+32 -7
View File
@@ -1,11 +1,36 @@
'use client' '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 export default DoorRenderer
+4 -9
View File
@@ -3,7 +3,7 @@
import { DoorAnimationSystem, DoorSystem } from '@pascal-app/viewer' 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 * - **`DoorSystem`** — rebuilds frame / leaf / glass / hardware
* geometry from `dirtyNodes`. Cascades dirty to the parent wall so * 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 * folding) at frame priority 2, then marks the door dirty so the
* geometry system rebuilds at priority 3. * geometry system rebuilds at priority 3.
* *
* Both are wrapped in `<LegacySystem kind="door">` at the legacy mount * Future: extract the geometry into a pure `buildDoorGeometry(node, ctx)`
* point; with door registered, those wrappers short-circuit and this * and migrate to `def.geometry`. The animation system stays as
* bundle takes over. * `def.system` (it's a real per-frame concern, not a geometry build).
*
* 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).
*/ */
const DoorSystems = () => { const DoorSystems = () => {
return ( return (
@@ -8,19 +8,19 @@ import {
useScene, useScene,
type WallEvent, type WallEvent,
} from '@pascal-app/core' } from '@pascal-app/core'
import {
calculateCursorRotation,
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
triggerSFX,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
const edgeMaterial = new LineBasicNodeMaterial({ const edgeMaterial = new LineBasicNodeMaterial({
@@ -34,7 +34,7 @@ const edgeMaterial = new LineBasicNodeMaterial({
* Door tool places DoorNodes on walls only. * Door tool places DoorNodes on walls only.
* Doors always sit at floor level (clampedY = height/2). * Doors always sit at floor level (clampedY = height/2).
*/ */
export const DoorTool: React.FC = () => { const DoorTool: React.FC = () => {
const draftRef = useRef<DoorNode | null>(null) const draftRef = useRef<DoorNode | null>(null)
const cursorGroupRef = useRef<Group>(null!) const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!) const edgesRef = useRef<LineSegments>(null!)
@@ -273,7 +273,7 @@ export const DoorTool: React.FC = () => {
useScene.getState().createNode(node, event.node.id as AnyNodeId) useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place') triggerSFX('sfx:item-place')
event.stopPropagation() event.stopPropagation()
} }
@@ -322,3 +322,5 @@ export const DoorTool: React.FC = () => {
</group> </group>
) )
} }
export default DoorTool
+54
View File
@@ -0,0 +1,54 @@
import { ElevatorNode as ElevatorNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { buildElevatorFloorplan } from './floorplan'
import { elevatorParametrics } from './parametrics'
import { ElevatorNode } from './schema'
/**
* Elevator — Stage A registration. Wrap-exports the legacy renderer +
* the three legacy systems (runtime / interaction / opening) bundled
* as one `def.system`. Move / inspector still go through legacy
* (`MoveElevatorTool`, `<ElevatorPanel>`) via panel-manager's
* hardcoded switch.
*/
export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
kind: 'elevator',
schemaVersion: 1,
schema: ElevatorNode,
category: 'structure',
defaults: () => {
const stub = ElevatorNodeSchema.parse({ id: 'elevator_default' as never, type: 'elevator' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: elevatorParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 3,
},
floorplan: buildElevatorFloorplan,
presentation: {
label: 'Elevator',
description: 'A multi-level elevator shaft with configurable openings per level.',
icon: { kind: 'url', src: '/icons/wallcut.png' },
paletteSection: 'structure',
paletteOrder: 80,
},
mcp: {
description: 'A multi-level elevator with shaft + openings per level.',
},
}
+313
View File
@@ -0,0 +1,313 @@
import {
type AnyNodeId,
type ElevatorNode,
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
resolveElevatorServiceLevelIds,
useInteractive,
useLiveNodeOverrides,
} from '@pascal-app/core'
/**
* Stage C floor-plan emitter for elevator. Renders:
*
* - **Outer shaft footprint** — rotated rectangle (cab + wall thickness).
* - **Cab indicator** — inner rectangle showing the cab's position within
* the shaft. Highlighted when `runtime.currentLevelId` matches the
* active level (i.e. the car is *on this floor*).
* - **Door opening indicator** — a short marker on the front face
* spanning `doorWidth` so users can see which way the doors open.
* - **Selection / target / queued chrome** — selection stroke when
* the elevator is selected, accent stroke when the runtime targets
* this level (cab is travelling here) or this level is queued.
*
* Reads the elevator's live state via `useLiveNodeOverrides.getState()`
* (inspector edits) and `useInteractive.getState().elevators[id]`
* (runtime cab travel). Those reads are non-reactive on their own —
* `FloorplanRegistryLayer` subscribes to both stores so the layer
* re-renders when they change, propagating into this builder.
*
* Per-level served-level chips (the small floor-label badges on each
* shaft side) are not emitted yet — they need an HTML-overlay primitive
* in `FloorplanGeometry` to render properly (SVG `<text>` rotates with
* the plan, which mangles label legibility). Tracked as follow-up; the
* legacy `<FloorplanElevatorLayer>` still renders the chips for
* pre-registry builds while we figure out the right primitive shape.
*/
const STAGE_LEVEL_FILTER_HIDE = true
export function buildElevatorFloorplan(
node: ElevatorNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
// Merge in any live overrides (inspector edits not yet committed).
const overrides = useLiveNodeOverrides.getState().get(node.id)
const display: ElevatorNode = overrides ? ({ ...node, ...overrides } as ElevatorNode) : node
// Service-level gate. If the active level isn't one the elevator
// serves, render nothing — legacy behaviour. The level id comes via
// `ctx.parent` (the elevator's parent in the tree is the level it's
// hosted on, which is the active level when the registry layer walks
// from `levelId`).
const parentLevelId = ctx.parent?.id
if (STAGE_LEVEL_FILTER_HIDE && parentLevelId) {
const sceneNodes = collectAllNodes(ctx)
const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes)
if (!serviceLevelIds.includes(parentLevelId as AnyNodeId)) {
return null
}
}
const wallThickness = Math.max(display.shaftWallThickness ?? 0.09, 0.04)
const cabWidth = Math.max(display.width, 0.8)
const cabDepth = Math.max(display.depth, 0.8)
const shaftWidth = Math.max(display.shaftWidth ?? display.width, cabWidth, 0.8)
const shaftDepth = Math.max(display.shaftDepth ?? display.depth, cabDepth, 0.8)
const doorWidth = Math.min(Math.max(display.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18)
const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness)
const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness)
const center = { x: display.position[0], y: display.position[2] }
const cos = Math.cos(display.rotation)
const sin = Math.sin(display.rotation)
const rotate = (lx: number, ly: number): [number, number] => {
// Same clockwise convention as `rotatePlanVector` in editor — see
// `wiki/architecture/tools.md` for why every plan-space rotation
// uses this matrix and not the standard counter-clockwise one.
return [lx * cos + ly * sin, -lx * sin + ly * cos]
}
// Outer shaft footprint corners.
const outerCorners: Array<readonly [number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
]
const outerPoints: FloorplanPoint[] = outerCorners.map(([lx, ly]) => {
const [rx, ry] = rotate(lx, ly)
return [center.x + rx, center.y + ry]
})
// Cab inner rectangle. The cab sits flush against the front face
// (-Z in local coords) so its center is `-shaftDepth/2 + cabDepth/2`
// away from shaft center.
const cabCenterLocalY = -shaftDepth / 2 + cabDepth / 2
const cabHalfW = cabWidth / 2
const cabHalfD = cabDepth / 2
const cabCorners: Array<readonly [number, number]> = [
[-cabHalfW, cabCenterLocalY - cabHalfD],
[cabHalfW, cabCenterLocalY - cabHalfD],
[cabHalfW, cabCenterLocalY + cabHalfD],
[-cabHalfW, cabCenterLocalY + cabHalfD],
]
const cabPoints: FloorplanPoint[] = cabCorners.map(([lx, ly]) => {
const [rx, ry] = rotate(lx, ly)
return [center.x + rx, center.y + ry]
})
// Runtime state — current level / target level / queued.
const runtime = useInteractive.getState().elevators[node.id]
const isCarOnLevel = parentLevelId ? runtime?.currentLevelId === parentLevelId : false
const isTargetLevel = parentLevelId ? runtime?.targetLevelId === parentLevelId : false
const isQueuedLevel = parentLevelId
? (runtime?.queue.includes(parentLevelId as never) ?? false)
: false
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
// Stroke selection — selected wins, then runtime target / queued
// states get the accent palette colour so users can spot "the cab is
// coming here" at a glance.
const stroke =
showSelectedChrome && palette
? palette.selectedStroke
: isTargetLevel || isQueuedLevel
? '#0ea5e9'
: '#475569'
// Shaft fill — orange when selected, light slate otherwise. When the
// car is *on this level*, the cab indicator inside gets the highlight
// instead of the whole shaft (more legible).
const shaftFill = showSelectedChrome ? '#fed7aa' : '#cbd5e1'
const cabFill = isCarOnLevel ? '#22c55e' : showSelectedChrome ? '#fef3c7' : '#e2e8f0'
const cabStroke = isCarOnLevel ? '#15803d' : '#475569'
const children: FloorplanGeometry[] = []
// Outer shaft.
children.push({
kind: 'polygon',
points: outerPoints,
fill: shaftFill,
stroke,
strokeWidth: showSelectedChrome ? 0.04 : 0.03,
strokeLinejoin: 'round',
opacity: 0.85,
})
// Cab inner rectangle.
children.push({
kind: 'polygon',
points: cabPoints,
fill: cabFill,
fillOpacity: isCarOnLevel ? 0.85 : 0.55,
stroke: cabStroke,
strokeWidth: 0.018,
strokeLinejoin: 'round',
opacity: 0.92,
})
// Door opening indicator — a short line on the front edge centered
// on the cab. The legacy renders a more complex slide / center-open
// hint; this is the minimum useful signal.
const doorY = -halfDepth
const [doorStartX, doorStartY] = rotate(-doorWidth / 2, doorY)
const [doorEndX, doorEndY] = rotate(doorWidth / 2, doorY)
children.push({
kind: 'line',
x1: center.x + doorStartX,
y1: center.y + doorStartY,
x2: center.x + doorEndX,
y2: center.y + doorEndY,
stroke: isCarOnLevel ? '#15803d' : '#0f172a',
strokeWidth: 0.05,
strokeLinecap: 'round',
opacity: 0.92,
})
// Served-level chips — vertical column of marker circles + level
// numbers to the right of the shaft, only when selected and the
// elevator serves more than one level. Mirrors the legacy
// `<FloorplanElevatorLayer>` chip rendering (~line 6423 in
// floorplan-panel.tsx).
if (isSelected && parentLevelId) {
const sceneNodes = collectAllNodes(ctx)
const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes)
if (serviceLevelIds.length > 1) {
const disabledLevelIds = new Set(display.disabledLevelIds ?? [])
const serviceOnlyLevelIds = new Set(display.serviceOnlyLevelIds ?? [])
const rangeStep = 0.18
const rangeHeight = Math.max(0, (serviceLevelIds.length - 1) * rangeStep)
const [rangeOffsetX, rangeOffsetY] = rotate(halfWidth + 0.38, 0)
const rangeX = center.x + rangeOffsetX
const rangeBottomY = center.y + rangeOffsetY + rangeHeight / 2
const rangeTopY = center.y + rangeOffsetY - rangeHeight / 2
// Connector spine — single vertical line tying the chips to the
// shaft. Sky blue, semi-transparent.
children.push({
kind: 'line',
x1: rangeX,
y1: rangeTopY,
x2: rangeX,
y2: rangeBottomY,
stroke: '#0ea5e9',
strokeOpacity: 0.52,
strokeWidth: 0.018,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
// One chip per served level. Lowest level at the bottom of the
// column, index increases upward — matches legacy ordering.
serviceLevelIds.forEach((levelId, index) => {
const isCurrent = runtime?.currentLevelId === levelId
const isTarget = runtime?.targetLevelId === levelId
// `resolveElevatorServiceLevelIds` returns plain `string[]`, but
// the runtime queue is `AnyNodeId[]` (branded). The values agree
// at runtime — narrowing through `as never` keeps the includes
// call type-safe without dragging the brand into the helper's
// public return type.
const isQueued = runtime?.queue.includes(levelId as never) ?? false
const isDisabled = disabledLevelIds.has(levelId)
const isServiceOnly = serviceOnlyLevelIds.has(levelId)
const isUnavailable = isDisabled || isServiceOnly
const markerFill = isCurrent
? '#22c55e'
: isTarget || isQueued
? '#38bdf8'
: isUnavailable
? '#94a3b8'
: '#ffffff'
const markerStroke = isUnavailable ? '#64748b' : '#0369a1'
const labelColor = isUnavailable ? '#64748b' : '#075985'
const y = rangeBottomY - index * rangeStep
children.push({
kind: 'circle',
cx: rangeX,
cy: y,
r: 0.055,
fill: markerFill,
fillOpacity: isUnavailable ? 0.72 : 0.95,
stroke: markerStroke,
strokeWidth: 0.012,
})
children.push({
kind: 'text',
x: rangeX + 0.11,
y,
text: String(index + 1),
fontSize: 0.13,
fontWeight: 700,
fill: labelColor,
textAnchor: 'start',
dominantBaseline: 'middle',
})
})
}
}
if (isSelected) {
children.push({
kind: 'move-handle',
point: [display.position[0], display.position[2]],
})
}
return { kind: 'group', children }
}
/**
* `ctx` exposes `resolve` and `children` / `siblings` / `parent`, but
* not the full nodes map. `resolveElevatorServiceLevelIds` wants a
* `Record<id, AnyNode>`; we rebuild it by walking the chain we DO have
* access to. For the elevator's service-level check we only need the
* elevator's parent (the level), its building, and any level siblings.
* This is the minimum graph the resolver needs.
*
* If a future use needs the full nodes map for a builder, we'd surface
* it through ctx — but doing so leaks the whole scene store into every
* `def.floorplan` call. Narrow opt-in is the better default.
*/
function collectAllNodes(ctx: GeometryContext): Record<string, never> {
// We need the building → levels graph for service-level resolution.
// Walk up from the elevator: parent (level) → its parent (building) →
// building.children (all levels). That's enough for the resolver.
const out: Record<string, unknown> = {}
const level = ctx.parent
if (level) {
out[level.id] = level
const building = (level as { parentId?: string }).parentId
? ctx.resolve((level as { parentId: string }).parentId as never)
: undefined
if (building) {
out[building.id] = building
const childIds = (building as unknown as { children?: string[] }).children
if (Array.isArray(childIds)) {
for (const cid of childIds) {
const child = ctx.resolve(cid as never)
if (child) out[child.id] = child
}
}
}
}
return out as Record<string, never>
}
+1
View File
@@ -0,0 +1 @@
export { elevatorDefinition } from './definition'
@@ -12,18 +12,22 @@ import {
useLiveTransforms, useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } 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 { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Send, Trash2 } from 'lucide-react' import { Copy, Move, Send, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { resolveElevatorNodeSupportY, resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
function findLevelId(levels: LevelNode[], levelId: string | null | undefined) { function findLevelId(levels: LevelNode[], levelId: string | null | undefined) {
if (!levelId) return null if (!levelId) return null
@@ -153,7 +157,7 @@ function degreesToRadians(degrees: number) {
return (degrees * Math.PI) / 180 return (degrees * Math.PI) / 180
} }
export function ElevatorPanel() { export default function ElevatorPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length) const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
@@ -302,7 +306,7 @@ export function ElevatorPanel() {
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') triggerSFX('sfx:item-pick')
clearLivePreview() clearLivePreview()
setMovingNode(node) setMovingNode(node)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
@@ -310,7 +314,7 @@ export function ElevatorPanel() {
const handleDuplicate = useCallback(() => { const handleDuplicate = useCallback(() => {
if (!(node && node.parentId)) return if (!(node && node.parentId)) return
sfxEmitter.emit('sfx:item-pick') triggerSFX('sfx:item-pick')
const duplicate = ElevatorNodeSchema.parse({ const duplicate = ElevatorNodeSchema.parse({
...structuredClone(node), ...structuredClone(node),
@@ -328,7 +332,7 @@ export function ElevatorPanel() {
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
if (!(selectedId && node)) return if (!(selectedId && node)) return
sfxEmitter.emit('sfx:structure-delete') triggerSFX('sfx:structure-delete')
clearLivePreview() clearLivePreview()
useScene.getState().deleteNode(selectedId as AnyNodeId) useScene.getState().deleteNode(selectedId as AnyNodeId)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
@@ -442,7 +446,11 @@ export function ElevatorPanel() {
) )
const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id)) const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id))
const defaultLevelOptions = const defaultLevelOptions =
enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels enabledServedLevels.length > 0
? enabledServedLevels
: servedLevels.length > 0
? servedLevels
: levels
const selectedDefaultLevelId = defaultLevelOptions.some( const selectedDefaultLevelId = defaultLevelOptions.some(
(level) => level.id === node.defaultLevelId, (level) => level.id === node.defaultLevelId,
) )
@@ -570,14 +578,14 @@ export function ElevatorPanel() {
<ActionButton <ActionButton
label="-45°" label="-45°"
onClick={() => { onClick={() => {
sfxEmitter.emit('sfx:item-rotate') triggerSFX('sfx:item-rotate')
commitTransform(displayPosition, displayRotation - Math.PI / 4) commitTransform(displayPosition, displayRotation - Math.PI / 4)
}} }}
/> />
<ActionButton <ActionButton
label="+45°" label="+45°"
onClick={() => { onClick={() => {
sfxEmitter.emit('sfx:item-rotate') triggerSFX('sfx:item-rotate')
commitTransform(displayPosition, displayRotation + Math.PI / 4) commitTransform(displayPosition, displayRotation + Math.PI / 4)
}} }}
/> />
@@ -753,9 +761,7 @@ export function ElevatorPanel() {
</div> </div>
<select <select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground" className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
onChange={(event) => onChange={(event) => handleServiceBoundaryChange('fromLevelId', event.target.value)}
handleServiceBoundaryChange('fromLevelId', event.target.value)
}
value={fromLevelId} value={fromLevelId}
> >
{levels.map((level) => ( {levels.map((level) => (
@@ -0,0 +1,6 @@
import type { ElevatorNode, ParametricDescriptor } from '@pascal-app/core'
export const elevatorParametrics: ParametricDescriptor<ElevatorNode> = {
groups: [],
customPanel: () => import('./panel'),
}
@@ -1,3 +1,5 @@
'use client'
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
@@ -21,6 +23,7 @@ import {
useRegistry, useRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useNodeEvents } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { import {
@@ -33,7 +36,6 @@ import {
TorusGeometry, TorusGeometry,
} from 'three' } from 'three'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useNodeEvents } from '../../../hooks/use-node-events'
const SHAFT_WALL_COLOR = '#d7dce4' const SHAFT_WALL_COLOR = '#d7dce4'
const SHAFT_SIDE_COLOR = '#4b5563' const SHAFT_SIDE_COLOR = '#4b5563'
@@ -1308,3 +1310,5 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
</group> </group>
) )
} }
export default ElevatorRenderer
+1
View File
@@ -0,0 +1 @@
export { ElevatorNode } from '@pascal-app/core'
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core'
import { ElevatorInteractionSystem } from '@pascal-app/viewer'
/**
* Composite system for elevator — bundles three per-frame systems:
* `ElevatorRuntimeSystem` (cab travel + door state machine),
* `ElevatorInteractionSystem` (call buttons / cab UI), and
* `ElevatorOpeningSystem` (wall + slab cutout cascade).
*/
export default function ElevatorSystem() {
return (
<>
<ElevatorRuntimeSystem />
<ElevatorInteractionSystem />
<ElevatorOpeningSystem />
</>
)
}
+8
View File
@@ -1,5 +1,6 @@
import type { NodeDefinition } from '@pascal-app/core' import type { NodeDefinition } from '@pascal-app/core'
import { buildFenceFloorplan } from './floorplan' import { buildFenceFloorplan } from './floorplan'
import { fenceMoveEndpointAffordance } from './floorplan-affordances'
import { buildFenceGeometry } from './geometry' import { buildFenceGeometry } from './geometry'
import { fenceParametrics } from './parametrics' import { fenceParametrics } from './parametrics'
import { FenceNode } from './schema' import { FenceNode } from './schema'
@@ -74,6 +75,13 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
// Legacy `floorplanFenceEntries` short-circuits to [] when fence is // Legacy `floorplanFenceEntries` short-circuits to [] when fence is
// registered (see floorplan-panel.tsx). // registered (see floorplan-panel.tsx).
floorplan: buildFenceFloorplan, 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. // Stage D — all four fence drag-affordances live in this folder.
// curve / move-endpoint / move are 1:1 ports of the legacy tools // curve / move-endpoint / move are 1:1 ports of the legacy tools
// (same snap pipeline, same history dance, same cursor render), // (same snap pipeline, same history dance, same cursor render),
@@ -0,0 +1,132 @@
import {
type AnyNode,
type AnyNodeId,
type FenceNode,
type FloorplanAffordance,
type FloorplanAffordanceSession,
useScene,
type WallNode,
} from '@pascal-app/core'
import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pascal-app/editor'
/**
* Floor-plan 2D drag affordances for fence — sister to the 3D
* `actions/move-endpoint.ts` `DragAction`. Same legacy interaction
* (endpoint snap pipeline + linked-fence cascade via `endpoint-match`
* with an epsilon, ALT-detach), driven from SVG pointer events instead
* of R3F grid events.
*
* Why not share the `DragAction`? The 3D code goes through
* `createDragSession` which assumes a `SceneApi`-style helper bag
* (snapshot, restoreAll, pauseHistory, resumeHistory). The 2D registry
* layer owns those semantics directly via the dispatcher's snapshot +
* pause/resume dance, so the affordance only needs the pure mutation
* logic. The shape is intentionally close to the legacy fence drag —
* 1:1 behaviorally.
*/
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
type FenceEndpointPayload = { fenceId: AnyNodeId; endpoint: 'start' | 'end' }
function pointsNearlyEqual(a: FencePlanPoint, b: FencePlanPoint): boolean {
return (
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
)
}
function collectLevel(
nodes: Record<AnyNodeId, AnyNode>,
parentId: string | null,
): { walls: WallNode[]; fences: FenceNode[] } {
const walls: WallNode[] = []
const fences: FenceNode[] = []
for (const node of Object.values(nodes)) {
if (!node) continue
if ((node.parentId ?? null) !== parentId) continue
if (node.type === 'wall') walls.push(node as WallNode)
else if (node.type === 'fence') fences.push(node as FenceNode)
}
return { walls, fences }
}
function collectLinkedFences(
fences: FenceNode[],
draggedFenceId: AnyNodeId,
linkedPoint: FencePlanPoint,
): Array<{ id: AnyNodeId; start: FencePlanPoint; end: FencePlanPoint }> {
const out: Array<{ id: AnyNodeId; start: FencePlanPoint; end: FencePlanPoint }> = []
for (const fence of fences) {
if (fence.id === draggedFenceId) continue
if (!pointsNearlyEqual(fence.start, linkedPoint) && !pointsNearlyEqual(fence.end, linkedPoint))
continue
out.push({
id: fence.id,
start: [fence.start[0], fence.start[1]],
end: [fence.end[0], fence.end[1]],
})
}
return out
}
export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
start({ node, payload, nodes }): FloorplanAffordanceSession {
const { endpoint } = payload as FenceEndpointPayload
const originalStart: FencePlanPoint = [node.start[0], node.start[1]]
const originalEnd: FencePlanPoint = [node.end[0], node.end[1]]
const originalMovingPoint = endpoint === 'start' ? originalStart : originalEnd
const fixedPoint: FencePlanPoint = endpoint === 'start' ? originalEnd : originalStart
const parentId = node.parentId ?? null
const { walls, fences } = collectLevel(nodes, parentId)
const linkedOriginals = collectLinkedFences(fences, node.id, originalMovingPoint)
const affectedIds: AnyNodeId[] = [node.id, ...linkedOriginals.map((l) => l.id)]
return {
affectedIds,
apply({ planPoint, modifiers }) {
// Re-collect siblings each tick: the user might be dragging a
// fence whose sibling positions changed (the dragged fence
// itself is excluded via `ignoreFenceIds`).
const sceneNodes = useScene.getState().nodes
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId)
const snapped = snapFenceDraftPoint({
point: planPoint as FencePlanPoint,
walls: nextWalls,
fences: nextFences,
start: fixedPoint,
angleSnap: !modifiers.shiftKey,
ignoreFenceIds: [node.id],
})
const nextStart = endpoint === 'start' ? snapped : fixedPoint
const nextEnd = endpoint === 'end' ? snapped : fixedPoint
const linkedUpdates = modifiers.altKey
? []
: linkedOriginals.map((l) => ({
id: l.id,
start: pointsNearlyEqual(l.start, originalMovingPoint) ? snapped : l.start,
end: pointsNearlyEqual(l.end, originalMovingPoint) ? snapped : l.end,
}))
useScene.getState().updateNodes([
{ id: node.id, data: { start: nextStart, end: nextEnd } },
...linkedUpdates.map((u) => ({
id: u.id,
data: { start: u.start, end: u.end },
})),
])
},
canCommit() {
const finalFence = useScene.getState().nodes[node.id] as FenceNode | undefined
return (
!!finalFence &&
finalFence.type === 'fence' &&
isWallLongEnough(finalFence.start, finalFence.end)
)
},
}
},
}
+366 -22
View File
@@ -1,32 +1,376 @@
import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core' import {
import { isCurvedWall, sampleWallCenterline } from '@pascal-app/core' type FloorplanGeometry,
type GeometryContext,
getWallCurveFrameAt,
getWallCurveLength,
isCurvedWall,
sampleWallCenterline,
} from '@pascal-app/core'
import type { FenceNode } from './schema' import type { FenceNode } from './schema'
/** /**
* Stage C floor-plan builder for fence. Draws the fence centerline as * Stage C floor-plan builder for fence. 1:1 visual port of the legacy
* a polyline; thickness becomes the stroke width. * `FloorplanFenceLayer` from `floorplan-panel.tsx`:
* *
* Curved fences sample the centerline at 24 segments — same density the * 1. Three stacked stroke paths along the centerline, all with
* legacy `floorplanFenceEntries` useMemo uses, so straight + curved * `vectorEffect: 'non-scaling-stroke'` so widths stay constant on
* fences look comparable to the legacy rendering. * 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 * `getFloorplanFenceMarkerTs` is inlined here — it was a private helper
* direction, post markers along the centerline) are deferred — Phase 5 * in the legacy panel and is fence-specific, so it lives with the kind.
* Stage D will revisit if real visual parity is needed.
*/ */
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 { return {
kind: 'polyline', kind: 'group',
points, transform: { translate: [point.x, point.y], rotate: angleRadians },
stroke: node.color || '#475569', children: [
strokeWidth: Math.max(node.thickness, 0.05), // White underlay so the X shows against any background.
opacity: 0.9, {
kind: 'line',
x1: -half,
y1: -half,
x2: half,
y2: half,
stroke: surfaceColor,
strokeWidth: 2.8,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'line',
x1: half,
y1: -half,
x2: -half,
y2: half,
stroke: surfaceColor,
strokeWidth: 2.8,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
// Accent X on top.
{
kind: 'line',
x1: -half,
y1: -half,
x2: half,
y2: half,
stroke: accentColor,
strokeWidth: markerStrokeWidth,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'line',
x1: half,
y1: -half,
x2: -half,
y2: half,
stroke: accentColor,
strokeWidth: markerStrokeWidth,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
],
} }
} }
export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): FloorplanGeometry {
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const isHovered = view?.hovered ?? false
const isActive = isSelected || isHighlighted
const showInteractiveChrome = isActive || isHovered
// Centerline path — sampled for curved fences so the underlay /
// accent / glow all trace the same shape.
const centerlinePoints = isCurvedWall(node)
? sampleWallCenterline(node, 24)
: [
{ x: node.start[0], y: node.start[1] },
{ x: node.end[0], y: node.end[1] },
]
const pathD = buildCenterlinePathD(centerlinePoints)
// Stroke shifts: selected wins; hover (not selected) → `wallHoverStroke`
// (light blue from the legacy palette, same as walls); otherwise dark
// accent. Mirrors the `fenceStroke` ternary in the legacy panel.
const accentStroke =
isActive && palette
? palette.selectedStroke
: isHovered && palette
? palette.wallHoverStroke
: '#111827'
const glowStroke =
isActive && palette
? palette.selectedStroke
: isHovered && palette
? palette.wallHoverStroke
: accentStroke
const underlayStroke = 'rgba(255, 255, 255, 0.98)'
// Surface (white) for marker plates — themed dark surface would look
// wrong on the white underlay, so we hardcode white here.
const markerSurface = '#ffffff'
// Widths step up on hover (between idle and active) — same pattern the
// legacy panel uses for `fenceUnderlayWidth` / `fenceStrokeWidth`.
const underlayWidth = isActive ? 6.5 : isHovered ? 6 : 5.2
const accentWidth = isActive ? 2.6 : isHovered ? 2.35 : 2.05
// Glow only appears when active or hovered. Opacity gradient matches
// the legacy (0.22 active / 0.14 hover / 0 idle).
const glowOpacity = isActive ? 0.22 : isHovered ? 0.14 : 0
// Marker frames. Filter to first+last when infill is off so the user
// still sees end posts (matches legacy).
const markerTs = getFloorplanFenceMarkerTs(node)
const markerFrames = markerTs.map((t) => {
const frame = getWallCurveFrameAt(node, t)
return {
point: frame.point,
angle: Math.atan2(frame.tangent.y, frame.tangent.x),
}
})
const visibleMarkers =
(node.showInfill ?? true)
? markerFrames
: markerFrames.filter((_, i) => i === 0 || i === markerFrames.length - 1)
const children: FloorplanGeometry[] = []
// 1. Glow (only when active / highlighted / hovered). Wide,
// low-opacity ring. Width steps with the interaction level so hover
// is subtler than active.
if (glowOpacity > 0) {
children.push({
kind: 'path',
d: pathD,
fill: 'none',
stroke: glowStroke,
strokeWidth: isActive ? 9.5 : isHovered ? 8.8 : 8.2,
strokeOpacity: glowOpacity,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
}
// 2. White underlay — visible fence body base layer.
children.push({
kind: 'path',
d: pathD,
fill: 'none',
stroke: underlayStroke,
strokeOpacity: 0.98,
strokeWidth: underlayWidth,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
// 3. Dark accent on top.
children.push({
kind: 'path',
d: pathD,
fill: 'none',
stroke: accentStroke,
strokeWidth: accentWidth,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
// 4. Style-aware markers. Pass `showInteractiveChrome` so hover also
// bumps marker stroke widths slightly (legacy panel does the same).
for (const marker of visibleMarkers) {
children.push(
buildMarker(
node,
marker.point,
marker.angle,
accentStroke,
markerSurface,
showInteractiveChrome,
),
)
}
// 5. Hit-line for click detection.
children.push({
kind: 'hit-line',
x1: node.start[0],
y1: node.start[1],
x2: node.end[0],
y2: node.end[1],
strokeWidthPx: 18,
cursor: 'pointer',
})
// 6. Endpoint handles + length label when selected.
if (isSelected) {
children.push({
kind: 'endpoint-handle',
point: [node.start[0], node.start[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'start' as const },
})
children.push({
kind: 'endpoint-handle',
point: [node.end[0], node.end[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'end' as const },
})
const length = getWallCurveLength(node)
if (length >= 0.1) {
const midX = (node.start[0] + node.end[0]) / 2
const midZ = (node.start[1] + node.end[1]) / 2
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
children.push({
kind: 'dimension-label',
cx: midX,
cy: midZ,
text: `${Number.parseFloat(length.toFixed(2))}m`,
angle: Math.atan2(dz, dx),
})
}
}
return { kind: 'group', children }
}
+51
View File
@@ -0,0 +1,51 @@
import { GuideNode as GuideNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { guideParametrics } from './parametrics'
import { GuideNode } from './schema'
/**
* Guide — Stage A. Measurement reference annotations placed by the
* user (linear / area / arc). `GuideSystem` handles per-frame
* positioning; the renderer mounts the visual marker + dimensioning
* HUD via `<Html>`.
*/
export const guideDefinition: NodeDefinition<typeof GuideNode> = {
kind: 'guide',
schemaVersion: 1,
schema: GuideNode,
category: 'site',
defaults: () => {
const stub = GuideNodeSchema.parse({ id: 'guide_default' as never, type: 'guide' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: false,
deletable: true,
},
parametrics: guideParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 5,
},
presentation: {
label: 'Guide',
description: 'A measurement / reference annotation (linear, area, or arc).',
icon: { kind: 'url', src: '/icons/blueprint.png' },
paletteSection: 'site',
paletteOrder: 30,
},
mcp: {
description: 'A measurement reference guide annotation.',
},
}
+1
View File
@@ -0,0 +1 @@
export { guideDefinition } from './definition'
+5
View File
@@ -0,0 +1,5 @@
import type { GuideNode, ParametricDescriptor } from '@pascal-app/core'
export const guideParametrics: ParametricDescriptor<GuideNode> = {
groups: [],
}
@@ -1,11 +1,12 @@
'use client'
import { type GuideNode, useRegistry } from '@pascal-app/core' import { type GuideNode, useRegistry } from '@pascal-app/core'
import { useAssetUrl, useViewer } from '@pascal-app/viewer'
import { useLoader } from '@react-three/fiber' import { useLoader } from '@react-three/fiber'
import { Suspense, useMemo, useRef } from 'react' import { Suspense, useMemo, useRef } from 'react'
import { DoubleSide, type Group, type Texture, TextureLoader } from 'three' import { DoubleSide, type Group, type Texture, TextureLoader } from 'three'
import { float, texture } from 'three/tsl' import { float, texture } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useAssetUrl } from '../../../hooks/use-asset-url'
import useViewer from '../../../store/use-viewer'
export const GuideRenderer = ({ node }: { node: GuideNode }) => { export const GuideRenderer = ({ node }: { node: GuideNode }) => {
const showGuides = useViewer((s) => s.showGuides) const showGuides = useViewer((s) => s.showGuides)
@@ -67,3 +68,5 @@ const GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opaci
</mesh> </mesh>
) )
} }
export default GuideRenderer

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