Files
editor/packages/nodes/src/door/definition.ts
T
Wassim SAMADandClaude Opus 4.8 f773e6b8c5 feat(editor): placement & interaction overhaul — FSM spine, bug tracks, perf
Implements plans/editor-placement-interaction-overhaul.md: an authoritative
interaction-scope state machine plus the catalogued placement/interaction
fixes, and split-view floor-plan performance.

- Interaction-scope spine (lib/interaction/* + store/use-interaction-scope),
  driven from central useEditor setters; overlay scoping (zone labels,
  context badges, floating action menu) reads resolveOverlayPolicy.
- Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity,
  auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset,
  per-frame jank.
- Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier
  model (Shift=cycle, Alt=free place, Ctrl=grid step).
- Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle
  rig hides during a whole-node move; rotate gizmo advertises Shift=free
  rotation in the HUD and hides the move cross while rotating.
- Floor-plan perf: pause live reactivity while in 3D-only view; per-node
  geometry cache so only changed nodes rebuild on a drag; hoist wall miters
  to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:04:58 -04:00

270 lines
11 KiB
TypeScript

import type {
AnyNodeId,
DoorNode as DoorNodeType,
HandleDescriptor,
NodeDefinition,
RoofSegmentNode,
WallNode,
} from '@pascal-app/core'
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { scaleHandleHeight } from './door-math'
import { buildDoorFloorplan } from './floorplan'
import { doorWidthAffordance } from './floorplan-affordances'
import { doorFloorplanMoveTarget } from './floorplan-move'
import { doorPaint } from './paint'
import { doorParametrics } from './parametrics'
import { DoorNode } from './schema'
import { doorSlots } from './slots'
const SIDE_HANDLE_OFFSET = 0.24
const HEIGHT_HANDLE_OFFSET = 0.24
const MIN_DOOR_HEIGHT = 0.5
const MIN_DOOR_WIDTH = 0.3
// How far the move cross floats off the wall face (+Z, the door's facing
// normal) so it's grabbable instead of buried in the leaf/frame.
const MOVE_HANDLE_LIFT = 0.12
function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
if (!door.wallId) return Number.POSITIVE_INFINITY
const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined
if (!wall) return Number.POSITIVE_INFINITY
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
}
function readWallHeight(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
if (!door.wallId) return Number.POSITIVE_INFINITY
const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined
return wall?.height ?? Number.POSITIVE_INFINITY
}
// Width arrow on the door-local +X (right) or -X (left) side. Drag grows
// the door from the anchored OPPOSITE edge; the door's wall-local center
// re-centers so the anchored edge stays put.
function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor<DoorNodeType> {
const sign = side === 'right' ? 1 : -1
return {
kind: 'linear-resize',
axis: 'x',
// 'min' = -X edge anchored (right arrow grows the +X edge outward).
// 'max' = +X edge anchored (left arrow grows the -X edge outward).
anchor: side === 'right' ? 'min' : 'max',
min: MIN_DOOR_WIDTH,
max: (n, scene) => {
// Roof-hosted doors clamp against the face profile (the wall-based
// limits read Infinity when wallId is unset).
const roofMax = readRoofFaceWidthMax(n, scene, sign)
if (roofMax !== null) return Math.max(MIN_DOOR_WIDTH, roofMax)
return readWallLength(n, scene)
},
currentValue: (n) => n.width,
onDrag: (node) => publishOpeningResizeGuides(node, false),
apply: (initial, newWidth) => {
// Anchored edge stays fixed in wall-local coords. Door rotation is
// applied by the inner ride group (the renderer mounts a nested
// <group> at the door's pose), so the apply math here is in
// door-local coords AND the patch.position must be in wall-local
// coords. We compute the anchored wall-local point from the
// initial node, then derive the new wall-local center from it.
const rotY = initial.rotation[1]
const armX = Math.cos(rotY)
const armZ = -Math.sin(rotY)
const anchorX = initial.position[0] - sign * (initial.width / 2) * armX
const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ
const newCenterX = anchorX + sign * (newWidth / 2) * armX
const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ
return {
width: newWidth,
position: [newCenterX, initial.position[1], newCenterZ],
}
},
placement: {
// door-local: +X axis lives along door's own X. Inner ride group
// applies door.rotation, so we sit purely on door-local +X / -X.
position: (n) => [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), 0, 0],
rotationY: () => (side === 'right' ? 0 : Math.PI),
},
portal: 'grandparent',
}
}
function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
return {
kind: 'linear-resize',
axis: 'y',
anchor: 'min', // bottom anchored at wall-local Y = position[1] - height/2
min: MIN_DOOR_HEIGHT,
max: (n, scene) => {
const roofMax = readRoofFaceHeightMax(n, scene, 1)
if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax)
const bottom = n.position[1] - n.height / 2
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
},
currentValue: (n) => n.height,
onDrag: (node) => publishOpeningResizeGuides(node, false),
apply: (initial, newHeight) => {
const bottom = initial.position[1] - initial.height / 2
// Scale the handle so it tracks the door instead of staying glued to a
// fixed floor height (shared with the panel's Height slider).
return {
height: newHeight,
position: [initial.position[0], bottom + newHeight / 2, initial.position[2]],
handleHeight: scaleHandleHeight(initial.handleHeight, initial.height, newHeight),
}
},
placement: {
position: (n) => [0, n.height / 2 + HEIGHT_HANDLE_OFFSET, 0],
},
portal: 'grandparent',
}
}
// Press-drag move grip at the door centre, standing in the wall face. Routes
// through the same move tool as the floating Move button (3D
// `affordanceTools.move`, 2D `floorplanMoveTarget`) — wall slide + re-host onto
// another wall — but `engageMoveDrag` commits on release, with no second click.
function doorMoveHandle(): HandleDescriptor<DoorNodeType> {
return {
kind: 'tap-action',
shape: 'move-cross',
plane: 'node-normal',
portal: 'grandparent',
cursor: 'move',
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
placement: {
position: () => [0, 0, MOVE_HANDLE_LIFT],
},
}
}
const doorHandles: HandleDescriptor<DoorNodeType>[] = [
doorMoveHandle(),
doorWidthHandle('left'),
doorWidthHandle('right'),
doorHeightHandle(),
]
/**
* Door — Phase 5 batch kind. Hosted on walls, cuts holes in them,
* animated open/close state.
*
* Capabilities:
* - **No `movable`**: door's move is bespoke wall-bound drag (slide
* along the wall, snap to wall start/end). Capability-driven dispatch
* keeps legacy `MoveDoorTool`.
* - `selectable`, `duplicable`, `deletable` standard.
*
* Stages:
* - A: registered.
* - B: deferred — door geometry (frame / leaf / glass / hardware /
* segments) is ~800 lines in DoorSystem; extraction is a focused
* session. `def.renderer` (wrap-export of legacy DoorRenderer) +
* `def.system` (DoorSystem + DoorAnimationSystem bundle) hold parity.
* - C: `def.floorplan` polygon sits in parent wall's cutout. Legacy
* `openingPolygons` short-circuits door entries when registered.
*/
export const doorDefinition: NodeDefinition<typeof DoorNode> = {
kind: 'door',
schemaVersion: 1,
schema: DoorNode,
category: 'structure',
surfaceRole: 'joinery',
// Leverage the schema's zod `.default()` annotations to compute the
// full default shape — door has 40+ fields, listing them inline would
// duplicate the schema. Parse a minimal stub, drop id/type, return rest.
defaults: () => {
const stub = DoorNode.parse({ id: 'door_default' as never, type: 'door' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
wallOpeningPlacement: true,
// Doors also host on roof-segment wall faces (base walls under the
// roof, gable ends). `buildCut` punches the opening into the
// segment's wall brush; `dirtyHandledByOwnSystem` keeps the roof-merge
// loop from consuming door dirty marks (DoorSystem owns them and
// already cascades to the host via parentId).
roofAccessory: {
buildCut: (node, hostSegment) =>
buildRoofWallOpeningCut(node as DoorNodeType, hostSegment as RoofSegmentNode),
cutScope: 'wall',
dirtyHandledByOwnSystem: true,
},
// `wallId` / `roofSegmentId` tie the door to its host and are
// re-derived from the surface under the cursor when a preset is
// placed. Host apps strip these at preset-save time via
// `getHostRefFields(def)`.
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
// Panel / glass slots painted through the registry. The door system tags
// each mesh with its `userData.slotId`; paint writes `node.slots`.
slots: () => doorSlots(),
paint: doorPaint,
},
parametrics: doorParametrics,
handles: doorHandles,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
// Priority 3 mirrors the legacy DoorSystem (after animation at 2,
// before wall mitering at 4).
priority: 3,
},
// Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
// direction + perpendicular for the cutout footprint.
floorplan: buildDoorFloorplan,
floorplanDependsOnSiblings: true,
// 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,
// 2D drag affordances. `resize-width` drives the door's two side
// arrows — pointer-down on either arrow starts an anchored width drag
// (opposite edge stays fixed, clamped to wall bounds).
floorplanAffordances: {
'resize-width': doorWidthAffordance,
},
toolHints: [
{ key: 'Left click', label: 'Place door on wall' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Door',
description: 'A door cut into a wall. Animated open/close state.',
icon: { kind: 'url', src: '/icons/door.webp' },
paletteSection: 'structure',
paletteOrder: 50,
},
mcp: {
description: 'A door mounted on a wall, with type / dimensions / hardware options.',
},
}