From 9bfbcc9f01a5dab1dc00f72c57f21f883a85effd Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 22 Jun 2026 11:09:02 -0400 Subject: [PATCH 1/9] editor: add resize SFX triggered while dragging resize handles Register a `resize` sound effect (three round-robin variations with pitch/pan jitter and an 80ms gap) and emit it from the linear/radial resize drag handler whenever the snapped+clamped value steps, so width / length / depth / radius / height drags tick as the size changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/editor/public/audios/sfx/resize_0.mp3 | Bin 0 -> 25984 bytes apps/editor/public/audios/sfx/resize_1.mp3 | Bin 0 -> 25984 bytes apps/editor/public/audios/sfx/resize_2.mp3 | Bin 0 -> 25984 bytes .../components/editor/node-arrow-handles.tsx | 9 +++++++++ packages/editor/src/lib/sfx-bus.ts | 2 ++ packages/editor/src/lib/sfx-player.ts | 10 ++++++++++ 6 files changed, 21 insertions(+) create mode 100644 apps/editor/public/audios/sfx/resize_0.mp3 create mode 100644 apps/editor/public/audios/sfx/resize_1.mp3 create mode 100644 apps/editor/public/audios/sfx/resize_2.mp3 diff --git a/apps/editor/public/audios/sfx/resize_0.mp3 b/apps/editor/public/audios/sfx/resize_0.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..7743b81cecd33f10c326f396b3864f228afcac5d GIT binary patch literal 25984 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/audios/sfx/resize_2.mp3 b/apps/editor/public/audios/sfx/resize_2.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..aa86b4cbbc36789fdf609eade2ba3dbdeb6c48e6 GIT binary patch literal 25984 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 U$^Sq8|E$128~soI|Fi!856}ml761SM literal 0 HcmV?d00001 diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 515dc048..622034cd 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -668,6 +668,11 @@ function LinearArrow({ ? 1 : -1 + // Last value an emitted resize tick fired at — a new tick fires only + // when the (snapped + clamped) value actually changes, so the cue + // tracks real size steps instead of every sub-pixel pointer jitter. + let lastTickValue = initialValue + return { overrideId, onBegin: () => { @@ -695,6 +700,10 @@ function LinearArrow({ ? snapScalar(rawNext, gridSnapStep) : rawNext const next = Math.min(maxBound, Math.max(minBound, snappedNext)) + if (next !== lastTickValue) { + lastTickValue = next + sfxEmitter.emit('sfx:resize') + } const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial // Let the kind publish live guides for the edge being resized. onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi) diff --git a/packages/editor/src/lib/sfx-bus.ts b/packages/editor/src/lib/sfx-bus.ts index 262525d5..a4551410 100644 --- a/packages/editor/src/lib/sfx-bus.ts +++ b/packages/editor/src/lib/sfx-bus.ts @@ -10,6 +10,7 @@ type SFXEvents = { 'sfx:item-pick': undefined 'sfx:item-place': undefined 'sfx:item-rotate': undefined + 'sfx:resize': undefined 'sfx:structure-build-start': undefined 'sfx:structure-build': undefined 'sfx:structure-delete': undefined @@ -40,6 +41,7 @@ export function initSFXBus() { sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick')) sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace')) sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate')) + sfxEmitter.on('sfx:resize', () => playSFX('resize')) sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart')) sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd')) sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete')) diff --git a/packages/editor/src/lib/sfx-player.ts b/packages/editor/src/lib/sfx-player.ts index f4d12e65..e50538d0 100644 --- a/packages/editor/src/lib/sfx-player.ts +++ b/packages/editor/src/lib/sfx-player.ts @@ -59,6 +59,16 @@ export const SFX: Record = { volumeRange: [0.92, 1.0], panJitter: 0.15, }, + // Ticks as a resize handle is dragged across snap steps. Fires in rapid + // succession, so it mirrors gridSnap: three variations cycled round-robin + // with pitch/pan jitter and a gap so the run reads as texture, not a tone. + resize: { + src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'], + rateRange: [0.98, 1.02], + volumeRange: [0.26, 0.34], + panJitter: 0.15, + minIntervalMs: 80, + }, // Fired when a structure draft begins (first click of a wall/slab/etc). structureBuildStart: { src: '/audios/sfx/structure_build_start.mp3', From a71de82ccbd0671f75fc4eb4123f8527d7723f34 Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Tue, 23 Jun 2026 18:07:15 +0530 Subject: [PATCH 2/9] nodes: add MEP movement controls and DWV parity (#438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 * fixed conflict * feat(duct): ceiling-snap drawing + connected-joint endpoint move Duct draw tool's ceiling mode now hangs each path point just below the ceiling actually covering it (per-room heights tracked), with a translucent surface highlight and a plumb line to the floor so the in-flight point reads clearly from any angle. Dragging a duct corner that sits on a fitting now carries the fitting's other ducts along (port-connectivity second hop), so the joint moves together instead of tearing apart. Co-Authored-By: Claude Opus 4.7 * feat(mep): detach + vertical modifiers for duct/pipe joint editing Alt detaches a dragged duct/pipe endpoint or fitting from its connected joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical riser movement on the fitting move. Behavioral parity across 2D and 3D. Co-Authored-By: Claude Opus 4.7 * feat(mep): full DWV pipe parity for joint editing Bring pipe-segment endpoint drags and pipe-fitting moves to parity with duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow re-aim, and connectivity follow. Generalizes the shared elbow-reaim and auto-fitting helpers to dispatch by run kind so 2D and 3D share one path. Co-Authored-By: Claude Opus 4.7 * feat(mep): wall-style arrow handles for duct fittings + segments Add violet directional arrow affordances to duct-fitting selection (height, move cross, rotate arc) mirroring the duct-segment rig: portaled into the parent frame to stay out of the selection outline, rendered via the shared HandleArrow, and carrying mated-run connectivity through the single-undo dance. The move cross engages press-drag-release (placementDragMode) the same way the floating drag does, so the markup hit-areas go inert and the fitting move tool commits on pointer-up. Also re-export the HandleArrow primitives from @pascal-app/editor and extend the duct-segment side-move/floorplan affordances. Co-Authored-By: Claude Opus 4.7 * feat(mep): click-to-latch cube handles for duct + fitting editing Replace the hover-reveal / multi-handle selection rigs with a single click-to-latch cube that opens a directional cluster, shared between duct segments and fittings via a new selection-handles module (HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube). - Duct segment: per-vertex + run-center cubes reveal axis-locked move chevrons (down arrow always shown), plus a roll arc at the run center. - Duct fitting: center cube reveals six ±XYZ move arrows and three per-axis rotation arcs (oriented in place), replacing the old height/move/rotate trio with axis-cycling. - Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth. - thin chevron profile + press-drag-release commit retained. * fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag Build a fully-determined basis for the duct roll gizmo so the curved arrow always seats at the top-outer 45° corner regardless of run direction, instead of an arbitrary apex from a single setFromUnitVectors. The selection-rig ±Y arrows now own vertical movement, so the redundant Ctrl-modifier riser drag is removed from the fitting move tool. Co-Authored-By: Claude Opus 4.7 * feat(mep): run-aligned duct handles, swing snapping, elbow flatten Align the duct run-center cube + horizontal arrows to the run axis (matching the per-vertex handles) while keeping whole-run translate. Endpoint side / up-down swing arrows now follow grid snap points and port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow realign + fitting schemas to flatten to a straight 0° coupling. Surface HVAC-specific hints in the select-mode helper panel for duct / fitting. Co-Authored-By: Claude Opus 4.7 * feat(mep): per-segment linesets/liquid-lines with joint-follow editing Linesets and liquid lines now commit one independent two-point node per drawn segment instead of folding into a single mitered polyline, so each line selects and deletes on its own. Endpoint caps fill shared-coordinate joints so connected segments still read as continuous pipe. Dragging a shared endpoint carries mated segments along via port connectivity (Alt detaches), so a run still edits as one welded piece. Liquid-line follow mode traces the whole connected lineset run, laying a per-segment parallel line down its full assembled length. Co-Authored-By: Claude Opus 4.7 * feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag Lifting/lowering a connected run with the run-center cube now keeps each connected end welded to its stationary partner instead of dragging the whole network. Run-to-run ends get the classic S/Z offset (two elbows + plumb riser, partner trimmed back one leg); elbow-connected ends form a clean L — the existing elbow stays put and re-aims its collar vertical, with one new top elbow + riser reconnecting to the lifted endpoint. The offset is ghosted live and minted as a single undo step on release. Co-Authored-By: Claude Opus 4.7 * Add roof accessory placement guides Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters. * Improve duct and placement routing * Fix duct vertical movement routing * Fix duct vertical offsets and roof accessory movement * Add DWV movement parity and line endpoint controls * Fix MEP handle review issues * Fix chimney placement and duct offset cleanup * Use snapped targets for roof accessory commits * fix(nodes): repair MEP movement review issues - auto-fitting: tee branch now follows the drawn lateral angle; update the stale square-tee test + doc comment that contradicted the rewrite - duct-segment: re-enable the vertical auto-offset rewind (the disabled stub left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers and stacked duplicates); remove the dead stub - duct-segment: strip the stale auto-offset tag on manual corner/roll commits so the horizontal-move path no longer trusts an out-of-date base - chimney: resume history before mutating segment children arrays so a cross-segment move reparents in one tracked transaction (undo stays consistent) - dormer: align schema test with the new windowSill=false default * fix: address mep movement review issues * fix: address follow-up mep review comments * fix: address additional mep review comments --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: pascal-open-bot --- apps/editor/components/build-tab.tsx | 27 +- apps/editor/components/viewer-toolbar.tsx | 3 +- .../components/PreviewToolbar.tsx | 3 +- packages/core/src/material-library.ts | 2 +- packages/core/src/registry/handles.ts | 39 + packages/core/src/registry/index.ts | 1 + packages/core/src/registry/types.ts | 17 + packages/core/src/schema/nodes/dormer.ts | 2 +- .../core/src/schema/nodes/duct-fitting.ts | 11 +- .../core/src/schema/nodes/duct-segment.ts | 1 + .../core/src/schema/nodes/pipe-fitting.ts | 6 +- packages/core/src/schema/nodes/pipe-trap.ts | 2 +- packages/core/src/services/index.ts | 2 + packages/core/src/services/level-height.ts | 44 + .../src/services/port-connectivity.test.ts | 349 ++++ .../core/src/services/port-connectivity.ts | 466 +++-- .../core/src/store/actions/node-actions.ts | 19 + .../editor/handles/handle-arrow.tsx | 33 +- .../editor/src/components/editor/index.tsx | 21 +- .../components/editor/node-arrow-handles.tsx | 129 +- .../editor/wall-move-side-handles.tsx | 2 +- .../components/tools/shared/cursor-sphere.tsx | 57 +- .../ui/command-palette/editor-commands.tsx | 4 +- .../components/ui/command-palette/index.tsx | 5 +- .../components/ui/helpers/helper-manager.tsx | 28 +- .../editor/src/components/viewer-overlay.tsx | 51 +- packages/editor/src/index.tsx | 22 +- packages/editor/src/lib/contextual-help.ts | 22 + packages/nodes/src/box-vent/move-tool.tsx | 32 +- packages/nodes/src/box-vent/tool.tsx | 19 +- packages/nodes/src/chimney/move-tool.tsx | 174 +- packages/nodes/src/chimney/tool.tsx | 16 +- packages/nodes/src/column/renderer.tsx | 13 +- packages/nodes/src/cupola/move-tool.tsx | 32 +- packages/nodes/src/cupola/tool.tsx | 16 +- packages/nodes/src/door/preview.tsx | 2 +- .../nodes/src/dormer/__tests__/schema.test.ts | 2 +- packages/nodes/src/dormer/definition.ts | 77 +- packages/nodes/src/dormer/move-tool.tsx | 150 +- .../nodes/src/dormer/placement-guides.tsx | 273 +++ packages/nodes/src/dormer/tool.tsx | 12 +- .../nodes/src/dormer/use-dormer-placement.ts | 5 + packages/nodes/src/duct-fitting/definition.ts | 12 +- packages/nodes/src/duct-fitting/geometry.ts | 32 +- .../src/duct-fitting/inspector-editors.tsx | 38 + packages/nodes/src/duct-fitting/move-tool.tsx | 81 +- .../src/duct-fitting/parametrics.test.ts | 271 +++ .../nodes/src/duct-fitting/parametrics.ts | 135 +- packages/nodes/src/duct-fitting/ports.ts | 3 +- packages/nodes/src/duct-fitting/selection.tsx | 879 +++++++++- packages/nodes/src/duct-segment/definition.ts | 10 +- packages/nodes/src/duct-segment/floorplan.ts | 30 + packages/nodes/src/duct-segment/geometry.ts | 71 +- packages/nodes/src/duct-segment/move-tool.tsx | 125 +- packages/nodes/src/duct-segment/selection.tsx | 1540 +++++++++++++++-- packages/nodes/src/duct-segment/tool.tsx | 732 +++++--- packages/nodes/src/eyebrow-vent/move-tool.tsx | 32 +- packages/nodes/src/eyebrow-vent/tool.tsx | 19 +- packages/nodes/src/gutter/move-tool.tsx | 23 +- packages/nodes/src/gutter/tool.tsx | 21 +- packages/nodes/src/item/renderer.tsx | 2 +- packages/nodes/src/lineset/connect.test.ts | 124 -- packages/nodes/src/lineset/connect.ts | 98 -- packages/nodes/src/lineset/geometry.ts | 14 +- packages/nodes/src/lineset/index.ts | 1 - packages/nodes/src/lineset/move-tool.tsx | 26 +- packages/nodes/src/lineset/selection.tsx | 281 +-- packages/nodes/src/lineset/tool.tsx | 46 +- packages/nodes/src/liquid-line/connect.ts | 98 -- packages/nodes/src/liquid-line/geometry.ts | 13 +- packages/nodes/src/liquid-line/index.ts | 1 - packages/nodes/src/liquid-line/selection.tsx | 281 +-- packages/nodes/src/liquid-line/tool.tsx | 204 ++- packages/nodes/src/pipe-fitting/definition.ts | 1 + packages/nodes/src/pipe-fitting/move-tool.tsx | 361 ++++ .../src/pipe-fitting/parametrics.test.ts | 106 ++ .../nodes/src/pipe-fitting/parametrics.ts | 59 +- packages/nodes/src/pipe-fitting/selection.tsx | 784 ++++++++- packages/nodes/src/pipe-segment/move-tool.tsx | 26 +- packages/nodes/src/pipe-segment/selection.tsx | 904 ++++++++-- packages/nodes/src/pipe-segment/tool.tsx | 46 +- packages/nodes/src/pipe-trap/definition.ts | 2 +- packages/nodes/src/pipe-trap/geometry.ts | 24 +- packages/nodes/src/pipe-trap/tool.tsx | 2 +- packages/nodes/src/ridge-vent/move-tool.tsx | 23 +- packages/nodes/src/ridge-vent/tool.tsx | 22 +- .../nodes/src/shared/auto-fitting.test.ts | 33 +- packages/nodes/src/shared/auto-fitting.ts | 202 ++- .../nodes/src/shared/auto-offset-tag.test.ts | 155 ++ packages/nodes/src/shared/auto-offset-tag.ts | 139 ++ packages/nodes/src/shared/duct-body-paint.ts | 17 + .../src/shared/fitting-endpoint-reaim.ts | 183 ++ packages/nodes/src/shared/fitting-rotation.ts | 3 +- packages/nodes/src/shared/mep-ghost.tsx | 106 ++ .../nodes/src/shared/path-point-affordance.ts | 95 +- .../src/shared/path-segment-affordance.ts | 134 ++ .../pipe-run-translation-offset.test.ts | 138 ++ .../src/shared/pipe-run-translation-offset.ts | 182 ++ .../src/shared/pipe-vertical-offset.test.ts | 245 +++ .../nodes/src/shared/pipe-vertical-offset.ts | 1103 ++++++++++++ .../src/shared/port-connectivity-pipe.test.ts | 15 +- .../src/shared/refrigerant-line-selection.tsx | 522 ++++++ .../nodes/src/shared/relative-roof-drag.ts | 19 + .../roof-surface-placement-guides.test.ts | 537 ++++++ .../shared/roof-surface-placement-guides.ts | 859 +++++++++ .../nodes/src/shared/roof-surface.test.ts | 25 +- packages/nodes/src/shared/roof-surface.ts | 505 ++++++ .../nodes/src/shared/run-move-connectivity.ts | 99 ++ .../src/shared/run-translation-offset.test.ts | 152 ++ .../src/shared/run-translation-offset.ts | 190 ++ .../nodes/src/shared/selection-handles.tsx | 148 ++ .../nodes/src/shared/vertical-offset.test.ts | 843 +++++++++ packages/nodes/src/shared/vertical-offset.ts | 1128 ++++++++++++ packages/nodes/src/skylight/move-tool.tsx | 31 +- packages/nodes/src/skylight/tool.tsx | 16 +- packages/nodes/src/solar-panel/move-tool.tsx | 32 +- packages/nodes/src/solar-panel/tool.tsx | 16 +- packages/nodes/src/stair/paint.ts | 2 +- packages/nodes/src/turbine-vent/move-tool.tsx | 32 +- packages/nodes/src/turbine-vent/tool.tsx | 19 +- packages/nodes/src/window/definition.ts | 3 + packages/nodes/src/window/preview.tsx | 11 +- packages/viewer/src/store/use-viewer.ts | 6 +- .../viewer/src/systems/door/door-system.tsx | 1 + .../systems/geometry/geometry-system.test.ts | 17 + .../src/systems/geometry/geometry-system.tsx | 23 +- .../viewer/src/systems/wall/wall-cutout.tsx | 17 +- .../viewer/src/systems/wall/wall-materials.ts | 50 +- .../src/systems/window/window-system.tsx | 1 + 129 files changed, 15799 insertions(+), 2049 deletions(-) create mode 100644 packages/core/src/services/port-connectivity.test.ts create mode 100644 packages/nodes/src/dormer/placement-guides.tsx create mode 100644 packages/nodes/src/duct-fitting/inspector-editors.tsx create mode 100644 packages/nodes/src/duct-fitting/parametrics.test.ts delete mode 100644 packages/nodes/src/lineset/connect.test.ts delete mode 100644 packages/nodes/src/lineset/connect.ts delete mode 100644 packages/nodes/src/liquid-line/connect.ts create mode 100644 packages/nodes/src/pipe-fitting/move-tool.tsx create mode 100644 packages/nodes/src/pipe-fitting/parametrics.test.ts create mode 100644 packages/nodes/src/shared/auto-offset-tag.test.ts create mode 100644 packages/nodes/src/shared/auto-offset-tag.ts create mode 100644 packages/nodes/src/shared/duct-body-paint.ts create mode 100644 packages/nodes/src/shared/fitting-endpoint-reaim.ts create mode 100644 packages/nodes/src/shared/mep-ghost.tsx create mode 100644 packages/nodes/src/shared/path-segment-affordance.ts create mode 100644 packages/nodes/src/shared/pipe-run-translation-offset.test.ts create mode 100644 packages/nodes/src/shared/pipe-run-translation-offset.ts create mode 100644 packages/nodes/src/shared/pipe-vertical-offset.test.ts create mode 100644 packages/nodes/src/shared/pipe-vertical-offset.ts create mode 100644 packages/nodes/src/shared/refrigerant-line-selection.tsx create mode 100644 packages/nodes/src/shared/roof-surface-placement-guides.test.ts create mode 100644 packages/nodes/src/shared/roof-surface-placement-guides.ts create mode 100644 packages/nodes/src/shared/run-move-connectivity.ts create mode 100644 packages/nodes/src/shared/run-translation-offset.test.ts create mode 100644 packages/nodes/src/shared/run-translation-offset.ts create mode 100644 packages/nodes/src/shared/selection-handles.tsx create mode 100644 packages/nodes/src/shared/vertical-offset.test.ts create mode 100644 packages/nodes/src/shared/vertical-offset.ts create mode 100644 packages/viewer/src/systems/geometry/geometry-system.test.ts diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 99fb55d9..f80d65a8 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -168,7 +168,8 @@ export function BuildTab() { const ductContext = mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting') const pipeContext = - mode === 'build' && (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting') + mode === 'build' && + (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting' || activeTool === 'pipe-trap') const liquidLineContext = mode === 'build' && activeTool === 'liquid-line' const isMepItemActive = (item: MepItem) => @@ -421,6 +422,30 @@ export function BuildTab() { /> Add Fitting + ) : null} diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 225f7237..ddd2c821 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -119,11 +119,12 @@ const levelModeLabels: Record = { solo: 'Solo', } -const wallModeOrder = ['cutaway', 'up', 'down'] as const +const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const const wallModeConfig: Record = { up: { icon: '/icons/room.webp', label: 'Full height' }, cutaway: { icon: '/icons/wallcut.webp', label: 'Cutaway' }, down: { icon: '/icons/walllow.webp', label: 'Low' }, + translucent: { icon: '/icons/wall.webp', label: 'Translucent' }, } const SHADING_OPTIONS = [ diff --git a/apps/ifc-converter/components/PreviewToolbar.tsx b/apps/ifc-converter/components/PreviewToolbar.tsx index 192119dd..6ae43f52 100644 --- a/apps/ifc-converter/components/PreviewToolbar.tsx +++ b/apps/ifc-converter/components/PreviewToolbar.tsx @@ -14,7 +14,7 @@ import { Box, Grid2x2, Layers, Layers2, Maximize, ScanLine, Square } from 'lucid import { type ReactNode, useMemo } from 'react' const levelModes = ['stacked', 'solo', 'exploded', 'manual'] as const -const wallModes = ['up', 'cutaway', 'down'] as const +const wallModes = ['up', 'cutaway', 'down', 'translucent'] as const const levelLabel: Record<(typeof levelModes)[number], string> = { stacked: 'Stack', @@ -27,6 +27,7 @@ const wallLabel: Record<(typeof wallModes)[number], string> = { up: 'Full', cutaway: 'Cutaway', down: 'Down', + translucent: 'Translucent', } function cycle(list: readonly T[], current: T): T { diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index 161b25b6..ca1ab1b9 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -4193,7 +4193,7 @@ export function getLibraryMaterialIdFromRef(materialRef?: string | null) { } export function getSceneMaterialIdFromRef(materialRef?: string | null): string | null { - if (!materialRef || !materialRef.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null + if (!materialRef?.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null return materialRef.slice(SCENE_MATERIAL_REF_PREFIX.length) } diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 5824255c..a248b54d 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -182,6 +182,25 @@ export type LinearResizeHandle = { * the roof shell below it. Only consulted when `shape === 'tracker'`. */ trackerBaseY?: (node: N, sceneApi: SceneApi) => number + /** + * Stand the chevron blade up into the node's facing plane instead of + * leaving it flat in the local XZ plane. For an `axis: 'x'` handle on a + * wall-mounted opening (door / window), the local XZ plane is horizontal, + * so the default blade is seen edge-on from the front — rotating it 90° + * about its pointing axis lays it in the wall face (local XY) so it reads + * face-on toward the camera. Chevron shape only; `axis: 'y'` handles are + * already stood up unconditionally so this is a no-op for them. + */ + faceNormal?: boolean + /** + * Gate this arrow behind a click-to-latch cube. When set, the arrow is + * hidden until the user clicks the {@link LatchHandle} cube declaring the + * same `group` name; clicking the cube again hides it. Lets a node keep a + * dense cluster (e.g. a dormer's window width/height arrows) collapsed + * behind a single grip until the user opts in. The latch state is local to + * the selection and resets when the node is deselected. + */ + latchGroup?: string } /** @@ -365,6 +384,25 @@ export type TranslateHandle = { portal?: HandlePortal } +/** + * Click-to-latch cube. Renders a small persistent cube at `placement` that + * toggles the visibility of every handle tagged with the matching + * {@link LinearResizeHandle.latchGroup} `group`. Clicking the cube once shows + * the group's arrows; clicking again hides them. The latch state is local to + * the current selection and resets on deselect. + * + * Mirrors the duct-fitting selection cube but driven by descriptor data so any + * node can collapse a dense arrow cluster behind one grip — e.g. a dormer's + * window width/height arrows latch behind a cube at the window center. + */ +export type LatchHandle = { + kind: 'latch' + /** The `latchGroup` name whose arrows this cube reveals / hides. */ + group: string + placement: HandlePlacement + portal?: HandlePortal +} + export type HandleDescriptor = | LinearResizeHandle | RadialResizeHandle @@ -372,6 +410,7 @@ export type HandleDescriptor = | EndpointMoveHandle | TapActionHandle | TranslateHandle + | LatchHandle /** * Static array, or a function for shape-dependent cases (column diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 37d4ac5e..df489b6f 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -9,6 +9,7 @@ export type { HandleList, HandlePlacement, HandlePortal, + LatchHandle, LinearResizeHandle, RadialResizeHandle, TapActionHandle, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index c12bbdfa..3c20cae5 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1584,6 +1584,23 @@ export type ParametricDescriptor = { * `updateNodes`. */ reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial }> + /** + * Deletion companion to `reconcile`: when a node of this kind is about + * to be removed, return patches for OTHER nodes that must follow to + * undo whatever the node imposed on its neighbours — e.g. an + * auto-inserted elbow re-extends the duct runs it trimmed back onto the + * corner it replaced. Called with the node and the live scene `nodes` + * map BEFORE the deletion lands; patches targeting nodes also being + * deleted are ignored. Applied in the same `set` as the delete so it's + * one undo step. Fires only on `deleteNodes` (user-intent deletes) — + * NOT on `applyNodeChanges`, whose deletes are internal re-routes that + * rewrite neighbours explicitly in the same batch and would fight a + * restore. + */ + onDelete?: ( + node: N, + nodes: Record, + ) => Array<{ id: AnyNodeId; data: Partial }> customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> /** * Extra buttons rendered in the inspector's Actions section diff --git a/packages/core/src/schema/nodes/dormer.ts b/packages/core/src/schema/nodes/dormer.ts index d4b772c0..a5702caa 100644 --- a/packages/core/src/schema/nodes/dormer.ts +++ b/packages/core/src/schema/nodes/dormer.ts @@ -91,7 +91,7 @@ export const DormerNode = BaseNode.extend({ windowCornerRadii: z .tuple([z.number(), z.number(), z.number(), z.number()]) .default(DEFAULT_CORNER_RADII), - windowSill: z.boolean().default(true), + windowSill: z.boolean().default(false), windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH), windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS), }).describe( diff --git a/packages/core/src/schema/nodes/duct-fitting.ts b/packages/core/src/schema/nodes/duct-fitting.ts index 277b02f7..1c22a6e0 100644 --- a/packages/core/src/schema/nodes/duct-fitting.ts +++ b/packages/core/src/schema/nodes/duct-fitting.ts @@ -45,7 +45,7 @@ export const DuctFittingNode = BaseNode.extend({ // matching the trunk the fitting sits in. Reducers ignore the shape. // When non-round, `diameter` carries the area-equivalent round size // (drives leg lengths + advertised ports). - shape: z.enum(['round', 'rect', 'oval']).default('round'), + shape: z.enum(['round', 'rect', 'oval']).default('rect'), // Rect / oval run-leg profile in inches (used when shape ≠ 'round'). width: z.number().min(4).max(60).default(14), height: z.number().min(3).max(40).default(8), @@ -53,13 +53,15 @@ export const DuctFittingNode = BaseNode.extend({ // rect / oval profile matching the duct drawn off the tap. When // non-round, `diameter2` carries the branch's area-equivalent round // size. A cross's two opposed branches share this one profile. - shape2: z.enum(['round', 'rect', 'oval']).default('round'), + shape2: z.enum(['round', 'rect', 'oval']).default('rect'), // Rect / oval branch profile in inches (used when shape2 ≠ 'round'). width2: z.number().min(4).max(60).default(14), height2: z.number().min(3).max(40).default(8), // Elbow turn angle in degrees. Residential sheet-metal elbows come in - // 90° and 45°; adjustable elbows cover the range between. - angle: z.number().min(15).max(90).default(90), + // 90° and 45°; adjustable elbows cover the range between. 0° is a + // straight coupling — what an elbow flattens to when its run is dragged + // into line with the fixed collar. + angle: z.number().min(0).max(90).default(90), // Tee branch angle in degrees, measured off the +X (outlet) axis: 90° // is a square straight tee, <90° a lateral whose branch sweeps // downstream toward the outlet (flow merges), >90° leans the branch @@ -72,6 +74,7 @@ export const DuctFittingNode = BaseNode.extend({ diameter2: z.number().min(2).max(48).default(6), ductMaterial: z.enum(['sheet-metal', 'flex', 'duct-board']).default('sheet-metal'), system: z.enum(['supply', 'return']).default('supply'), + slots: z.record(z.string(), z.string()).optional(), }).describe( dedent` Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs. diff --git a/packages/core/src/schema/nodes/duct-segment.ts b/packages/core/src/schema/nodes/duct-segment.ts index 21af751f..348a909d 100644 --- a/packages/core/src/schema/nodes/duct-segment.ts +++ b/packages/core/src/schema/nodes/duct-segment.ts @@ -58,6 +58,7 @@ export const DuctSegmentNode = BaseNode.extend({ // Which side of the air loop this segment belongs to. Drives visual tint // and (in later slices) System graph membership. system: z.enum(['supply', 'return']).default('supply'), + slots: z.record(z.string(), z.string()).optional(), }).describe( dedent` Duct segment - polyline of 3D points connected by duct sections. diff --git a/packages/core/src/schema/nodes/pipe-fitting.ts b/packages/core/src/schema/nodes/pipe-fitting.ts index 88e38e9a..81707ae0 100644 --- a/packages/core/src/schema/nodes/pipe-fitting.ts +++ b/packages/core/src/schema/nodes/pipe-fitting.ts @@ -24,8 +24,10 @@ export const PipeFittingNode = BaseNode.extend({ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), fittingType: z.enum(['elbow', 'wye', 'sanitary-tee', 'cross']).default('elbow'), // Elbow turn in degrees — DWV bends ship as 22.5 / 45 / 90 ("long - // sweep" for drains); adjustable range matches the duct elbow. - angle: z.number().min(15).max(90).default(90), + // sweep" for drains); adjustable range matches the duct elbow. 0° is a + // straight coupling — what an elbow flattens to when its run is dragged + // into line with the fixed collar. + angle: z.number().min(0).max(90).default(90), // Run nominal size in inches. diameter: z.number().min(1.25).max(8).default(2), // Branch collar size (wye / sanitary-tee). diff --git a/packages/core/src/schema/nodes/pipe-trap.ts b/packages/core/src/schema/nodes/pipe-trap.ts index ca63f242..0eba603f 100644 --- a/packages/core/src/schema/nodes/pipe-trap.ts +++ b/packages/core/src/schema/nodes/pipe-trap.ts @@ -21,7 +21,7 @@ export const PipeTrapNode = BaseNode.extend({ // Yaw in radians (the arm direction in plan). rotation: z.number().default(0), // Trap size in inches — matches the fixture drain it serves. - diameter: z.number().min(1.25).max(4).default(1.5), + diameter: z.number().min(1.25).max(4).default(2), pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'), // Developed length of the trap arm (trap weir → vent) in meters. The // draw tool measures it when the arm is drawn; editable in the diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index f52f9394..4d8bac97 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -43,6 +43,8 @@ export { } from './hosting' export { DEFAULT_LEVEL_HEIGHT, + getCeilingAt, + getCeilingHeightAt, getLevelHeight, } from './level-height' export { diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 46f4da0a..016711ac 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,3 +1,4 @@ +import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager' import type { CeilingNode, LevelNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' @@ -40,3 +41,46 @@ export function getLevelHeight( return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT } + +/** + * The ceiling covering level-local point `[x, z]`, or `null` when none + * sits over it. Points inside a ceiling's hole are treated as uncovered. + * When ceilings overlap, the lowest one wins — that's the surface a duct + * would actually hang from. + */ +export function getCeilingAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): CeilingNode | null { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return null + + let best: CeilingNode | null = null + for (const childId of level.children) { + const child = nodes[childId as keyof typeof nodes] + if (child?.type !== 'ceiling') continue + const ceiling = child as CeilingNode + if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue + if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue + const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT + if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling + } + return best +} + +/** + * Underside elevation (meters above the level floor) of the ceiling + * covering level-local point `[x, z]`, or `null` when no ceiling sits + * over that point. See {@link getCeilingAt}. + */ +export function getCeilingHeightAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): number | null { + const ceiling = getCeilingAt(levelId, nodes, x, z) + return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null +} diff --git a/packages/core/src/services/port-connectivity.test.ts b/packages/core/src/services/port-connectivity.test.ts new file mode 100644 index 00000000..7d9cc1ea --- /dev/null +++ b/packages/core/src/services/port-connectivity.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeDefinition, DistributionRole, NodePort } from '../registry' +import { registerNode } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' +import { analyzePortConnectivity, resolveConnectivityUpdates } from './port-connectivity' + +type Point = [number, number, number] + +// Stub registrations mirroring the real kinds' port + role conventions +// without importing the nodes package (which pulls in CSG and can't load +// under the test runner). A run exposes start/end at its path tips; the +// fitting here is a simple two-collar elbow at ±X around its position. +function stubDef( + kind: string, + distributionRole: DistributionRole, + ports: (node: AnyNode) => NodePort[], +): void { + registerNode({ + kind, + schemaVersion: 1, + schema: {}, + category: 'utility', + distributionRole, + defaults: () => ({}), + capabilities: {}, + ports, + } as unknown as AnyNodeDefinition) +} + +stubDef('duct-segment', 'run', (node) => { + const path = (node as unknown as { path: Point[] }).path + const system = (node as unknown as { system: string }).system + return [ + { id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter: 6, system }, + { id: 'end', position: path[path.length - 1]!, direction: [1, 0, 0], diameter: 6, system }, + ] +}) +stubDef('duct-fitting', 'fitting', (node) => { + const position = (node as unknown as { position: Point }).position + const system = (node as unknown as { system: string }).system + return [ + { + id: 'inlet', + position: [position[0] - 0.2, position[1], position[2]], + direction: [-1, 0, 0], + diameter: 6, + system, + }, + { + id: 'outlet', + position: [position[0] + 0.2, position[1], position[2]], + direction: [1, 0, 0], + diameter: 6, + system, + }, + ] +}) +stubDef('duct-tee', 'fitting', (node) => { + const position = (node as unknown as { position: Point }).position + const system = (node as unknown as { system: string }).system + return [ + { + id: 'inlet', + position: [position[0] - 0.2, position[1], position[2]], + direction: [-1, 0, 0], + diameter: 6, + system, + }, + { + id: 'outlet', + position: [position[0] + 0.2, position[1], position[2]], + direction: [1, 0, 0], + diameter: 6, + system, + }, + { + id: 'branch', + position: [position[0], position[1], position[2] + 0.2], + direction: [0, 0, 1], + diameter: 6, + system, + }, + ] +}) + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} + +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +function expectPointClose(actual: Point, expected: Point) { + expect(actual[0]).toBeCloseTo(expected[0], 6) + expect(actual[1]).toBeCloseTo(expected[1], 6) + expect(actual[2]).toBeCloseTo(expected[2], 6) +} + +describe('port connectivity — joint follow (stretch vs translate)', () => { + // Layout: duct A ends at the fitting's inlet (−0.2,0,0); duct B starts at the + // fitting's outlet (+0.2,0,0). Both runs lie on the X axis. Dragging A's + // mated endpoint carries the fitting and duct B; how B reacts depends on + // whether the drag is along its axis (stretch) or across it (translate). + function joint() { + const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' }) + const ductA = makeNode('duct-segment', { + path: [ + [-3, 0, 0], + [-0.2, 0, 0], + ], + system: 'supply', + }) + const ductB = makeNode('duct-segment', { + path: [ + [0.2, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + return { fitting, ductA, ductB } + } + + function movedA(end: Point): AnyNode { + const { ductA } = joint() + return { ...(ductA as Record), path: [[-3, 0, 0], end] } as AnyNode + } + + test('the fitting and sibling run are picked up as carried connections', () => { + const { fitting, ductA, ductB } = joint() + const connectivity = analyzePortConnectivity(ductA, sceneOf(fitting, ductA, ductB)) + expect( + connectivity.connections.find((c) => c.kind === 'rigid-node' && c.nodeId === fitting.id), + ).toBeDefined() + expect( + connectivity.connections.find((c) => c.kind === 'run' && c.nodeId === ductB.id), + ).toBeDefined() + }) + + test('perpendicular drag translates the WHOLE sibling run (no skew)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + const connectivity = analyzePortConnectivity(ductA, nodes) + + // Move A's mated end +1 in Z — perpendicular to B's X axis. + const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1])) + + expect( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + ).toEqual([0, 0, 1]) + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + // Both ends ride +1 in Z: the run keeps its length and direction. + expect(bPath[0]).toEqual([0.2, 0, 1]) + expect(bPath[1]).toEqual([3, 0, 1]) + }) + + test('parallel drag stretches the sibling run (only the near end slides)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + const connectivity = analyzePortConnectivity(ductA, nodes) + + // Move A's mated end +0.5 in X — along B's axis (the fitting slides toward B). + const updates = resolveConnectivityUpdates(connectivity, movedA([0.3, 0, 0])) + + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + // Near end slid +0.5 in X; far end stayed put → the run shortened. + expect(bPath[0]).toEqual([0.7, 0, 0]) + expect(bPath[1]).toEqual([3, 0, 0]) + }) + + test('perpendicular slide propagates through the sibling run to its far joint', () => { + // Extend the chain: duct B's far end (3,0,0) meets a second elbow, and duct + // C hangs off that elbow. A perpendicular drag should carry the whole chain. + const { fitting, ductA, ductB } = joint() + const elbow2 = makeNode('duct-fitting', { position: [3.2, 0, 0], system: 'supply' }) + // elbow ports are ±0.2 on X around its position → inlet at (3,0,0) meets B. + const ductC = makeNode('duct-segment', { + path: [ + [3.4, 0, 0], + [6, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(fitting, ductA, ductB, elbow2, ductC) + const connectivity = analyzePortConnectivity(ductA, nodes) + + const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1])) + + // Whole chain rode +1 in Z. + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + expect(bPath[1]).toEqual([3, 0, 1]) + expect((updates.find((u) => u.id === elbow2.id)!.data as { position: Point }).position).toEqual( + [3.2, 0, 1], + ) + const cPath = (updates.find((u) => u.id === ductC.id)!.data as { path: Point[] }).path + expect(cPath[0]).toEqual([3.4, 0, 1]) + expect(cPath[1]).toEqual([6, 0, 1]) + }) + + test('a run reached from both ends applies both endpoint deltas', () => { + const moved = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + const follower = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, follower) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [0, 0, 1], + [3, 0, 2], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + const path = (updates.find((u) => u.id === follower.id)!.data as { path: Point[] }).path + expect(path[0]).toEqual([0, 0, 1]) + expect(path[1]).toEqual([3, 0, 2]) + }) + + test('a polyline run reached from both ends preserves interior bend shape', () => { + const moved = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 3], + ], + system: 'supply', + }) + const follower = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [1, 0, 0], + [1, 0, 3], + [3, 0, 3], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, follower) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.5, 0, 0], + [3.5, 0, 3], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + const path = (updates.find((u) => u.id === follower.id)!.data as { path: Point[] }).path + expect(path).toEqual([ + [-0.5, 0, 0], + [1, 0, 0], + [1, 0, 3], + [3.5, 0, 3], + ]) + }) + + test('a fitting reached from both collars rebroadcasts its final compatible rigid delta', () => { + const moved = makeNode('duct-segment', { + path: [ + [-0.2, 0, 0], + [0.2, 0, 0], + ], + system: 'supply', + }) + const fitting = makeNode('duct-tee', { position: [0, 0, 0], system: 'supply' }) + const downstream = makeNode('duct-segment', { + path: [ + [0, 0, 0.2], + [3, 0, 0.2], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, fitting, downstream) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.2, 0, 1], + [0.2, 0, 1.00005], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + expectPointClose( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + [0, 0, 1.000025], + ) + const path = (updates.find((u) => u.id === downstream.id)!.data as { path: Point[] }).path + expectPointClose(path[0]!, [0, 0, 1.200025]) + expectPointClose(path[1]!, [3, 0, 1.200025]) + }) + + test('a fitting reached from incompatible collars merges constraints deterministically', () => { + const moved = makeNode('duct-segment', { + path: [ + [-0.2, 0, 0], + [0.2, 0, 0], + ], + system: 'supply', + }) + const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' }) + const nodes = sceneOf(moved, fitting) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.2, 0, 1], + [0.2, 0, -1], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + expectPointClose( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + [0, 0, 0], + ) + }) + + test('an unrelated run not on the fitting is left alone', () => { + const { fitting, ductA, ductB } = joint() + const distant = makeNode('duct-segment', { + path: [ + [10, 0, 0], + [13, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(fitting, ductA, ductB, distant) + const connectivity = analyzePortConnectivity(ductA, nodes) + expect(connectivity.connections.find((c) => c.nodeId === distant.id)).toBeUndefined() + }) +}) diff --git a/packages/core/src/services/port-connectivity.ts b/packages/core/src/services/port-connectivity.ts index 3dbbbf35..57c0b0d4 100644 --- a/packages/core/src/services/port-connectivity.ts +++ b/packages/core/src/services/port-connectivity.ts @@ -13,14 +13,29 @@ import type { AnyNode, AnyNodeId } from '../schema' * * Pure logic: it asks each node for its ports via `def.ports` (level-local * meters) and does arithmetic. No Three.js, no rendering — it lives in - * core and is consumed by the editor's move tool and the duct-segment - * system alike. + * core and is consumed by the editor's move tool and the duct/pipe + * selection affordances alike. * - * Propagation is intentionally **one hop**: a moved fitting stretches the - * ducts touching it (their near endpoint follows) and rigidly drags any - * fitting mated collar-to-collar, but it does NOT chase the far end of - * those ducts or anything beyond. Bounded and predictable — no runaway - * network rearrangement. + * ## Propagation model + * + * The joint graph is snapshotted once at drag start (`analyzePortConnectivity`) + * and walked every frame (`resolveConnectivityUpdates`) given the moved node's + * live transform. Deltas flow outward from the moved node through coincident + * ports: + * + * - **Fitting** (rigid): a collar pushed by delta `d` translates the whole + * fitting by `d`; every other collar carries that same `d` onward. + * - **Run** (stretch + slide, never skew): an endpoint pushed by delta `d` is + * split against the run's own axis. The *parallel* part slides only that + * endpoint (the run lengthens / shortens); the *perpendicular* part + * translates the entire run (so its direction is preserved). The far + * endpoint therefore moves by just the perpendicular part, and that part + * propagates onward to whatever is mated to the far endpoint. + * + * Propagation walks the whole connected component so a joint stays welded all + * the way down the chain, with a visited guard so cycles (looped runs) and + * shared joints terminate. First-reached (shortest path) wins on a node + * reachable two ways. */ type Point = readonly [number, number, number] @@ -30,36 +45,55 @@ type Point = readonly [number, number, number] * generous slack for grid-snapped hand placement without false matches. */ const COINCIDENT_EPS_M = 0.05 -/** A node attached to one of the moved node's ports, plus how it follows. */ +/** Below this (meters) a propagated delta is treated as zero — stops the + * walk from chasing sub-millimeter perpendicular residue. */ +const DELTA_EPS_M = 1e-4 +const PROPAGATION_EPS_M = 1e-9 + +/** A node carried by the edit, plus the snapshot needed to revert it. Kept + * deliberately small: the move tools read only `kind` + `nodeId` and the + * matching start snapshot to revert before the single tracked commit. */ export type PortConnection = | { - /** Partner is a duct run: the endpoint touching the moved port slides - * to track it (one hop — the far endpoint stays put, stretching the - * run). */ - kind: 'duct-endpoint' - nodeId: AnyNodeId - /** Index in the duct's `path` that tracks the moved port. */ - pathIndex: number - /** The moved node's port id this endpoint follows. */ - movedPortId: string - /** The duct's full path at edit-start (other points are preserved). */ - startPath: Point[] - } - | { - /** Partner is another fitting mated collar-to-collar: it translates - * rigidly so its collar stays on the moved collar. */ + /** A fitting mated collar-to-collar: it translates rigidly. */ kind: 'rigid-node' nodeId: AnyNodeId - movedPortId: string - /** Partner node's `position` at edit-start. */ + /** Node's `position` at edit-start. */ startPosition: Point } + | { + /** A run whose endpoint(s) ride the edit: it stretches and/or + * translates, never skews. */ + kind: 'run' + nodeId: AnyNodeId + /** The run's full `path` at edit-start. */ + startPath: Point[] + } + +/** One node in the snapshotted joint graph (everything reachable from the + * moved node, excluding the moved node itself). */ +type GraphNode = { + id: AnyNodeId + role: 'run' | 'fitting' + ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + startPath?: Point[] + startPosition?: Point +} + +/** Who else sits on a given node's port, keyed `nodeId` → `portId` → mates. */ +type Adjacency = Record>> export type PortConnectivity = { movedNodeId: AnyNodeId - /** The moved node's port world positions at edit-start, keyed by port id. - * Used as the reference each connection's delta is measured from. */ + /** The moved node's port world positions at edit-start, keyed by port id — + * the reference each frame's delta is measured from. */ startMovedPorts: Record + /** Reachable run/fitting nodes (excludes the moved node), keyed by id. */ + graph: Record + /** Port coincidence edges across the moved node + every graph node. */ + adjacency: Adjacency + /** Flat list of carried nodes for the move tools' revert + "anything to + * follow?" check. Derived from `graph`. */ connections: PortConnection[] } @@ -83,85 +117,225 @@ function distSq(a: Point, b: Point): number { return dx * dx + dy * dy + dz * dz } +/** Two ports mate when they coincide AND don't cross incompatible systems + * (a supply duct and a waste pipe that merely touch must not fuse). */ +function portsMate( + a: { position: Point; system?: string }, + b: { position: Point; system?: string }, + epsSq: number, +): boolean { + if (distSq(a.position, b.position) > epsSq) return false + if (a.system && b.system && a.system !== b.system) return false + return true +} + /** - * Snapshot which nodes are connected to `movedNode`'s ports, taken at the + * Snapshot the joint graph reachable from `movedNode`'s ports, taken at the * start of a move/resize. Call once before the drag; feed the result to * `resolveConnectivityUpdates` on every frame. * - * Only `run`-role partners (segments — endpoint stretch) and `fitting`-role - * partners (rigid follow) are tracked — terminals and equipment usually mount - * to a surface and shouldn't be yanked off it when an adjacent fitting nudges. + * Only `run`-role partners (segments) and `fitting`-role partners are walked — + * terminals and equipment usually mount to a surface and shouldn't be yanked + * off it when an adjacent fitting nudges. Fittings that declare + * `portConnectivityFollow: false` are anchored fixtures (e.g. pipe-trap) and + * are skipped, so a connected run stretches against them instead. */ export function analyzePortConnectivity( movedNode: AnyNode, nodes: Record, ): PortConnectivity { - const movedPorts = portsOf(movedNode) ?? [] - const startMovedPorts: Record = {} - const movedPortSystem: Record = {} - for (const p of movedPorts) { - startMovedPorts[p.id] = p.position - movedPortSystem[p.id] = p.system - } - - const connections: PortConnection[] = [] const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M + const movedPorts = portsOf(movedNode) ?? [] + const startMovedPorts: Record = {} + for (const p of movedPorts) startMovedPorts[p.id] = p.position + + // Candidate partners: every run + every following fitting in the scene. + const candidates: GraphNode[] = [] for (const other of Object.values(nodes)) { if (!other || other.id === movedNode.id) continue - // Generalised across every distribution family (HVAC duct + DWV pipe): - // `run` partners stretch an endpoint, `fitting` partners follow rigidly. - // Terminals/equipment mount to surfaces and are intentionally NOT dragged. - // Fittings that declare `portConnectivityFollow: false` are anchored - // fixtures (e.g. pipe-trap) — moving a connected run stretches the arm. - const otherRole = roleOf(other) - if (otherRole !== 'run' && otherRole !== 'fitting') continue - const otherDef = nodeRegistry.get(other.type) - if (otherRole === 'fitting' && otherDef?.portConnectivityFollow === false) continue - const otherPorts = portsOf(other) - if (!otherPorts) continue + const role = roleOf(other) + if (role !== 'run' && role !== 'fitting') continue + if (role === 'fitting' && nodeRegistry.get(other.type)?.portConnectivityFollow === false) { + continue + } + const ports = portsOf(other) + if (!ports) continue + const startPath = + role === 'run' + ? (other as unknown as { path?: Point[] }).path?.map((p) => [...p] as Point) + : undefined + if (role === 'run' && (!startPath || startPath.length < 2)) continue + const startPosition = + role === 'fitting' + ? (() => { + const pos = (other as unknown as { position?: Point }).position + return pos ? ([pos[0], pos[1], pos[2]] as Point) : undefined + })() + : undefined + if (role === 'fitting' && !startPosition) continue + candidates.push({ id: other.id as AnyNodeId, role, ports, startPath, startPosition }) + } - for (const op of otherPorts) { - // Find which of the moved node's ports this partner port sits on. - let matchedId: string | null = null - for (const mp of movedPorts) { - if (distSq(op.position, mp.position) > epsSq) continue - // Don't fuse ports from incompatible systems (e.g. a supply duct - // and a waste pipe that happen to cross): only mate when both - // ports declare the same system, or at least one is unscoped. - const ms = movedPortSystem[mp.id] - if (ms && op.system && ms !== op.system) continue - matchedId = mp.id - break - } - if (!matchedId) continue + // Walk outward from the moved node, collecting every node reachable through + // coincident ports. The adjacency records each port's mates so the resolver + // can replay the same edges with live deltas. + const adjacency: Adjacency = {} + const addEdge = (nodeId: string, portId: string, mate: { nodeId: AnyNodeId; portId: string }) => { + const byPort = adjacency[nodeId] ?? {} + adjacency[nodeId] = byPort + const mates = byPort[portId] ?? [] + byPort[portId] = mates + mates.push(mate) + } - if (otherRole === 'run') { - const path = (other as unknown as { path?: Point[] }).path - if (!Array.isArray(path) || path.length < 2) continue - // Port id 'start' → first point, 'end' → last point. - const pathIndex = op.id === 'start' ? 0 : path.length - 1 - connections.push({ - kind: 'duct-endpoint', - nodeId: other.id, - pathIndex, - movedPortId: matchedId, - startPath: path.map((p) => [...p] as Point), - }) - } else { - const position = (other as unknown as { position?: Point }).position - if (!position) continue - connections.push({ - kind: 'rigid-node', - nodeId: other.id, - movedPortId: matchedId, - startPosition: [position[0], position[1], position[2]], - }) + const graph: Record = {} + const visited = new Set([movedNode.id]) + + // Seed: the moved node's own ports. + const queue: Array<{ + id: string + ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + }> = [{ id: movedNode.id, ports: movedPorts }] + + while (queue.length > 0) { + const { id, ports } = queue.shift()! + for (const port of ports) { + for (const cand of candidates) { + if (cand.id === id) continue + for (const cp of cand.ports) { + if (!portsMate(port, cp, epsSq)) continue + addEdge(id, port.id, { nodeId: cand.id, portId: cp.id }) + addEdge(cand.id, cp.id, { nodeId: id as AnyNodeId, portId: port.id }) + if (!visited.has(cand.id)) { + visited.add(cand.id) + graph[cand.id] = cand + queue.push({ id: cand.id, ports: cand.ports }) + } + } } } } - return { movedNodeId: movedNode.id as AnyNodeId, connections, startMovedPorts } + const connections: PortConnection[] = Object.values(graph).map((g) => + g.role === 'fitting' + ? { kind: 'rigid-node', nodeId: g.id, startPosition: g.startPosition! } + : { kind: 'run', nodeId: g.id, startPath: g.startPath! }, + ) + + return { + movedNodeId: movedNode.id as AnyNodeId, + startMovedPorts, + graph, + adjacency, + connections, + } +} + +function add(a: Point, b: Point): Point { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +function sub(a: Point, b: Point): Point { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +function lenSq(v: Point): number { + return v[0] * v[0] + v[1] * v[1] + v[2] * v[2] +} + +/** Split `delta` into the component along unit `axis` and the remainder. */ +function decompose(delta: Point, axis: Point): { parallel: Point; perp: Point } { + const dot = delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2] + const parallel: Point = [axis[0] * dot, axis[1] * dot, axis[2] * dot] + return { parallel, perp: sub(delta, parallel) } +} + +function scale(v: Point, scalar: number): Point { + return [v[0] * scalar, v[1] * scalar, v[2] * scalar] +} + +function average(deltas: Point[]): Point { + const sum = deltas.reduce((acc, delta) => add(acc, delta), [0, 0, 0]) + return scale(sum, 1 / deltas.length) +} + +function nearlyEqual(a: Point, b: Point): boolean { + return lenSq(sub(a, b)) <= DELTA_EPS_M * DELTA_EPS_M +} + +function propagationEqual(a: Point, b: Point): boolean { + return lenSq(sub(a, b)) <= PROPAGATION_EPS_M * PROPAGATION_EPS_M +} + +function effectivePortDeltas( + constraints: Record>, +): Record { + return Object.fromEntries( + Object.entries(constraints).map(([portId, bySource]) => [ + portId, + average(Object.values(bySource)), + ]), + ) +} + +/** Unit direction of the run's segment adjacent to its `start` / `end` tip. */ +function endpointAxis(path: Point[], portId: string): Point { + const n = path.length + const [a, b] = portId === 'start' ? [path[1]!, path[0]!] : [path[n - 2]!, path[n - 1]!] + const dir = sub(b, a) + const l2 = lenSq(dir) + if (l2 < 1e-12) return [0, 0, 0] + const l = Math.sqrt(l2) + return [dir[0] / l, dir[1] / l, dir[2] / l] +} + +function runPathFromSinglePortDelta( + startPath: Point[], + portId: 'start' | 'end', + delta: Point, +): Point[] { + const nearIdx = portId === 'start' ? 0 : startPath.length - 1 + const axis = endpointAxis(startPath, portId) + const { parallel, perp } = decompose(delta, axis) + const path = startPath.map((p) => add(p, perp)) + path[nearIdx] = add(path[nearIdx]!, parallel) + return path +} + +function runEndpointDeltas(startPath: Point[], path: Point[]): Record { + return { + start: sub(path[0]!, startPath[0]!), + end: sub(path[path.length - 1]!, startPath[startPath.length - 1]!), + } +} + +function runPathFromPortDeltas(startPath: Point[], portDeltas: Record): Point[] { + const startDelta = portDeltas.start + const endDelta = portDeltas.end + if (startDelta && endDelta) { + if (startPath.length === 2) { + return [add(startPath[0]!, startDelta), add(startPath[1]!, endDelta)] + } + + if (nearlyEqual(startDelta, endDelta)) { + return startPath.map((p) => add(p, startDelta)) + } + + const startParts = decompose(startDelta, endpointAxis(startPath, 'start')) + const endParts = decompose(endDelta, endpointAxis(startPath, 'end')) + const commonPerp = average([startParts.perp, endParts.perp]) + const path = startPath.map((p) => add(p, commonPerp)) + path[0] = add(path[0]!, startParts.parallel) + path[path.length - 1] = add(path[path.length - 1]!, endParts.parallel) + return path + } + + return runPathFromSinglePortDelta( + startPath, + startDelta ? 'start' : 'end', + (startDelta ?? endDelta)!, + ) } /** @@ -169,45 +343,103 @@ export function analyzePortConnectivity( * that keep every connected node attached. `previewNode` is the moved node * with its current drag position/rotation applied so its ports recompute. * - * - Duct endpoint: set the tracked path point to the moved port's new - * position (the joint stays welded; the run stretches). - * - Rigid fitting: translate by the moved port's delta so its mated collar - * rides along. + * Walks the snapshotted graph, propagating each port delta outward: fittings + * translate rigidly, runs stretch along their axis and translate across it + * (never skew when driven from one end), and effective port movement carries on + * to neighbouring joints. Port-level output guards bound cycles while still + * allowing a looped/shared run to accept constraints at both endpoints. */ export function resolveConnectivityUpdates( connectivity: PortConnectivity, previewNode: AnyNode, ): { id: AnyNodeId; data: Partial }[] { + const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity + if (Object.keys(graph).length === 0) return [] + const newPorts = portsOf(previewNode) ?? [] - const newById: Record = {} - for (const p of newPorts) newById[p.id] = p.position + const newMovedPos: Record = {} + for (const p of newPorts) newMovedPos[p.id] = p.position - const updates: { id: AnyNodeId; data: Partial }[] = [] - for (const conn of connectivity.connections) { - const start = connectivity.startMovedPorts[conn.movedPortId] - const now = newById[conn.movedPortId] - if (!start || !now) continue + // Each queue item drives a node's port by a delta ("this collar / endpoint + // must move by this much"). + const queue: Array<{ nodeId: AnyNodeId; portId: string; delta: Point; sourceKey: string }> = [] + const results: Record }> = {} + const constrainedPorts: Record>> = {} + const propagatedPorts: Record> = {} - if (conn.kind === 'duct-endpoint') { - const path = conn.startPath.map((p, i) => - i === conn.pathIndex ? ([now[0], now[1], now[2]] as Point) : ([...p] as Point), - ) - updates.push({ id: conn.nodeId, data: { path } as Partial }) - } else { - const dx = now[0] - start[0] - const dy = now[1] - start[1] - const dz = now[2] - start[2] - updates.push({ - id: conn.nodeId, - data: { - position: [ - conn.startPosition[0] + dx, - conn.startPosition[1] + dy, - conn.startPosition[2] + dz, - ], - } as Partial, + const enqueueMates = (nodeId: string, portId: string, delta: Point) => { + const byPort = propagatedPorts[nodeId] ?? {} + propagatedPorts[nodeId] = byPort + const previous = byPort[portId] + if (previous && propagationEqual(previous, delta)) return + byPort[portId] = delta + + for (const mate of adjacency[nodeId]?.[portId] ?? []) { + if (mate.nodeId === movedNodeId) continue + queue.push({ + nodeId: mate.nodeId, + portId: mate.portId, + delta, + sourceKey: `${nodeId}:${portId}`, }) } } - return updates + + const acceptPortDelta = ( + nodeId: AnyNodeId, + portId: string, + sourceKey: string, + delta: Point, + ): boolean => { + const byPort = constrainedPorts[nodeId] ?? {} + constrainedPorts[nodeId] = byPort + const bySource = byPort[portId] ?? {} + byPort[portId] = bySource + const existing = bySource[sourceKey] + if (existing && propagationEqual(existing, delta)) { + return false + } + bySource[sourceKey] = delta + return true + } + + // Seed from the moved node's live port deltas. + for (const [portId, start] of Object.entries(startMovedPorts)) { + const now = newMovedPos[portId] + if (!now) continue + const delta = sub(now, start) + if (lenSq(delta) <= DELTA_EPS_M * DELTA_EPS_M) continue + enqueueMates(movedNodeId, portId, delta) + } + + while (queue.length > 0) { + const { nodeId, portId, delta, sourceKey } = queue.shift()! + const node = graph[nodeId] + if (!node) continue + if (!acceptPortDelta(nodeId, portId, sourceKey, delta)) continue + const portDeltas = effectivePortDeltas(constrainedPorts[nodeId]!) + + if (node.role === 'fitting') { + const start = node.startPosition! + const effectiveDelta = average(Object.values(portDeltas)) + results[nodeId] = { + id: nodeId, + data: { position: add(start, effectiveDelta) } as Partial, + } + // Rigid: every collar carries the effective body translation onward. + for (const p of node.ports) { + enqueueMates(nodeId, p.id, effectiveDelta) + } + } else { + const startPath = node.startPath! + const path = runPathFromPortDeltas(startPath, portDeltas) + results[nodeId] = { id: nodeId, data: { path } as Partial } + for (const [nextPortId, nextDelta] of Object.entries(runEndpointDeltas(startPath, path))) { + if (lenSq(nextDelta) <= DELTA_EPS_M * DELTA_EPS_M) continue + enqueueMates(nodeId, nextPortId, nextDelta) + } + } + } + + return Object.values(results) } diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 5de80de9..94997dba 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1,3 +1,4 @@ +import { nodeRegistry } from '../../registry/registry' import { type AnyNode, type AnyNodeId, @@ -1010,6 +1011,24 @@ export const deleteNodesAction = ( } for (const id of allIds) deletedIds.add(id) + // Let each deleted kind undo what it imposed on its neighbours (e.g. an + // auto-inserted elbow re-extends the duct runs it trimmed back onto the + // corner it replaced). Read against pre-deletion `nextNodes`; skip + // patches that target a node also being deleted. + for (const id of allIds) { + const node = nextNodes[id] + if (!node) continue + const onDelete = nodeRegistry.get(node.type)?.parametrics?.onDelete + if (!onDelete) continue + for (const { id: targetId, data } of onDelete(node, nextNodes)) { + if (allIds.has(targetId)) continue + const target = nextNodes[targetId] + if (!target) continue + nextNodes[targetId] = { ...target, ...data } as AnyNode + nodesToMarkDirty.add(targetId) + } + } + for (const plan of mergePlans) { const primaryWall = nextNodes[plan.primaryWallId] if (!(primaryWall && primaryWall.type === 'wall') || allIds.has(plan.primaryWallId)) { diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index caf71ffe..8c8f0589 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -51,6 +51,13 @@ const CHEVRON_DEPTH = 0.08 const CHEVRON_BEVEL_THICKNESS = 0.035 const CHEVRON_BEVEL_SIZE = 0.03 const CHEVRON_BEVEL_SEGMENTS = 10 +// Slimmer extrude profile matching the legacy wall side handles +// (`wall-move-side-handles.tsx`) — opt-in via the `thin` prop so the chunkier +// default is preserved for every other handle that uses the shared chevron. +const CHEVRON_THIN_DEPTH = 0.045 +const CHEVRON_THIN_BEVEL_THICKNESS = 0.018 +const CHEVRON_THIN_BEVEL_SIZE = 0.02 +const CHEVRON_THIN_BEVEL_SEGMENTS = 8 const MOVE_CROSS_HALF_LENGTH = 0.36 const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03 const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12 @@ -64,7 +71,7 @@ const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3 const ROTATE_RIBBON_HALF_WIDTH = 0.02 const ROTATE_HEAD_HALF_WIDTH = 0.045 const TRACKER_CUBE_SIZE = 0.16 -export const CORNER_HEX_RADIUS = 0.16 +export const CORNER_HEX_RADIUS = 0.11 export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker' export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross' @@ -90,6 +97,8 @@ export type HandleArrowProps = { indicatorRotation?: readonly [number, number, number] onPointerEnter?: PointerHandler onPointerLeave?: PointerHandler + // Extrude the slimmer wall-handle chevron profile (chevron shape only). + thin?: boolean } function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape { @@ -179,8 +188,9 @@ export function createRotateArrowHandleGeometry() { // Reused chevron+shaft silhouette. The chevron points along +X by default; // callers rotate it around Y for Z-axis handles and into a vertical frame for -// Y-axis handles. -export function createArrowHandleGeometry() { +// Y-axis handles. `thin` extrudes the slimmer wall-handle profile. +export function createArrowHandleGeometry(thin = false) { + const depth = thin ? CHEVRON_THIN_DEPTH : CHEVRON_DEPTH const shape = new Shape() shape.moveTo(CHEVRON_MAX_X, 0) shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH) @@ -191,16 +201,16 @@ export function createArrowHandleGeometry() { shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH) shape.lineTo(CHEVRON_MAX_X, 0) const geometry = new ExtrudeGeometry(shape, { - depth: CHEVRON_DEPTH, + depth, bevelEnabled: true, - bevelThickness: CHEVRON_BEVEL_THICKNESS, - bevelSize: CHEVRON_BEVEL_SIZE, + bevelThickness: thin ? CHEVRON_THIN_BEVEL_THICKNESS : CHEVRON_BEVEL_THICKNESS, + bevelSize: thin ? CHEVRON_THIN_BEVEL_SIZE : CHEVRON_BEVEL_SIZE, bevelOffset: 0, - bevelSegments: CHEVRON_BEVEL_SEGMENTS, + bevelSegments: thin ? CHEVRON_THIN_BEVEL_SEGMENTS : CHEVRON_BEVEL_SEGMENTS, curveSegments: 16, steps: 1, }) - geometry.translate(0, 0, -CHEVRON_DEPTH / 2) + geometry.translate(0, 0, -depth / 2) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() geometry.computeBoundingSphere() @@ -314,8 +324,8 @@ export function createEndpointHitAreaGeometry(radius: number) { return geometry } -function createHandleArrowGeometry(shape: HandleArrowShape) { - if (shape === 'chevron') return createArrowHandleGeometry() +function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) { + if (shape === 'chevron') return createArrowHandleGeometry(thin) if (shape === 'cross') return createMoveCrossHandleGeometry() if (shape === 'curved-arrow') return createRotateArrowHandleGeometry() if (shape === 'tracker') { @@ -459,9 +469,10 @@ export function HandleArrow({ onPointerDown, onPointerEnter, onPointerLeave, + thin = false, }: HandleArrowProps) { const visualShape = normalizeHandleArrowShape(shape, cursor) - const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape]) + const geometry = useMemo(() => createHandleArrowGeometry(visualShape, thin), [visualShape, thin]) const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape]) const indicatorMaterial = useHandleArrowMaterial(visualShape) const hitMaterial = useInvisibleHitAreaMaterial() diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d8b27dec..4ff55fd2 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -1147,6 +1147,7 @@ export default function Editor({ + {isFirstPersonMode && } @@ -1205,8 +1206,14 @@ export default function Editor({ {!isLoading && isPreviewMode ? (
- useEditor.getState().setPreviewMode(false)} /> -
{previewViewerContent}
+ {isFirstPersonMode ? ( + useEditor.getState().setFirstPersonMode(false)} /> + ) : ( + useEditor.getState().setPreviewMode(false)} /> + )} +
+ {previewViewerContent} +
) : ( <> @@ -1270,8 +1277,14 @@ export default function Editor({ {!isLoading && isPreviewMode ? ( <> - useEditor.getState().setPreviewMode(false)} /> -
{previewViewerContent}
+ {isFirstPersonMode ? ( + useEditor.getState().setFirstPersonMode(false)} /> + ) : ( + useEditor.getState().setPreviewMode(false)} /> + )} +
+ {previewViewerContent} +
) : ( <> diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 622034cd..052065b1 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -9,6 +9,7 @@ import { DEFAULT_ANGLE_STEP, type HandleDescriptor, type HandlePortal, + type LatchHandle, type LinearResizeHandle, nodeRegistry, type RadialResizeHandle, @@ -109,11 +110,16 @@ export { ARROW_COLOR, ARROW_HOVER_COLOR, ARROW_SCALE, + createArrowHandleGeometry, createArrowHitAreaGeometry, createEndpointHitAreaGeometry, createMoveCrossHandleGeometry, createRotateArrowHandleGeometry, createRotateArrowHitAreaGeometry, + HandleArrow, + type HandleArrowInputShape, + type HandleArrowPlacement, + type HandleArrowProps, HIT_AREA_MARGIN, InvisibleHandleHitArea, NO_RAYCAST, @@ -369,6 +375,21 @@ function NodeArrowHandlesForNode({ // hook count between renders and trip React's rules-of-hooks check. const [activeIndex, setActiveIndex] = useState(null) const [preDragNode, setPreDragNode] = useState(null) + // Latch groups currently toggled open. A `latch` cube descriptor flips its + // group here on click; arrows tagged with a `latchGroup` only render while + // their group is in this set. Local to this mount, so it resets on deselect + // (the rig remounts per selection — see the `key` on NodeArrowHandlesForNode). + const [openLatchGroups, setOpenLatchGroups] = useState>(() => new Set()) + const toggleLatchGroup = useMemo( + () => (group: string) => + setOpenLatchGroups((prev) => { + const next = new Set(prev) + if (next.has(group)) next.delete(group) + else next.add(group) + return next + }), + [], + ) const dragControls = useMemo( () => ({ onStart: (index: number, snapshot: AnyNode) => { @@ -399,21 +420,36 @@ function NodeArrowHandlesForNode({ // here, or they'd lag behind the moving item. const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate' - const arrows = descriptors.map((descriptor, index) => ( - - )) + const arrows = descriptors.map((descriptor, index) => { + // A `latch` cube toggles its group's visibility; render it always. + if (descriptor.kind === 'latch') { + return ( + + ) + } + // Arrows tagged with a latch group stay hidden until that group is open. + const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined + if (latchGroup && !openLatchGroups.has(latchGroup)) return null + return ( + + ) + }) return createPortal( @@ -717,10 +753,19 @@ function LinearArrow({ // X+Z rotation chain matching DoorHeightArrowHandle. When the handle // sits below the node (placement Y < 0, e.g. window bottom arrow), // flip the Z rotation so the chevron points outward (downward). + // + // For axis === 'x' with `faceNormal` (wall-mounted opening width arrows), + // roll the blade 90° about its own pointing (X) axis so it stands up from + // the horizontal XZ plane into the node's facing plane (XY = the wall + // face) — otherwise the blade is seen edge-on from the front. + const faceNormalX = + descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true const innerRotation: [number, number, number] = descriptor.axis === 'y' ? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2] - : [0, 0, 0] + : faceNormalX + ? [Math.PI / 2, 0, 0] + : [0, 0, 0] // Optional guide decoration — linear handles use it for curved-stair // width / inner-radius rings; radial handles use it for the column's @@ -806,6 +851,7 @@ function LinearArrow({ onPointerDown={activate} placement={{ position, rotation: [0, rotationY, 0], baseScale }} shape="chevron" + thin > {showLabel ? : null} @@ -1201,6 +1247,7 @@ function ArcArrow({ baseScale, }} shape={isRotateShape ? 'curved-arrow' : 'chevron'} + thin /> ) @@ -1323,6 +1370,56 @@ function TapActionArrow({ onPointerDown={onActivate} placement={{ position, rotation, baseScale }} shape={shape === 'move-cross' ? 'move-cross' : 'chevron'} + thin + /> + ) +} + +// Click-to-latch cube. A persistent grip (the `tracker` cube) that toggles +// the visibility of every arrow tagged with its `latchGroup` on click. Sized +// to match the duct selection cube (`baseScale = zoom`, full TRACKER_CUBE_SIZE) +// so every latch grip reads the same across the app. Stays highlighted while +// its group is open so the user can tell it's engaged. +function LatchCube({ + descriptor, + node, + open, + onToggle, +}: { + descriptor: LatchHandle + node: AnyNode + open: boolean + onToggle: (group: string) => void +}) { + const [isHovered, setIsHovered] = useState(false) + const { camera } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom + + const placementSceneApi = useMemo(() => createSceneApi(useScene), []) + const position = descriptor.placement.position(node, placementSceneApi) + const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 + + // Route through the shared tap path so the cube click is swallowed before it + // reaches the select tool — stops R3F propagation, suppresses box-select, and + // eats the trailing DOM click that would otherwise select the host node. + const onPointerDown = useHandleDrag({ + kind: 'tap', + onTap: () => { + setIsHovered(false) + onToggle(descriptor.group) + }, + }) + + return ( + ) } diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 1840e459..97d7d6e7 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -54,7 +54,7 @@ const ARROW_HOVER_COLOR = '#a5b4fc' // Match the door arrows: scale the rendered chevron down to ~two-thirds // so the in-world handles read as a single UI family. const ARROW_SCALE = 0.65 -const CORNER_HEX_RADIUS = 0.16 +const CORNER_HEX_RADIUS = 0.11 const CORNER_DASH_SIZE = 0.1 const CORNER_GAP_SIZE = 0.07 const CORNER_DASH_THICKNESS = 0.006 diff --git a/packages/editor/src/components/tools/shared/cursor-sphere.tsx b/packages/editor/src/components/tools/shared/cursor-sphere.tsx index 10af0c08..d403842a 100644 --- a/packages/editor/src/components/tools/shared/cursor-sphere.tsx +++ b/packages/editor/src/components/tools/shared/cursor-sphere.tsx @@ -12,12 +12,28 @@ interface CursorSphereProps extends Omit { depthWrite?: boolean showTooltip?: boolean height?: number + /** + * Put the bright marker dot at the TIP of the vertical line (y = height) + * instead of on the ground ring. Used when the point being placed hangs + * above the floor (e.g. duct drawn against the ceiling): the dot rides at + * the cursor / placement point while the line drops to a floor ring that + * keeps the plan position readable. + */ + dotAtTip?: boolean /** Custom tooltip content — overrides the auto-detected build tool icon */ tooltipContent?: React.ReactNode } export const CursorSphere = forwardRef(function CursorSphere( - { color = '#818cf8', showTooltip = true, height = 2.5, visible = true, tooltipContent, ...props }, + { + color = '#818cf8', + showTooltip = true, + height = 2.5, + dotAtTip = false, + visible = true, + tooltipContent, + ...props + }, ref, ) { const tool = useEditor((s) => s.tool) @@ -39,19 +55,23 @@ export const CursorSphere = forwardRef(function Cursor return ( - {/* Flat marker on the ground */} + {/* Flat marker on the ground. The bright center dot moves to the tip + of the line in `dotAtTip` mode (the placement point hangs above the + floor), leaving a faint ring here so the plan position stays read. */} - {/* Center dot */} - - - - + {/* Center dot — at the ground unless the placement point is elevated */} + {!dotAtTip && ( + + + + + )} {/* Outer ring / glow */} @@ -60,7 +80,7 @@ export const CursorSphere = forwardRef(function Cursor color={color} depthTest={false} depthWrite={false} - opacity={0.25} + opacity={dotAtTip ? 0.2 : 0.25} transparent /> @@ -80,6 +100,15 @@ export const CursorSphere = forwardRef(function Cursor )} + {/* Bright marker dot at the tip of the line — the actual placement + point, riding at the cursor while the line drops to the floor. */} + {dotAtTip && height > 0 && ( + + + + + )} + {/* Tool Icon Tooltip at the top of the line */} {isVisible && showTooltip && (activeToolConfig || tooltipContent) && ( , - keywords: ['wall', 'cutaway', 'up', 'down', 'view'], + keywords: ['wall', 'cutaway', 'up', 'down', 'translucent', 'view'], badge: () => { const mode = useViewer.getState().wallMode - return { cutaway: 'Cutaway', up: 'Up', down: 'Down' }[mode] + return { cutaway: 'Cutaway', up: 'Up', down: 'Down', translucent: 'Translucent' }[mode] }, navigate: true, execute: () => navigateTo('wall-mode'), diff --git a/packages/editor/src/components/ui/command-palette/index.tsx b/packages/editor/src/components/ui/command-palette/index.tsx index 4f9d2964..2979d80f 100644 --- a/packages/editor/src/components/ui/command-palette/index.tsx +++ b/packages/editor/src/components/ui/command-palette/index.tsx @@ -244,10 +244,11 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm setOpen(false) } - const wallModeLabel: Record<'cutaway' | 'up' | 'down', string> = { + const wallModeLabel: Record<'cutaway' | 'up' | 'down' | 'translucent', string> = { cutaway: 'Cutaway', up: 'Up', down: 'Down', + translucent: 'Translucent', } const levelModeLabel: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = { manual: 'Manual', @@ -373,7 +374,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm {/* ── Wall Mode sub-page ────────────────────────────────────── */} {page === 'wall-mode' && ( - {(['cutaway', 'up', 'down'] as const).map((mode) => ( + {(['cutaway', 'up', 'down', 'translucent'] as const).map((mode) => ( node !== undefined), ), ) - const selectModeHints = useMemo( - () => - resolveSelectModeHelpHints({ - selectedCount: selectedNodes.length, - hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)), - hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)), - commandPressed: modifiers.command, - shiftPressed: modifiers.shift, - }), - [modifiers.command, modifiers.shift, selectedNodes], - ) + const selectModeHints = useMemo(() => { + const single = selectedNodes.length === 1 ? selectedNodes[0] : null + const mepSelection = + single?.type === 'duct-segment' || single?.type === 'pipe-segment' + ? 'run' + : single?.type === 'duct-fitting' || single?.type === 'pipe-fitting' + ? 'fitting' + : null + return resolveSelectModeHelpHints({ + selectedCount: selectedNodes.length, + hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)), + hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)), + commandPressed: modifiers.command, + shiftPressed: modifiers.shift, + mepSelection, + }) + }, [modifiers.command, modifiers.shift, selectedNodes]) // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. if (isMobile) return null diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index 9e1cc312..e60c5d66 100644 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -25,6 +25,7 @@ import { Check, ChevronRight, Diamond, + Footprints, Layers, Palette, PenLine, @@ -32,8 +33,10 @@ import { Square, } from 'lucide-react' import Link from 'next/link' +import { flushSync } from 'react-dom' import { useShallow } from 'zustand/react/shallow' import { cn } from '../lib/utils' +import useEditor from '../store/use-editor' import { ActionButton } from './ui/action-menu/action-button' import { DropdownMenu, @@ -51,6 +54,24 @@ type ProjectOwner = { image: string | null } +function requestWalkthroughPointerLock() { + const canvas = document.querySelector('[data-pascal-viewer-3d] canvas') + if (!canvas) return + + if (!canvas.hasAttribute('tabindex')) { + canvas.tabIndex = -1 + } + canvas.focus({ preventScroll: true }) + + if (document.pointerLockElement === canvas) return + + try { + canvas.requestPointerLock?.() + } catch { + return + } +} + const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { stacked: 'Stacked', exploded: 'Exploded', @@ -83,6 +104,12 @@ const wallModeConfig = { ), label: 'Low', }, + translucent: { + icon: (props: any) => ( + Translucent + ), + label: 'Translucent', + }, } const SHADING_OPTIONS = [ @@ -580,7 +607,12 @@ export const ViewerOverlay = ({ } label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`} onClick={() => { - const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] + const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [ + 'cutaway', + 'up', + 'down', + 'translucent', + ] const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') }} @@ -641,6 +673,23 @@ export const ViewerOverlay = ({ src="/icons/topview.webp" /> + +
+ + {/* First-person walkthrough */} + { + flushSync(() => useEditor.getState().setFirstPersonMode(true)) + requestWalkthroughPointerLock() + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + +
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 1ef26d77..0fd27078 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -39,7 +39,27 @@ export { formatMeasurement, MeasurementPill, } from './components/editor/measurement-pill' -export { NodeArrowHandles } from './components/editor/node-arrow-handles' +// In-world arrow handle primitives (chevron geometry, invisible hit area, +// shared material, palette + scale constants). Re-exported so kind-owned +// 3D selection affordances in `@pascal-app/nodes` (duct side-move / height / +// extend arrows) reuse the same UI family as the wall / fence side handles. +export { + ARROW_COLOR, + ARROW_HOVER_COLOR, + ARROW_SCALE, + createArrowHandleGeometry, + createArrowHitAreaGeometry, + HandleArrow, + type HandleArrowInputShape, + type HandleArrowPlacement, + type HandleArrowProps, + InvisibleHandleHitArea, + NO_RAYCAST, + NodeArrowHandles, + swallowNextClick, + useArrowMaterial, + useInvisibleHitAreaMaterial, +} from './components/editor/node-arrow-handles' export { type SnapshotCameraData, ThumbnailGenerator, diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index 3648a8c9..221fe580 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -10,12 +10,19 @@ export type SelectModeHelpContext = { hasRotatableSelection: boolean commandPressed: boolean shiftPressed: boolean + // When a single MEP node is selected its in-world handle rig (click a dot to + // reveal move arrows) is the real editing path, so the panel leads with the + // handle-specific hints instead of just the generic Cmd-drag tips. + mepSelection?: 'run' | 'fitting' | null } const COMMAND_KEY = 'Cmd/Ctrl' const LEFT_CLICK = 'Left click' const RIGHT_CLICK = 'Right click' const SHIFT_KEY = 'Shift' +const CLICK = 'Click' +const ALT_KEY = 'Alt' +const ROTATE_KEYS = 'R / T' export function resolveSelectModeHelpHints({ selectedCount, @@ -23,6 +30,7 @@ export function resolveSelectModeHelpHints({ hasRotatableSelection, commandPressed, shiftPressed, + mepSelection = null, }: SelectModeHelpContext): ContextualShortcutHint[] { const hints: ContextualShortcutHint[] = [] @@ -37,6 +45,20 @@ export function resolveSelectModeHelpHints({ return hints } + // MEP handle workflow — duct/pipe runs and fittings are edited through the + // in-world arrow rig that a click on the handle dot reveals, so surface those + // hints first. A run endpoint's side / up-down arrows swing the run and Alt + // detaches the joint mid-drag; a fitting's cluster adds rotate arcs, with + // R / T (and Alt to switch axis) for keyboard rotation. + if (mepSelection === 'run') { + hints.push({ keys: [CLICK], label: 'Click a handle dot to show move arrows' }) + hints.push({ keys: [ALT_KEY], label: 'Detach the joint while dragging an arrow' }) + } else if (mepSelection === 'fitting') { + hints.push({ keys: [CLICK], label: 'Click the handle dot to show move + rotate handles' }) + hints.push({ keys: [ROTATE_KEYS], label: 'Rotate ±45°' }) + hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' }) + } + if (commandPressed) { if (hasMovableSelection) { hints.push({ diff --git a/packages/nodes/src/box-vent/move-tool.tsx b/packages/nodes/src/box-vent/move-tool.tsx index 6553f7a3..1c9ec302 100644 --- a/packages/nodes/src/box-vent/move-tool.tsx +++ b/packages/nodes/src/box-vent/move-tool.tsx @@ -5,6 +5,7 @@ import { type BoxVentNode, emitter, type RoofEvent, + type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, @@ -21,8 +22,14 @@ import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, + snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, +} from '../shared/roof-surface-placement-guides' import BoxVentPreview from './preview' /** @@ -72,10 +79,21 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { lastSnap = null setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() + } + + const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { + const rawTarget = roofDrag.resolve(event) + if (!rawTarget) return null + return snapRoofSurfaceNodeTarget({ + target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + node, + bypass: event.nativeEvent?.shiftKey === true, + }) } const updatePreview = (event: RoofEvent) => { - const target = roofDrag.resolve(event) + const target = resolveSnappedTarget(event) if (!target) { clearTarget() return @@ -102,12 +120,18 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { target.localZ, ]), ) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node as RoofNode, + segment: target.segment, + center: [target.localX, target.localY, target.localZ], + node, + }) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? roofDrag.resolve(event) + const target = lastTarget ?? resolveSnappedTarget(event) if (!target) return committed = true const targetSegmentId = target.segment.id as AnyNodeId @@ -152,6 +176,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { if (obj) obj.visible = true triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() exitMoveMode() event.stopPropagation() } @@ -172,6 +197,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { useScene.getState().deleteNode(node.id as AnyNodeId) useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() return } @@ -191,6 +217,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() } @@ -223,6 +250,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { // the original mesh visible rather than stranded invisible. const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true + clearRoofSurfacePlacementGuides() useScene.temporal.getState().resume() } }, [exitMoveMode, node]) diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index 23c8af63..27dd2da6 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { boxVentDefinition } from './definition' import BoxVentPreview from './preview' @@ -85,6 +90,15 @@ const BoxVentTool = () => { setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode({ + ...previewNode, + rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), + }), + }) event.stopPropagation() } @@ -109,6 +123,7 @@ const BoxVentTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [vent.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -120,8 +135,9 @@ const BoxVentTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -131,6 +147,7 @@ const BoxVentTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/chimney/move-tool.tsx b/packages/nodes/src/chimney/move-tool.tsx index 5e4690f9..bf056266 100644 --- a/packages/nodes/src/chimney/move-tool.tsx +++ b/packages/nodes/src/chimney/move-tool.tsx @@ -10,11 +10,25 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { consumePlacementDragRelease, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { createRelativeRoofDrag, type RelativeRoofDragTarget } from '../shared/relative-roof-drag' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + snapRelativeRoofDragTarget, +} from '../shared/relative-roof-drag' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, +} from '../shared/roof-surface-placement-guides' import ChimneyPreview from './preview' const tmpMatrix = new THREE.Matrix4() @@ -67,6 +81,25 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { useEffect(() => { if (!activeBuildingId) return + useScene.temporal.getState().pause() + + const original = { + position: [...node.position] as [number, number, number], + rotation: node.rotation ?? 0, + roofSegmentId: node.roofSegmentId, + parentId: node.parentId, + metadata: node.metadata, + } + const meta = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + const isNew = !!meta.isNew + + if (node.id) { + const chimneyObj = sceneRegistry.nodes.get(node.id) + if (chimneyObj) chimneyObj.visible = false + } const computeSegmentXform = (segmentId: string): SegmentTransform | null => { const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId) @@ -84,25 +117,33 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { } let lastTarget: RelativeRoofDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag({ - position: [...node.position] as [number, number, number], - roofSegmentId: node.roofSegmentId, + position: original.position, + roofSegmentId: original.roofSegmentId, }) + const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { + const rawTarget = roofDrag.resolve(event) + if (!rawTarget) return null + return snapRoofSurfaceNodeTarget({ + target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + node, + bypass: event.nativeEvent?.shiftKey === true, + }) + } + const clearTarget = () => { lastTarget = null setSegmentXform(null) setHitLocal(null) setPreviewSegment(null) + clearRoofSurfacePlacementGuides() } const updatePreview = (event: RoofEvent) => { - const target = roofDrag.resolve(event) - if (!target) { - clearTarget() - return - } - lastTarget = target + const target = resolveSnappedTarget(event) + if (!target) return clearTarget() const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 @@ -113,26 +154,32 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { } const xform = computeSegmentXform(target.segment.id) - if (!xform) return + if (!xform) return clearTarget() + lastTarget = target setSegmentXform(xform) setHitLocal([target.localX, target.localY, target.localZ]) setPreviewSegment(target.segment) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node, + segment: target.segment, + center: [target.localX, target.localY, target.localZ], + node, + }) event.stopPropagation() } const onClick = (event: RoofEvent) => { - const target = lastTarget ?? roofDrag.resolve(event) + if (committed) return + const target = lastTarget ?? resolveSnappedTarget(event) if (!target) return + committed = true const state = useScene.getState() // Strip the `isNew` flag — only used to mark a duplicate clone // that hasn't been committed yet. - const meta = - node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) - ? (node.metadata as Record) - : {} const { isNew, ...restMeta } = meta as { isNew?: boolean } const cleanedMeta = Object.keys(restMeta).length > 0 ? restMeta : undefined + const targetSegmentId = target.segment.id as AnyNodeId // Duplicate (clone with no committed id yet) → create a fresh // chimney parented to the hit segment. Plain move (existing id, @@ -143,29 +190,105 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { ...node, id: undefined as never, roofSegmentId: target.segment.id, + parentId: target.segment.id, position: [target.localX, target.localY, target.localZ], + visible: true, metadata: cleanedMeta, }) - state.createNode(committed, target.segment.id as AnyNodeId) - state.dirtyNodes.add(target.segment.id as AnyNodeId) + useScene.temporal.getState().resume() + state.applyNodeChanges({ + delete: node.id ? [node.id as AnyNodeId] : [], + create: [{ node: committed, parentId: targetSegmentId }], + }) + state.dirtyNodes.add(targetSegmentId) setSelection({ selectedIds: [committed.id] }) + useScene.temporal.getState().pause() } else { - const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined + const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined + const reparenting = Boolean(prevSegmentId && prevSegmentId !== targetSegmentId) + // Resume BEFORE any scene edits so the reparent (both segments' + // children arrays + the chimney's own host/position update) lands as + // one tracked transaction. Otherwise undo reverts the chimney but + // leaves the children arrays inconsistent with its parentId. + useScene.temporal.getState().resume() + if (reparenting) { + const oldSeg = state.nodes[prevSegmentId!] as RoofSegmentNode | undefined + if (oldSeg) { + state.updateNode(prevSegmentId!, { + children: (oldSeg.children ?? []).filter((id) => id !== node.id), + }) + } + const newSeg = state.nodes[targetSegmentId] as RoofSegmentNode | undefined + if (newSeg && !(newSeg.children ?? []).includes(node.id)) { + state.updateNode(targetSegmentId, { + children: [...(newSeg.children ?? []), node.id], + }) + } + state.dirtyNodes.add(prevSegmentId!) + } state.updateNode(node.id as AnyNodeId, { roofSegmentId: target.segment.id, parentId: target.segment.id, position: [target.localX, target.localY, target.localZ], + rotation: original.rotation, + visible: true, metadata: cleanedMeta, }) - if (prevSegmentId) state.dirtyNodes.add(prevSegmentId) - state.dirtyNodes.add(target.segment.id as AnyNodeId) + useScene.temporal.getState().pause() + state.dirtyNodes.add(targetSegmentId) + state.dirtyNodes.add(node.id as AnyNodeId) setSelection({ selectedIds: [node.id] }) } + const obj = node.id && !isNew ? sceneRegistry.nodes.get(node.id) : null + if (obj) obj.visible = true + clearRoofSurfacePlacementGuides() setMovingNode(null) triggerSFX('sfx:item-place') event.stopPropagation() } + const onCancel = () => { + if (isNew) { + if (node.id) { + const parentId = original.roofSegmentId as AnyNodeId | undefined + if (parentId) { + const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined + if (parent) { + useScene.getState().updateNode(parentId, { + children: (parent.children ?? []).filter((id) => id !== node.id), + }) + } + } + useScene.getState().deleteNode(node.id as AnyNodeId) + } + useScene.temporal.getState().resume() + markToolCancelConsumed() + clearRoofSurfacePlacementGuides() + setMovingNode(null) + return + } + + if (node.id) { + useScene.getState().updateNode(node.id as AnyNodeId, { + position: original.position, + rotation: original.rotation, + roofSegmentId: original.roofSegmentId as AnyNodeId | undefined, + parentId: original.parentId as AnyNodeId | undefined, + metadata: original.metadata, + }) + if (original.roofSegmentId) { + useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId) + } + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + } + + useScene.temporal.getState().resume() + markToolCancelConsumed() + clearRoofSurfacePlacementGuides() + setMovingNode(null) + } + const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return if (!lastTarget) return @@ -179,6 +302,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onClick) emitter.on('roof:leave', clearTarget) + emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { @@ -186,7 +310,15 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) emitter.off('roof:leave', clearTarget) + emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + + if (node.id) { + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + } + clearRoofSurfacePlacementGuides() + useScene.temporal.getState().resume() } }, [activeBuildingId, node, setMovingNode, setSelection]) diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index 19a7ada0..e8455b02 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -16,6 +16,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { chimneyDefinition } from './definition' import ChimneyPreview from './preview' @@ -102,6 +107,12 @@ const ChimneyTool = () => { setSegmentXform(xform) setHitLocal([hit.localX, hit.localY, hit.localZ]) setPreviewSegment(hit.segment) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode, { segment: hit.segment }), + }) event.stopPropagation() } @@ -126,6 +137,7 @@ const ChimneyTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [chimney.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -137,8 +149,9 @@ const ChimneyTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -149,6 +162,7 @@ const ChimneyTool = () => { setSegmentXform(null) setHitLocal(null) setPreviewSegment(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && segmentXform && hitLocal && previewSegment && ( diff --git a/packages/nodes/src/column/renderer.tsx b/packages/nodes/src/column/renderer.tsx index fb070206..53a6d5bd 100644 --- a/packages/nodes/src/column/renderer.tsx +++ b/packages/nodes/src/column/renderer.tsx @@ -2404,18 +2404,7 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { textures, colorPreset, }), - [ - shading, - textures, - colorPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - node.materialPreset, - node.slots, - sceneMaterials, - ], + [shading, textures, colorPreset, node, sceneMaterials], ) useRegistry(node.id, node.type, ref) diff --git a/packages/nodes/src/cupola/move-tool.tsx b/packages/nodes/src/cupola/move-tool.tsx index 1d888217..0e02936b 100644 --- a/packages/nodes/src/cupola/move-tool.tsx +++ b/packages/nodes/src/cupola/move-tool.tsx @@ -5,6 +5,7 @@ import { type CupolaNode, emitter, type RoofEvent, + type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, @@ -21,8 +22,14 @@ import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, + snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, +} from '../shared/roof-surface-placement-guides' import CupolaPreview from './preview' /** @@ -70,10 +77,21 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { lastSnap = null setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() + } + + const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { + const rawTarget = roofDrag.resolve(event) + if (!rawTarget) return null + return snapRoofSurfaceNodeTarget({ + target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + node, + bypass: event.nativeEvent?.shiftKey === true, + }) } const updatePreview = (event: RoofEvent) => { - const target = roofDrag.resolve(event) + const target = resolveSnappedTarget(event) if (!target) { clearTarget() return @@ -100,12 +118,18 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { target.localZ, ]), ) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node as RoofNode, + segment: target.segment, + center: [target.localX, target.localY, target.localZ], + node, + }) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? roofDrag.resolve(event) + const target = lastTarget ?? resolveSnappedTarget(event) if (!target) return committed = true const targetSegmentId = target.segment.id as AnyNodeId @@ -146,6 +170,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { if (obj) obj.visible = true triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() exitMoveMode() event.stopPropagation() } @@ -164,6 +189,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { useScene.getState().deleteNode(node.id as AnyNodeId) useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() return } @@ -183,6 +209,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() } @@ -212,6 +239,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true + clearRoofSurfacePlacementGuides() useScene.temporal.getState().resume() } }, [exitMoveMode, node]) diff --git a/packages/nodes/src/cupola/tool.tsx b/packages/nodes/src/cupola/tool.tsx index 2e137394..fbf004de 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { cupolaDefinition } from './definition' import CupolaPreview from './preview' @@ -77,6 +82,12 @@ const CupolaTool = () => { setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode), + }) event.stopPropagation() } @@ -101,6 +112,7 @@ const CupolaTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [cupola.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -112,8 +124,9 @@ const CupolaTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -123,6 +136,7 @@ const CupolaTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/door/preview.tsx b/packages/nodes/src/door/preview.tsx index 8cd9b637..f2441caf 100644 --- a/packages/nodes/src/door/preview.tsx +++ b/packages/nodes/src/door/preview.tsx @@ -29,7 +29,7 @@ const DoorPreview = ({ const m = buildDoorPreviewMesh(node) m.layers.set(EDITOR_LAYER) return m - }, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount]) + }, [node]) // Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips; // its cleanup only disposes the clones it made. diff --git a/packages/nodes/src/dormer/__tests__/schema.test.ts b/packages/nodes/src/dormer/__tests__/schema.test.ts index 0a16e44c..eb833c34 100644 --- a/packages/nodes/src/dormer/__tests__/schema.test.ts +++ b/packages/nodes/src/dormer/__tests__/schema.test.ts @@ -12,7 +12,7 @@ describe('DormerNode schema', () => { expect(parsed.height).toBe(0) expect(parsed.roofType).toBe('gable') expect(parsed.windowShape).toBe('rectangle') - expect(parsed.windowSill).toBe(true) + expect(parsed.windowSill).toBe(false) }) test('windowColumns / windowRows clamped to [1, 8]', () => { diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index 17e9581d..ab7a1518 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -33,6 +33,9 @@ const MAX_SKIRT = 6 const WINDOW_SIDE_HANDLE_OFFSET = 0.15 const WINDOW_HEIGHT_HANDLE_OFFSET = 0.15 const WINDOW_FACE_Z_OFFSET = 0.05 +// The four window-edge arrows latch behind a cube at the window center; +// they stay hidden until the user clicks that cube to open the group. +const WINDOW_LATCH_GROUP = 'dormer-window' // Lower clamp for window dims matches the geometry's internal clamp // in `getDormerSkirtWindowDims` (0.1m). Upper clamps depend on the // dormer dimensions and are resolved per-handle via the function form @@ -109,21 +112,43 @@ function dormerWidthHandle(side: 'left' | 'right'): HandleDescriptor { +// Depth arrow on the +Z (front) or -Z (back) side. Asymmetric resize: +// dragging one arrow grows the dormer outward from its own edge while +// the opposite edge stays world-fixed in segment frame — same pattern +// as `dormerWidthHandle`, just on the Z axis. `apply` recomputes +// `position` so the anchored edge stays at the same segment-local point +// even when the dormer is Y-rotated: project the dormer's local +Z onto +// segment frame via (sin r, cos r), find the anchored edge's segment- +// local XZ from the pre-drag node, then place the new center half a new- +// depth away from that anchor in the same direction. +function dormerDepthHandle(side: 'front' | 'back'): HandleDescriptor { + const sign = side === 'front' ? 1 : -1 return { kind: 'linear-resize', axis: 'z', - anchor: 'center', + // 'min' = -Z edge anchored (front arrow grows the +Z edge outward). + // 'max' = +Z edge anchored (back arrow grows the -Z edge outward). + anchor: side === 'front' ? 'min' : 'max', min: MIN_DIM, currentValue: (n) => n.depth, - apply: (_n, newValue) => ({ depth: newValue }), + apply: (initial, newDepth) => { + const rotY = initial.rotation ?? 0 + const armX = Math.sin(rotY) + const armZ = Math.cos(rotY) + const anchorX = initial.position[0] - sign * (initial.depth / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.depth / 2) * armZ + const newCenterX = anchorX + sign * (newDepth / 2) * armX + const newCenterZ = anchorZ + sign * (newDepth / 2) * armZ + return { + depth: newDepth, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, placement: { - position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET], + position: (n) => [0, getBodyMidY(n), sign * (n.depth / 2 + SIDE_HANDLE_OFFSET)], + // The renderer auto-yaws axis-'z' chevrons by -π/2 so the default + // points +Z (front). Flip the back chevron 180° to point -Z. + rotationY: () => (side === 'front' ? 0 : Math.PI), }, } } @@ -273,6 +298,11 @@ function dormerWindowWidthHandle(side: 'left' | 'right'): HandleDescriptor