From fb4ac2b87467dac52521bd49b80b0ebfd0c2b595 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:08:19 -0400 Subject: [PATCH 01/27] fix(capture): round armed FOV, add Alt slow modifier for the drone camera armCaptureFov stored the live camera FOV verbatim, so fractional pose FOVs printed float tails in the HUD and left the reset button enabled. Both writers now share clampCaptureFov. Alt holds the drone at 0.2x speed and look sensitivity for fine framing; Shift stays the boost. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../editor/first-person-controls.tsx | 28 +++++++++++++++++-- .../editor/snapshot-capture-overlay.tsx | 1 + packages/editor/src/store/use-editor.tsx | 16 ++++++----- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index f05e37595..953a88b00 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -94,6 +94,7 @@ const LOOK_SENSITIVITY = 0.002 // constant is an exponential approach rate, not a linear acceleration. const DRONE_SPEED = 7 const DRONE_RUN_MULTIPLIER = 3 +const DRONE_SLOW_MULTIPLIER = 0.2 const DRONE_SMOOTHING = 12 const CONTROLLER_CENTER_FROM_EYE = 0.85 const DOOR_INTERACTION_DISTANCE = 2.5 @@ -675,6 +676,7 @@ export const FirstPersonControls = () => { const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1) const crouchKeyRef = useRef(false) const droneAscendKeyRef = useRef(false) + const droneSlowKeyRef = useRef(false) const droneDescendKeyRef = useRef(false) const droneVelocityRef = useRef(new Vector3()) const suspendRef = useRef(false) @@ -1164,10 +1166,15 @@ export const FirstPersonControls = () => { // Shutter hold: the shot is rendering — a mouse twitch must not pan it. if (useEditor.getState().captureShutterHold) return - yawRef.current -= e.movementX * LOOK_SENSITIVITY + const lookSensitivity = + LOOK_SENSITIVITY * + (useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current + ? DRONE_SLOW_MULTIPLIER + : 1) + yawRef.current -= e.movementX * lookSensitivity pitchRef.current = Math.max( -(Math.PI / 2 - 0.05), - Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY), + Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * lookSensitivity), ) } @@ -1280,6 +1287,10 @@ export const FirstPersonControls = () => { event.preventDefault() event.stopPropagation() if (!suspendRef.current) droneAscendKeyRef.current = true + } else if ((event.code === 'AltLeft' || event.code === 'AltRight') && isDroneMode) { + event.preventDefault() + event.stopPropagation() + if (!suspendRef.current) droneSlowKeyRef.current = true } else if (event.code === 'Escape') { event.preventDefault() event.stopPropagation() @@ -1336,6 +1347,9 @@ export const FirstPersonControls = () => { if (event.code === 'KeyE' && !suspendRef.current) { droneAscendKeyRef.current = false } + if ((event.code === 'AltLeft' || event.code === 'AltRight') && !suspendRef.current) { + droneSlowKeyRef.current = false + } applyMovementKey(event, false) } @@ -1344,6 +1358,7 @@ export const FirstPersonControls = () => { crouchKeyRef.current = false droneAscendKeyRef.current = false droneDescendKeyRef.current = false + droneSlowKeyRef.current = false } } @@ -1617,7 +1632,14 @@ export const FirstPersonControls = () => { if (droneDesiredVelocity.lengthSq() > 0) { droneDesiredVelocity .normalize() - .multiplyScalar(DRONE_SPEED * (movement.run ? DRONE_RUN_MULTIPLIER : 1)) + .multiplyScalar( + DRONE_SPEED * + (droneSlowKeyRef.current + ? DRONE_SLOW_MULTIPLIER + : movement.run + ? DRONE_RUN_MULTIPLIER + : 1), + ) } droneVelocityRef.current.lerp(droneDesiredVelocity, 1 - Math.exp(-step * DRONE_SMOOTHING)) diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index f97b8d98f..226df5742 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -159,6 +159,7 @@ const CAMERA_NAV_HINTS: Record()( captureFovBaseline: null, setCaptureFov: (fov) => set({ - captureFov: Math.min(Math.max(Math.round(fov), CAPTURE_FOV_MIN), CAPTURE_FOV_MAX), + captureFov: clampCaptureFov(fov), }), - armCaptureFov: (fov) => - set( - fov === null - ? { captureFov: null, captureFovBaseline: null } - : { captureFov: fov, captureFovBaseline: fov }, - ), + armCaptureFov: (fov) => { + const captureFov = fov === null ? null : clampCaptureFov(fov) + set({ captureFov, captureFovBaseline: captureFov }) + }, captureShutterHold: false, setCaptureShutterHold: (hold) => set({ captureShutterHold: hold }), workspaceMode: 'edit' as WorkspaceMode, From 66ca8534b53945877e5b9b6958a8b4ac7a661a97 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:13:06 -0400 Subject: [PATCH 02/27] fix(roof): keep the gable shell base on the wall top The CSG degeneracy guard enforced its 5 cm minimum by lowering the shell base, which for wallHeight-0 room roofs put the gable 4 cm inside the wall and z-fought its faces. Raise the eave instead; mirror the floor in the opening-placement frame and the shed inset panel. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../schema/nodes/roof-segment-walls.test.ts | 9 +++++ .../src/schema/nodes/roof-segment-walls.ts | 6 ++- .../src/systems/roof/roof-system.test.ts | 39 ++++++++++++++++++- .../viewer/src/systems/roof/roof-system.tsx | 9 ++--- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/packages/core/src/schema/nodes/roof-segment-walls.test.ts b/packages/core/src/schema/nodes/roof-segment-walls.test.ts index 61c087f55..8b6004f82 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.test.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.test.ts @@ -23,6 +23,15 @@ function segment(overrides: Partial = {}): RoofSegmentNode { } describe('roof wall face frames', () => { + test('zero-height gable profiles keep their base at zero and raise the eave to five centimeters', () => { + const face = getRoofSegmentWallFace(segment({ wallHeight: 0 }), 'right') + + expect(Math.min(...face.profile.map(([, v]) => v))).toBe(0) + expect(face.profile[2]?.[1]).toBe(0.05) + expect(face.profile[4]?.[1]).toBe(0.05) + expect(face.profile[3]?.[1]).toBeCloseTo(0.05 + 3.05 * Math.tan((40 * Math.PI) / 180)) + }) + test('frame z = 0 lands on the nominal footprint (wall mid-plane)', () => { const seg = segment() // front face, u at the face middle, v = 1, mid-plane. diff --git a/packages/core/src/schema/nodes/roof-segment-walls.ts b/packages/core/src/schema/nodes/roof-segment-walls.ts index d6fa9c5bc..5595a2a8b 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -16,7 +16,9 @@ import { getDutchRoofMetrics, getSegmentSlopeFrame } from './roof-segment' * (`getVol(wallThickness / 2, 0, 0, …)`): the volume is the segment * footprint extended outward by `wallThickness / 2`, which drops the eave * line by `(wallThickness / 2) · tanθ` and raises the ridge by the same - * amount so the apex stays at `wallHeight + activeRh`. + * amount so the apex stays at `wallHeight + activeRh` unless the eave + * hits the CSG minimum. The base stays at 0; the eave is raised to at + * least 0.05 above it to avoid sinking the shell into the supporting wall. */ export type RoofWallFaceId = 'front' | 'back' | 'right' | 'left' @@ -76,7 +78,7 @@ function getWallVolumeFrame(node: SegmentWallInputs): WallVolumeFrame { const autoDrop = (wallThickness / 2) * tanTheta const wV = Math.max(0.01, node.width + wallThickness) const dV = Math.max(0.01, node.depth + wallThickness) - const eaveY = Math.max(0.01, node.wallHeight - autoDrop) + const eaveY = Math.max(0.05, node.wallHeight - autoDrop) let rh = activeRh if (activeRh > 0) { rh = activeRh + autoDrop diff --git a/packages/viewer/src/systems/roof/roof-system.test.ts b/packages/viewer/src/systems/roof/roof-system.test.ts index 910a44550..657de52b6 100644 --- a/packages/viewer/src/systems/roof/roof-system.test.ts +++ b/packages/viewer/src/systems/roof/roof-system.test.ts @@ -3,7 +3,40 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, RoofNode, RoofSegmentNode } from '@pascal-app/core' import * as THREE from 'three' -import { generateRoofSegmentGeometry } from './roof-system' +import { Evaluator, SUBTRACTION } from 'three-bvh-csg' +import { generateRoofSegmentGeometry, getRoofSegmentBrushes } from './roof-system' + +describe('roof system gable geometry', () => { + test('keeps a zero-height gable wall shell exactly on its base', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0, + wallThickness: 0.1, + pitch: 40, + }) + const brushes = getRoofSegmentBrushes(segment) + expect(brushes).not.toBeNull() + if (!brushes) return + + const shell = new Evaluator().evaluate(brushes.wallBrush, brushes.innerBrush, SUBTRACTION) + try { + brushes.wallBrush.geometry.computeBoundingBox() + expect(brushes.wallBrush.geometry.boundingBox!.min.y).toBe(0) + expect(shell.geometry.getAttribute('position').count).toBeGreaterThan(0) + shell.geometry.computeBoundingBox() + expect(shell.geometry.boundingBox!.min.y).toBeCloseTo(0, 12) + } finally { + shell.geometry.dispose() + brushes.wallBrush.geometry.dispose() + brushes.innerBrush.geometry.dispose() + brushes.deckSlab.geometry.dispose() + brushes.shinSlab.geometry.dispose() + brushes.rakeBoards?.dispose() + } + }) +}) describe('roof system shed geometry', () => { function inspectShedGeometry(segment: RoofSegmentNode) { @@ -179,9 +212,11 @@ describe('roof system shed geometry', () => { shedInsetEndPanels: true, wallShell: 'omit', }) - const { geometry, roofSideX, sideInfillNormals, sideInfillX } = inspectShedGeometry(segment) + const { geometry, roofSideX, sideInfillNormals, sideInfillX, wallVertexYs } = + inspectShedGeometry(segment) expect(sideInfillNormals).toHaveLength(2) + expect(Math.min(...wallVertexYs)).toBeCloseTo(0.05, 5) expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeCloseTo(infillHalfWidth, 5) expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeGreaterThan(span / 2) expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeLessThan(span / 2 + leftOverhang) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 3c4b966c0..a9a26324c 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1380,7 +1380,8 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe const dV = Math.max(0.01, depth + 2 * wExt) const autoDrop = wExt * tanTheta - const whV = Math.max(0.01, wallHeight - autoDrop + vOffset) + // Raise the top for CSG safety; sinking the base overlaps the supporting wall. + const whV = Math.max(baseY + 0.05, wallHeight - autoDrop + vOffset) let rhV = activeRh if (activeRh > 0) { @@ -1388,8 +1389,6 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe if (roofType === 'shed') rhV = activeRh + 2 * autoDrop } - const safeBaseY = Math.min(baseY, whV - 0.05) - let structuralI = baseI if (isVoid) { structuralI += deckThickness @@ -1401,7 +1400,7 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe d: dV, wh: whV, rh: rhV, - baseY: safeBaseY, + baseY, insets: { dutchI: structuralI }, baseW: width, baseD: depth, @@ -3589,7 +3588,7 @@ function createShedInsetEndPanelGeometry(node: RoofSegmentNode): THREE.BufferGeo }) const wallOuterOffset = node.wallThickness / 2 const autoDrop = wallOuterOffset * tanTheta - const wh = Math.max(0.01, node.wallHeight - autoDrop) + const wh = Math.max(0.05, node.wallHeight - autoDrop) const rh = activeRh > 0 ? activeRh + 2 * autoDrop : activeRh const faces = getRoofModuleFaces({ From bd7aa7264405ab5f62710456988544024f4698bb Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:13:23 -0400 Subject: [PATCH 03/27] feat(editor): Cmd/Ctrl+S saves instead of opening the browser dialog Capture-phase, always-on listener so the page-save dialog never appears. Hosts can take the chord over via onSaveShortcut; the default flushes the autosave through the existing executeSave path. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../editor/src/components/editor/index.tsx | 11 ++++++- .../keyboard-shortcuts-dialog.tsx | 1 + packages/editor/src/hooks/use-auto-save.ts | 26 +++++++++++++-- .../editor/src/hooks/use-save-shortcut.ts | 33 +++++++++++++++++++ packages/nodes/src/block/selection.tsx | 2 +- 5 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 packages/editor/src/hooks/use-save-shortcut.ts diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d6544ee25..7c964c26e 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -34,6 +34,7 @@ import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' +import { useSaveShortcut } from '../../hooks/use-save-shortcut' import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint' import { applySceneGraphToEditor, @@ -194,6 +195,11 @@ export interface EditorProps { // Persistence — defaults to localStorage when omitted onLoad?: () => Promise onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise + /** + * Cmd/Ctrl+S. Defaults to flushing the autosave; hosts with a richer save + * (the community version checkpoint) take the chord over. + */ + onSaveShortcut?: () => void onDirty?: () => void onSaveStatusChange?: (status: SaveStatus) => void @@ -1229,6 +1235,7 @@ function EditorContent({ projectId, onLoad, onSave, + onSaveShortcut, onDirty, onSaveStatusChange, previewScene, @@ -1249,13 +1256,15 @@ function EditorContent({ useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode }) - const { isLoadingSceneRef } = useAutoSave({ + const { isLoadingSceneRef, saveNow } = useAutoSave({ onSave, onDirty, onSaveStatusChange, isVersionPreviewMode, }) + useSaveShortcut(onSaveShortcut ?? saveNow) + const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) // A failed `onLoad` is shown as an error with a retry, never as an empty diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index b81d279ff..ccc4b031a 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -59,6 +59,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { keys: ['Delete / Backspace'], action: 'Delete selected objects' }, { keys: ['Cmd/Ctrl', 'Z'], action: 'Undo' }, { keys: ['Cmd/Ctrl', 'Shift', 'Z'], action: 'Redo' }, + { keys: ['Cmd/Ctrl', 'S'], action: 'Save' }, ], }, { diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts index a64e6a047..adf1d3e8d 100644 --- a/packages/editor/src/hooks/use-auto-save.ts +++ b/packages/editor/src/hooks/use-auto-save.ts @@ -94,7 +94,10 @@ export function useAutoSave({ onDirty, onSaveStatusChange, isVersionPreviewMode = false, -}: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject } { +}: UseAutoSaveOptions): { + isLoadingSceneRef: MutableRefObject + saveNow: () => void +} { const saveTimeoutRef = useRef(undefined) const isSavingRef = useRef(false) // Starts TRUE: the scene is "loading" from mount until the Editor's load @@ -339,5 +342,24 @@ export function useAutoSave({ setSaveStatus('saved') }, [isVersionPreviewMode, setSaveStatus]) - return { isLoadingSceneRef } + // Imperative flush for the save shortcut: drop the debounce and write now, + // through the same `executeSave` so the wipe guard and status callbacks stay + // in the loop. A write already in flight only arms the follow-up. + const saveNow = useCallback(() => { + if (isLoadingSceneRef.current) return + + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + saveTimeoutRef.current = undefined + } + + if (isSavingRef.current) { + pendingSaveRef.current = true + return + } + + executeSaveRef.current?.() + }, []) + + return { isLoadingSceneRef, saveNow } } diff --git a/packages/editor/src/hooks/use-save-shortcut.ts b/packages/editor/src/hooks/use-save-shortcut.ts new file mode 100644 index 000000000..ad738c52e --- /dev/null +++ b/packages/editor/src/hooks/use-save-shortcut.ts @@ -0,0 +1,33 @@ +'use client' + +import { useEffect, useRef } from 'react' + +/** + * Claims Cmd/Ctrl+S for the app's save. + * + * Capture phase and ungated on purpose: the browser's "Save page" dialog must + * never appear anywhere in the editor — including first-person, studio mode and + * while focus sits in an input, where people still expect the chord to save. + * `e.code` keeps it on the physical S key across keyboard layouts. + */ +export function useSaveShortcut(onSave: () => void) { + const onSaveRef = useRef(onSave) + + useEffect(() => { + onSaveRef.current = onSave + }, [onSave]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return + if (e.code !== 'KeyS') return + + e.preventDefault() + e.stopPropagation() + onSaveRef.current() + } + + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, []) +} diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index 87d420ebf..7d326ce0b 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -3453,7 +3453,7 @@ function BlockEditor({ } else if (actions.hasSelection) { actions.beginKeyboardTransformModal('rotate') } - } else if (key === 's') { + } else if (key === 's' && !(event.ctrlKey || event.metaKey)) { if (actions.hasSelection) { if (!actions.beginUniformScaleModal()) { playBlockSfx('tool-select') From d9c709e5a9581ee9b81f20abbc97d6980cf087db Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:17:21 -0400 Subject: [PATCH 04/27] fix(tools): anchor composite presets at their footprint centre, lift previews to the level Fresh (absolute) placement mapped the cursor to the node origin, so a cabinet run landed |bounds.center| away from the pointer. Subtract the rotated centre and keep it under the cursor across R/T. The registry mover's box/sphere now ride the target level's stacked Y like the other placement tools. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../registry/move-registry-node-tool.tsx | 103 ++++++++++----- .../src/lib/planar-cursor-placement.test.ts | 118 ++++++++++++++++++ .../editor/src/lib/planar-cursor-placement.ts | 28 ++++- 3 files changed, 219 insertions(+), 30 deletions(-) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 186820870..7b62a4473 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -11,6 +11,7 @@ import { collectAlignmentAnchors, createSceneApi, emitter, + findLevelAncestorId, footprintAABBFrom, type GridEvent, type GroupMoveSnapResult, @@ -33,12 +34,16 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useThree } from '@react-three/fiber' +import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { Group } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement' import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' -import { resolvePrioritizedPlanarCursorPosition } from '../../../lib/planar-cursor-placement' +import { + offsetPlanPositionByLocalCenter, + resolvePrioritizedPlanarCursorPosition, +} from '../../../lib/planar-cursor-placement' import { resolveAttachmentPreviewRotation } from '../../../lib/rigid-plan-svg-transform' import { movementSfxStepKey } from '../../../lib/sfx/movement-tick' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -95,20 +100,6 @@ type DragBoundsOverride = { centerY?: number } -function offsetPlanPositionByLocalCenter( - position: [number, number, number], - center: [number, number, number], - rotationY: number, -): [number, number, number] { - const cos = Math.cos(rotationY) - const sin = Math.sin(rotationY) - return [ - position[0] + center[0] * cos + center[2] * sin, - position[1] + center[1], - position[2] - center[0] * sin + center[2] * cos, - ] -} - /** * Alignment anchors for the moving node. When the kind declares * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet @@ -233,6 +224,20 @@ const ALIGNMENT_THRESHOLD_M = 0.08 type ClickTriggerEvent = GridEvent | NodeEvent export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { + const previewGroupRef = useRef(null) + useFrame(() => { + if (!previewGroupRef.current) return + const nodes = useScene.getState().nodes + const parentId = nodes[node.id]?.parentId ?? node.parentId + const parent = parentId ? nodes[parentId as AnyNodeId] : undefined + // Building-parented kinds already preview in the tool group's frame. + const levelId = + (parentId ? findLevelAncestorId(parentId as AnyNodeId, nodes) : null) ?? + (parent?.type === 'building' ? null : useViewer.getState().selection.levelId) + previewGroupRef.current.position.y = levelId + ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0) + : 0 + }) // Live camera ref — the pointer-surface cap reconstructs the cursor world // ray (camera → grid hit) to find which walking surface is aimed at. const camera = useThree((s) => s.camera) @@ -702,12 +707,23 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const magnetic = isMagneticSnapActive() const attachmentEnabled = magnetic || isGridSnapActive() + const absolute = useAbsoluteCursorPlacement || cursorAttached + const centerOffset: [number, number, number] = + absolute && dragBounds?.center + ? offsetPlanPositionByLocalCenter( + [0, 0, 0], + dragBounds.center, + previewRotationY(freeRotationRef.current), + ) + : [0, 0, 0] let attachmentRotationY: number | null = null const resolved = resolvePrioritizedPlanarCursorPosition({ cursor: [rawX, rawZ], original: [originalPlanPosition[0], originalPlanPosition[2]], anchor: dragAnchorRef.current, - mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', + mode: absolute ? 'absolute' : 'relative', + localCenter: dragBounds?.center, + rotationY: previewRotationY(freeRotationRef.current), // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only. snap: gridSnapPositionConfig ? undefined : snapToGridStep, snapPoint: @@ -715,7 +731,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ? ([planX, planZ]) => { const snappedPosition = gridSnapPositionConfig({ node, - candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ), + // Kind-owned grid hooks exchange origins and apply their own footprint offsets. + candidatePosition: canonicalPositionFromPlan( + planX - centerOffset[0], + originalPosition[1], + planZ - centerOffset[2], + ), candidateRotation: freeRotationRef.current, movingIds: [node.id as AnyNodeId], nodes: useScene.getState().nodes as Record, @@ -729,7 +750,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { snappedPosition, freeRotationRef.current, ) - return [snappedPlanPosition[0], snappedPlanPosition[2]] + return [ + snappedPlanPosition[0] + centerOffset[0], + snappedPlanPosition[2] + centerOffset[2], + ] } : undefined, resolveAttachment: @@ -738,7 +762,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const snapArgs: Parameters>[0] = { node, candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ), - candidateRotation: rotationRef.current, + candidateRotation: + absolute && dragBounds?.center ? freeRotationRef.current : rotationRef.current, movingIds: [node.id as AnyNodeId], nodes: useScene.getState().nodes as Record, levelId: @@ -1119,10 +1144,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ) if (nextFreeRotation === null) return sfxEmitter.emit('sfx:item-rotate') + let position = lastCursorRef.current + if ( + hasMovedRef.current && + (useAbsoluteCursorPlacement || cursorAttached) && + dragBounds?.center + ) { + const planCenter = offsetPlanPositionByLocalCenter( + getVisualPosition(position), + dragBounds.center, + previewRotationY(rotationRef.current), + ) + const planOrigin = offsetPlanPositionByLocalCenter( + planCenter, + [-dragBounds.center[0], 0, -dragBounds.center[2]], + previewRotationY(nextFreeRotation), + ) + position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2]) + lastCursorRef.current = position + } freeRotationRef.current = nextFreeRotation rotationRef.current = freeRotationRef.current setCursorRotationY(previewRotationY(rotationRef.current)) - const position = lastCursorRef.current const visualPosition = getVisualPosition(position) setCursorPosition(visualPosition) applyMeshPose(position) @@ -1255,12 +1298,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (boxDimensions && !dragBounds?.center) { return ( - + + + ) } @@ -1270,7 +1315,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { : cursorPosition return ( - <> + - + ) } diff --git a/packages/editor/src/lib/planar-cursor-placement.test.ts b/packages/editor/src/lib/planar-cursor-placement.test.ts index f49c53398..8089ff77c 100644 --- a/packages/editor/src/lib/planar-cursor-placement.test.ts +++ b/packages/editor/src/lib/planar-cursor-placement.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { + offsetPlanPositionByLocalCenter, resolvePlanarCursorPosition, resolvePrioritizedPlanarCursorPosition, } from './planar-cursor-placement' @@ -7,6 +8,83 @@ import { const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5 describe('resolvePlanarCursorPosition', () => { + test('absolute mode puts the unrotated footprint centre at the snapped cursor', () => { + const result = resolvePlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter: [1.3, 0.5, 0.2], + snap: snapHalf, + }) + + expect(result.point).toEqual([2.7, 2.3]) + expect(result.anchor).toBeNull() + }) + + test('absolute mode snaps the centre before deriving the rotated origin', () => { + const proposals: [number, number][] = [] + const localCenter: [number, number, number] = [1.3, 0.5, 0.2] + const result = resolvePlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter, + rotationY: Math.PI / 2, + snapPoint: (point) => { + proposals.push(point) + return [snapHalf(point[0]), snapHalf(point[1])] + }, + }) + + expect(proposals).toEqual([[4.24, 2.26]]) + expect(result.point[0]).toBeCloseTo(3.8) + expect(result.point[1]).toBeCloseTo(3.8) + const centre = offsetPlanPositionByLocalCenter( + [result.point[0], 0, result.point[1]], + localCenter, + Math.PI / 2, + ) + expect(centre[0]).toBeCloseTo(4) + expect(centre[2]).toBeCloseTo(2.5) + }) + + test('absolute mode follows the unsnapped cursor at an oblique rotation', () => { + const localCenter: [number, number, number] = [1.5, 0.5, -0.3] + const result = resolvePlanarCursorPosition({ + cursor: [-2.13, 6.27], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter, + rotationY: -Math.PI / 4, + }) + const centre = offsetPlanPositionByLocalCenter( + [result.point[0], 0, result.point[1]], + localCenter, + -Math.PI / 4, + ) + + expect(centre[0]).toBeCloseTo(-2.13) + expect(centre[2]).toBeCloseTo(6.27) + }) + + test('relative mode ignores the footprint centre and rotation', () => { + const result = resolvePlanarCursorPosition({ + cursor: [4.9, 5.2], + original: [10, 20], + anchor: [4.1, 6.1], + mode: 'relative', + localCenter: [1.3, 0.5, 0.2], + rotationY: Math.PI / 2, + snap: snapHalf, + }) + + expect(result.point).toEqual([11, 19]) + expect(result.anchor).toEqual([4.1, 6.1]) + }) + test('absolute mode places the point directly at the snapped cursor', () => { const result = resolvePlanarCursorPosition({ cursor: [1.24, -2.26], @@ -103,6 +181,46 @@ describe('resolvePlanarCursorPosition', () => { }) describe('resolvePrioritizedPlanarCursorPosition', () => { + test('attachment receives the corrected raw origin and returns the final origin', () => { + const proposals: [number, number][] = [] + const result = resolvePrioritizedPlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter: [1.3, 0.5, 0.2], + rotationY: Math.PI / 2, + snapPoint: () => { + throw new Error('Grid snapping must not run after attachment') + }, + resolveAttachment: (proposal) => { + proposals.push(proposal) + return [3, 5] + }, + }) + + expect(proposals).toHaveLength(1) + expect(proposals[0]![0]).toBeCloseTo(4.04) + expect(proposals[0]![1]).toBeCloseTo(3.56) + expect(result.point).toEqual([3, 5]) + expect(result.attachmentSnapped).toBe(true) + }) + + test('snaps the footprint centre when attachment declines the corrected origin', () => { + const result = resolvePrioritizedPlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter: [1.3, 0.5, 0.2], + snapPoint: ([x, z]) => [snapHalf(x), snapHalf(z)], + resolveAttachment: () => null, + }) + + expect(result.point).toEqual([2.7, 2.3]) + expect(result.attachmentSnapped).toBe(false) + }) + test('wall attachment receives the raw proposal and wins over grid snapping', () => { const attachmentProposals: [number, number][] = [] const result = resolvePrioritizedPlanarCursorPosition({ diff --git a/packages/editor/src/lib/planar-cursor-placement.ts b/packages/editor/src/lib/planar-cursor-placement.ts index 2bd61203f..df1a077aa 100644 --- a/packages/editor/src/lib/planar-cursor-placement.ts +++ b/packages/editor/src/lib/planar-cursor-placement.ts @@ -7,6 +7,8 @@ type ResolvePlanarCursorPositionArgs = { original: PlanarPoint anchor: PlanarPoint | null mode: PlanarCursorPlacementMode + localCenter?: [number, number, number] + rotationY?: number snap?: (value: number) => number snapPoint?: (point: PlanarPoint) => PlanarPoint } @@ -26,18 +28,42 @@ type ResolvePrioritizedPlanarCursorPositionResult = ResolvePlanarCursorPositionR const identity = (value: number) => value +export function offsetPlanPositionByLocalCenter( + position: [number, number, number], + center: [number, number, number], + rotationY: number, +): [number, number, number] { + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + return [ + position[0] + center[0] * cos + center[2] * sin, + position[1] + center[1], + position[2] - center[0] * sin + center[2] * cos, + ] +} + export function resolvePlanarCursorPosition({ cursor, original, anchor, mode, + localCenter, + rotationY = 0, snap = identity, snapPoint, }: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult { if (mode === 'absolute') { const proposal: PlanarPoint = [cursor[0], cursor[1]] + const snapped: PlanarPoint = snapPoint?.(proposal) ?? [snap(cursor[0]), snap(cursor[1])] + const origin: [number, number, number] = localCenter + ? offsetPlanPositionByLocalCenter( + [snapped[0], 0, snapped[1]], + [-localCenter[0], 0, -localCenter[2]], + rotationY, + ) + : [snapped[0], 0, snapped[1]] return { - point: snapPoint?.(proposal) ?? [snap(cursor[0]), snap(cursor[1])], + point: [origin[0], origin[2]], anchor, } } From c4a15351152be56a2ec67a41e92a87886428f0f0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:28:47 -0400 Subject: [PATCH 05/27] fix(stair): follow the storey height from the elected base Level-destination stairs returned the full floor-to-floor height even when a slab lifted their base, so the top overshot the storey plane. The resolver now subtracts the elected base for both destinations. The panel exposes Follows storey / Custom rise for level stairs, and the stair tool and landing toggle seed from the storey instead of a 2.5 m constant. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../core/src/systems/stair/stair-rise.test.ts | 141 +++++++++++++++--- packages/core/src/systems/stair/stair-rise.ts | 42 +++--- .../src/components/tools/stair/stair-tool.tsx | 25 ++-- packages/nodes/src/stair-segment/panel.tsx | 15 +- packages/nodes/src/stair/panel.tsx | 68 ++++----- wiki/architecture/vertical-model.md | 2 +- 6 files changed, 207 insertions(+), 86 deletions(-) diff --git a/packages/core/src/systems/stair/stair-rise.test.ts b/packages/core/src/systems/stair/stair-rise.test.ts index 8a2e78e7a..d4939b81e 100644 --- a/packages/core/src/systems/stair/stair-rise.test.ts +++ b/packages/core/src/systems/stair/stair-rise.test.ts @@ -95,6 +95,27 @@ function buildDeckScene(options: { return { deck, stair, nodes } } +function registerStairFootprint() { + registerNode({ + kind: 'stair', + schemaVersion: 1, + schema: z.object({ type: z.literal('stair') }) as never, + category: 'structure', + defaults: () => ({}) as never, + capabilities: { + floorPlaced: { + footprints: (node) => [ + { + position: (node as StairNodeType).position, + dimensions: [1, 1, 2] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }, + ], + }, + }, + } as AnyNodeDefinition) +} + function buildLevelSceneWithSegments(options: { levelHeight: number totalRise?: number @@ -349,24 +370,7 @@ describe('deck-attached rise with a floor-lifted base', () => { ] beforeEach(() => { - registerNode({ - kind: 'stair', - schemaVersion: 1, - schema: z.object({ type: z.literal('stair') }) as never, - category: 'structure', - defaults: () => ({}) as never, - capabilities: { - floorPlaced: { - footprints: (node) => [ - { - position: (node as StairNodeType).position, - dimensions: [1, 1, 2] as [number, number, number], - rotation: [0, 0, 0] as [number, number, number], - }, - ], - }, - }, - } as AnyNodeDefinition) + registerStairFootprint() }) function makeFloorSlab(elevation: number) { @@ -497,3 +501,104 @@ describe('deck-attached rise with a floor-lifted base', () => { expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(1.25) }) }) + +// A level-destination stair climbs to the storey plane above, which is an +// absolute level-local height — so a slab that lifts the stair's own base eats +// into the rise. Without the subtraction the last step overshoots the floor +// above by the slab's thickness (and a tall storey used to be missed entirely). +describe('level rise with a floor-lifted base', () => { + const FLOOR_POLYGON: Array<[number, number]> = [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], + ] + + beforeEach(() => { + registerStairFootprint() + }) + + function buildLiftedLevelScene(options: { + levelHeight: number + floorElevation?: number | null + totalRise?: number + segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> + }) { + const scene = buildLevelSceneWithSegments({ + levelHeight: options.levelHeight, + totalRise: options.totalRise, + segments: options.segments ?? [], + }) + if (options.floorElevation == null) return { ...scene, floor: null } + + const floor = SlabNode.parse({ + id: 'slab_floor', + type: 'slab', + polygon: FLOOR_POLYGON, + elevation: options.floorElevation, + thickness: 0.05, + }) + spatialGridManager.handleNodeCreated(floor as AnyNode, 'level_1') + return { + ...scene, + floor, + nodes: { ...scene.nodes, [floor.id]: floor } as Record, + } + } + + it('lands the last step on the storey plane: rise = floor-to-floor − elected base', () => { + const { stair, nodes } = buildLiftedLevelScene({ levelHeight: 5.3, floorElevation: 0.05 }) + const base = getFloorPlacedElevation({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: 'level_1', + }) + expect(base).toBeCloseTo(0.05) + const rise = resolveStairTotalRise(stair, nodes) + expect(rise).toBeCloseTo(5.25) + expect(base + rise).toBeCloseTo(5.3) + }) + + it('keeps the full storey height when the stair stands on bare ground', () => { + const { stair, nodes } = buildLiftedLevelScene({ levelHeight: 5.3, floorElevation: null }) + expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(5.3) + }) + + it('lets an explicit totalRise win over the base-adjusted storey rise', () => { + const { stair, nodes } = buildLiftedLevelScene({ + levelHeight: 5.3, + floorElevation: 0.05, + totalRise: 2.7, + }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.7) + }) + + it('converges a straight flight to the base-adjusted storey rise', () => { + const { nodes } = buildLiftedLevelScene({ + levelHeight: 5.3, + floorElevation: 0.05, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.5 }], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect(updates[0]?.id).toBe('sseg_1' as never) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(5.25) + }) + + it('re-converges after the base slab elevation changes', () => { + const scene = buildLiftedLevelScene({ + levelHeight: 2.5, + floorElevation: 0.05, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.45 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const movedFloor = { ...scene.floor, elevation: 0.3 } + const nodes = { ...scene.nodes, slab_floor: movedFloor as AnyNode } + spatialGridManager.handleNodeUpdated(movedFloor as AnyNode, 'level_1') + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(2.2) + }) +}) diff --git a/packages/core/src/systems/stair/stair-rise.ts b/packages/core/src/systems/stair/stair-rise.ts index f67a07755..3e62daf6b 100644 --- a/packages/core/src/systems/stair/stair-rise.ts +++ b/packages/core/src/systems/stair/stair-rise.ts @@ -10,32 +10,30 @@ export function resolveStairTotalRise(stair: StairNode, nodes: Record node.type === 'level' && node.children.includes(stair.id), ) + // Both destinations are absolute level-local heights, while the stair's own + // base may be lifted onto a floor slab by the floor-stack + // (`FloorElevationSystem` / `syncStairGroupElevation` put the group at + // `position[1] + elected slab elevation`). The rise is measured from that + // base, so subtract it — electing the base exactly the way the visual + // systems do (persisted `supportSlabId` honored, uncapped election + // otherwise) keeps base + rise landing precisely on the destination surface. + const baseElevation = getFloorStackedPosition({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: level?.id ?? null, + })[1] + if (stair.deckSlabId) { + // The deck's `elevation` IS its walking surface (level-local). A stale + // reference (deck gone) falls through to the level-derived rise. const deck = nodes[stair.deckSlabId] - // The deck's `elevation` IS its walking surface (level-local), but the - // stair's own base may be lifted onto a floor slab by the floor-stack - // (`FloorElevationSystem` / `syncStairGroupElevation` put the group at - // `position[1] + elected slab elevation`). The rise is measured from - // that base, so subtract it — electing the base exactly the way the - // visual systems do (persisted `supportSlabId` honored, uncapped - // election otherwise) keeps base + rise landing precisely on the deck's - // walking surface. A stale reference (deck gone) falls through to the - // level-derived rise. - if (deck?.type === 'slab') { - const baseElevation = getFloorStackedPosition({ - node: stair, - nodes, - position: stair.position, - rotation: stair.rotation, - levelId: level?.id ?? null, - })[1] - return (deck.elevation ?? 0.05) - baseElevation - } + if (deck?.type === 'slab') return (deck.elevation ?? 0.05) - baseElevation } - return level?.type === 'level' - ? getLevelFloorToFloorHeight(level.id, nodes as Record) - : DEFAULT_LEVEL_HEIGHT + if (level?.type !== 'level') return DEFAULT_LEVEL_HEIGHT + return getLevelFloorToFloorHeight(level.id, nodes as Record) - baseElevation } const RISE_SYNC_EPSILON = 1e-4 diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index a07de846c..9d68d491f 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -2,8 +2,10 @@ import { type AnyNode, collectAlignmentAnchors, createSurfaceOpeningPreviewController, + DEFAULT_LEVEL_HEIGHT, emitter, type GridEvent, + getLevelFloorToFloorHeight, type LevelNode, movingAlignmentAnchors, type NodeEvent, @@ -50,7 +52,6 @@ import { DEFAULT_SPIRAL_TOP_LANDING_MODE, DEFAULT_STAIR_ATTACHMENT_SIDE, DEFAULT_STAIR_FILL_TO_FLOOR, - DEFAULT_STAIR_HEIGHT, DEFAULT_STAIR_LENGTH, DEFAULT_STAIR_OPENING_OFFSET, DEFAULT_STAIR_RAILING_HEIGHT, @@ -71,8 +72,8 @@ type MoveTriggerEvent = GridEvent | NodeEvent * Generates the step-profile geometry for the ghost preview. * Same algorithm as StairSystem's generateStairSegmentGeometry. */ -function createStairPreviewGeometry(): THREE.BufferGeometry { - const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT +function createStairPreviewGeometry(rise: number): THREE.BufferGeometry { + const riserHeight = rise / DEFAULT_STAIR_STEP_COUNT const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT const shape = new THREE.Shape() @@ -103,14 +104,16 @@ function createStairPreviewGeometry(): THREE.BufferGeometry { } /** - * Creates a default straight stair segment. + * Creates a default straight stair segment climbing `rise` — the storey it is + * dropped on, not a constant: the placed stair has no explicit `totalRise`, so + * this is the height `syncStairRises` immediately converges it to anyway. */ -function createDefaultStairSegment() { +function createDefaultStairSegment(rise: number) { return StairSegmentNode.parse({ segmentType: 'stair', width: DEFAULT_STAIR_WIDTH, length: DEFAULT_STAIR_LENGTH, - height: DEFAULT_STAIR_HEIGHT, + height: rise, stepCount: DEFAULT_STAIR_STEP_COUNT, attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE, fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, @@ -178,7 +181,7 @@ function commitStairPlacement( const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length const name = `Staircase ${stairCount + 1}` - const segment = createDefaultStairSegment() + const segment = createDefaultStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) const destinationPlan = resolveStairDestinationLevel({ createMissing: true, @@ -248,7 +251,11 @@ export const StairTool: React.FC = () => { const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null) const currentLevelId = useViewer((state) => state.selection.levelId) - const previewGeometry = useMemo(() => createStairPreviewGeometry(), []) + const previewRise = useScene((state) => + currentLevelId ? getLevelFloorToFloorHeight(currentLevelId, state.nodes) : DEFAULT_LEVEL_HEIGHT, + ) + const previewGeometry = useMemo(() => createStairPreviewGeometry(previewRise), [previewRise]) + useEffect(() => () => previewGeometry.dispose(), [previewGeometry]) useEffect(() => { if (!currentLevelId) return @@ -280,7 +287,7 @@ export const StairTool: React.FC = () => { nodes, }) const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId - const segment = createDefaultStairSegment() + const segment = createDefaultStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) const stair = createDefaultStairNode({ name: 'Staircase Preview', levelId: placementLevelId, diff --git a/packages/nodes/src/stair-segment/panel.tsx b/packages/nodes/src/stair-segment/panel.tsx index daad0fa4b..698216d33 100644 --- a/packages/nodes/src/stair-segment/panel.tsx +++ b/packages/nodes/src/stair-segment/panel.tsx @@ -4,6 +4,8 @@ import { type AnyNode, type AnyNodeId, type AttachmentSide, + DEFAULT_LEVEL_HEIGHT, + resolveStairTotalRise, type StairSegmentNode, StairSegmentNode as StairSegmentNodeSchema, type StairSegmentType, @@ -67,6 +69,17 @@ export default function StairSegmentPanel() { setSelection({ selectedIds: [] }) }, [setSelection]) + // Turning a landing back into a flight seeds the rise the parent stair + // resolves — a fixed 2.5 m stops halfway up a tall storey, and for a + // follows-mode stair it is what `syncStairRises` would converge to anyway. + const resolveParentStairRise = useCallback(() => { + const sceneNodes = useScene.getState().nodes + const parent = node?.parentId ? sceneNodes[node.parentId as AnyNodeId] : undefined + return parent?.type === 'stair' + ? resolveStairTotalRise(parent, sceneNodes) + : DEFAULT_LEVEL_HEIGHT + }, [node]) + const handleBack = useCallback(() => { if (node?.parentId) { setSelection({ selectedIds: [node.parentId] }) @@ -136,7 +149,7 @@ export default function StairSegmentPanel() { updates.stepCount = 0 updates.length = 1.0 } else { - updates.height = 2.5 + updates.height = resolveParentStairRise() updates.stepCount = 10 updates.length = 3.0 } diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 9500bbce8..e6f135065 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -331,41 +331,39 @@ export default function StairPanel() { - {attachedDeck ? ( -
-
- Rise +
+
+ Rise +
+ + handleUpdate( + value === 'custom' ? { totalRise: resolvedRise } : { totalRise: undefined }, + ) + } + options={[ + { label: attachedDeck ? 'Follows deck' : 'Follows storey', value: 'follows' }, + { label: 'Custom rise', value: 'custom' }, + ]} + value={node.totalRise == null ? 'follows' : 'custom'} + /> + {node.totalRise == null ? ( +
+ Currently {resolvedRise} m
- - handleUpdate( - value === 'custom' ? { totalRise: resolvedRise } : { totalRise: undefined }, - ) - } - options={[ - { label: 'Follows deck', value: 'follows' }, - { label: 'Custom rise', value: 'custom' }, - ]} - value={node.totalRise == null ? 'follows' : 'custom'} + ) : ( + handleUpdate({ totalRise: value })} + precision={2} + step={0.05} + unit="m" + value={resolvedRise} /> - {node.totalRise == null ? ( -
- Currently {resolvedRise} m -
- ) : ( - handleUpdate({ totalRise: value })} - precision={2} - step={0.05} - unit="m" - value={resolvedRise} - /> - )} -
- ) : null} + )} +
{attachedDeck ? null : ( <> @@ -463,13 +461,13 @@ export default function StairPanel() { /> handleUpdate({ totalRise: value })} precision={2} step={0.05} unit="m" - value={Math.round(resolveStairTotalRise(node, nodes) * 100) / 100} + value={resolvedRise} /> Date: Wed, 9 Sep 2026 15:30:45 -0400 Subject: [PATCH 06/27] fix(item): drop un-hosted items to the floor, draw the placement box on the right storey The floor-path Y was frozen at drag start (#638), so an item pulled off a shelf kept the shelf height after reparenting. Read the live grid Y instead. The cursor group, grid surface and facing pose now add the level mesh's stacked Y, which the building-local tool group lacks. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../tools/item/placement-strategies.test.ts | 39 ++++++++++- .../tools/item/use-placement-coordinator.tsx | 65 ++++++++++++++++--- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/packages/editor/src/components/tools/item/placement-strategies.test.ts b/packages/editor/src/components/tools/item/placement-strategies.test.ts index b01048dd4..73006ceb0 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.test.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { BlockNode, + type GridEvent, ItemNode, type LevelNode, type NodeEvent, @@ -9,7 +10,7 @@ import { type WallNode, } from '@pascal-app/core' import { BufferGeometry, Mesh, MeshBasicMaterial, type Object3D, Vector3 } from 'three' -import { faceHostStrategy, wallStrategy } from './placement-strategies' +import { faceHostStrategy, floorStrategy, wallStrategy } from './placement-strategies' import type { PlacementContext, SpatialValidators } from './placement-types' import { registerTestBlockFaceHost } from './test-face-host' @@ -455,3 +456,39 @@ describe('wallStrategy.move', () => { expect(result.cursorPosition[1]).toBeCloseTo(0.4 + 0.05, 6) }) }) + +describe('floorStrategy.move', () => { + function makeGridEvent(x: number, y: number, z: number): GridEvent { + return { + position: [x, y, z], + localPosition: [x, y, z], + nativeEvent: {} as GridEvent['nativeEvent'], + } + } + + test('follows the live grid Y so a raised placement keeps its height', () => { + const context = floorItemContext() + context.gridPosition.set(0, 0.9, 0) + + const result = floorStrategy.move(context, makeGridEvent(1.25, 0.9, 2.25)) + if (!result) throw new Error('expected a placement result') + + expect(result.gridPosition[1]).toBe(0.9) + expect(result.cursorPosition[1]).toBe(0.9) + }) + + // `detachItemSurfaceToFloor` zeroes the grid Y when an item is taken off a + // host; the floor path must honour that instead of a Y frozen at drag start, + // or the item commits floating at the shelf's height. + test('drops to the level plane once un-hosting zeroes the grid Y', () => { + const context = floorItemContext() + context.gridPosition.set(0, 0, 0) + + const result = floorStrategy.move(context, makeGridEvent(1.25, 0.9, 2.25)) + if (!result) throw new Error('expected a placement result') + + expect(result.gridPosition[1]).toBe(0) + expect(result.cursorPosition[1]).toBe(0) + expect(result.nodeUpdate?.position?.[1]).toBe(0) + }) +}) diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index ccc793d41..b6a66b393 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -6,6 +6,7 @@ import { type CeilingEvent, collectAlignmentAnchors, emitter, + findLevelAncestorId, type GridEvent, getScaledDimensions, type ItemEvent, @@ -193,6 +194,27 @@ function getGridAlignedPreviewNode(item: ItemNode): ItemNode { } } +/** + * Building-local Y of the storey the floor-path ghost belongs to. + * + * The cursor group is mounted inside ToolManager's building-local group, which + * carries no per-floor elevation, while every floor-path position (grid + * position, `getFloorVisualPosition`) is LEVEL-local — so on an upper storey the + * wireframe and its dimension labels render a floor too low. The wall / ceiling + * / item-surface paths don't need this: they convert a world hit through + * `worldToBuildingLocal`, which already carries the storey. + * + * Read off the level mesh (same source as `LevelOffsetGroup`) rather than the + * stored elevation so the ghost also follows the exploded-view lerp. + */ +function getPlacementLevelY(draft: ItemNode | null | undefined): number { + const levelId = + (draft ? findLevelAncestorId(draft.id, useScene.getState().nodes) : null) ?? + useViewer.getState().selection.levelId + const levelMesh = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : null + return levelMesh ? levelMesh.position.y : 0 +} + // Shared materials for placement cursor - we just change colors, not swap materials // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines const edgeMaterial = new LineBasicNodeMaterial({ @@ -521,7 +543,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!asset.attachTo && placementState.current.surface === 'floor') { gridPosition.current.y = 0 if (cursorGroupRef.current) { - cursorGroupRef.current.position.y = 0 + cursorGroupRef.current.position.y = getPlacementLevelY(draftNode.current) } } @@ -731,7 +753,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Init draft ---- configRef.current.initDraft(gridPosition.current) - const floorAuthoredY = draftNode.current?.position[1] ?? 0 const preserveDragOffset = configRef.current.preserveDragOffset === true // The host the item was grabbed from + its pre-drag host-local position. // Each surface's grab anchor preserves the grab offset only on THAT host, @@ -890,7 +911,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } } else if (cursorGroupRef.current) { + // No registered mesh yet (a just-created draft renders next tick), so + // fall back to the level-local grid position lifted onto its storey. cursorGroupRef.current.position.copy(gridPosition.current) + cursorGroupRef.current.position.y += getPlacementLevelY(draftNode.current) cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0 } } @@ -1064,9 +1088,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea useAlignmentGuides.getState().clear() } + // `result.gridPosition[1]` is the LIVE `gridPosition.current.y` — seeded + // from the draft's authored Y by `initDraft` (so a block-face / raised + // construction-plane item keeps its height) and zeroed by + // `detachItemSurfaceToFloor` / `faceHostStrategy.leave` when the item + // comes back down. Freezing it at drag start instead left an item taken + // off a shelf floating at the shelf's height. let gridPos: [number, number, number] = [ result.gridPosition[0] + alignX, - floorAuthoredY, + result.gridPosition[1], result.gridPosition[2] + alignZ, ] frozenSupportSlabIdRef.current = undefined @@ -1104,7 +1134,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!draft && asset.attachTo) { cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo) } - cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2]) + cursorGroupRef.current.position.set( + cursorPosition[0], + cursorPosition[1] + getPlacementLevelY(draft), + cursorPosition[2], + ) // Floor items only rotate on Y; keep the preview box (and the live // transform the 2D floorplan mirrors) aligned with the draft's // rotation. Without this the box stays at its seed rotation until a @@ -1684,7 +1718,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea levelId ? { parentId: levelId } : undefined, ) if (cursorGroupRef.current) { - cursorGroupRef.current.position.set(...floorVisualPosition) + cursorGroupRef.current.position.set( + floorVisualPosition[0], + floorVisualPosition[1] + getPlacementLevelY(draftNode.current), + floorVisualPosition[2], + ) } const draft = draftNode.current @@ -2271,8 +2309,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draft.position = [x, gridPosition.current.y, z] if (cursorGroupRef.current) { if (surface === 'floor') { + const visual = getFloorVisualPosition([x, gridPosition.current.y, z]) cursorGroupRef.current.position.set( - ...getFloorVisualPosition([x, gridPosition.current.y, z]), + visual[0], + visual[1] + getPlacementLevelY(draft), + visual[2], ) } else { cursorGroupRef.current.position.x = x @@ -2601,6 +2642,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // moving existing node has no draft here, so the grid reads that case straight // off the node's mesh. Cleared when idle. const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) + const surfaceWorldPointRef = useRef(new Vector3()) const facingForwardRef = useRef(new Vector3(0, 0, 1)) const facingQuatRef = useRef(new Quaternion()) const ghostSurfaceQuatRef = useRef(new Quaternion()) @@ -2642,13 +2684,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const fwd = facingForwardRef.current.copy(n) if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z) // The forward triangle is a floor aid; drop it to the building-local floor - // under the hosted plane. - facingY = 0 + // under the hosted plane — the storey's floor, not world ground. + facingY = getPlacementLevelY(draftNode.current) } else { ghost.getWorldQuaternion(ghostSurfaceQuatRef.current) resolveItemPlacementSurfaceNormal(surf, ghostSurfaceQuatRef.current, null, n) } - publishPlacementSurface(ghost.position, n) + // `publishPlacementSurface` is a WORLD-space contract (the grid reads it in + // world space), but the ghost lives in the building-local tool group. + publishPlacementSurface(ghost.getWorldPosition(surfaceWorldPointRef.current), n) if (shape.depth > 0) { useFacingPose.getState().set({ @@ -2716,7 +2760,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.z, ]) mesh.position.y = visualPosition[1] - cursorGroupRef.current.position.y = visualPosition[1] + cursorGroupRef.current.position.y = + visualPosition[1] + getPlacementLevelY(draftNode.current) } } else if (placementState.current.surface === 'block-face') { const rotation = draftNode.current.rotation From 6cd7f3359fbcf6f4f3315820ca8887bf6512658e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:34:26 -0400 Subject: [PATCH 07/27] fix(selection): keep member rotation when pressing R/T mid-drag translateGroupPatches dropped the snapshots' yaw after a mid-gesture rotation, so the layout orbited while every item kept its old facing and the commit wrote the same. Carry rotation for vec3/scalar participants, pivot every session on the shared mesh-box centre the idle shortcut uses, and engage an armed session before rotating. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../editor-2d/floorplan-group-move.tsx | 91 ++++++++++++------- .../src/components/editor/group-actions.ts | 75 ++++++--------- .../src/components/editor/group-move-3d.ts | 61 ++++++++----- .../editor/group-transform-shared.test.ts | 78 ++++++++++++++++ .../editor/group-transform-shared.ts | 81 +++++++++++++++-- 5 files changed, 276 insertions(+), 110 deletions(-) diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index 0a2280b66..74d954cca 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -39,10 +39,13 @@ import { collectParticipants, computeGroupBox, expandToComponent, + type GroupPlanBounds, + groupPlanBounds, levelFrame, - participantExtents, + planBoundsCenter, rotateGroupPatches, rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, type Vec2, } from '../editor/group-transform-shared' @@ -112,6 +115,7 @@ export function startFloorplanGroupMove( affectedIds: AnyNodeId[] candidates: ReturnType restAnchors: ReturnType + restBounds: GroupPlanBounds restCenter: Vec2 lastDelta: Vec2 | null } @@ -140,20 +144,16 @@ export function startFloorplanGroupMove( // The group aligns as one rigid footprint: its bbox corners + center are // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays // mounted under every view mode); plan coords are level-frame, so convert. - const restBox = computeGroupBox(fullIds) const { inverse: frameInv } = levelFrame(levelId) - const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null - const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null - const restAnchors = - boxMin && boxMax - ? bboxCornerAnchors( - 'group-move', - Math.min(boxMin.x, boxMax.x), - Math.min(boxMin.z, boxMax.z), - Math.max(boxMin.x, boxMax.x), - Math.max(boxMin.z, boxMax.z), - ) - : [] + const restBounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv) + if (!restBounds) return null + const restAnchors = bboxCornerAnchors( + 'group-move', + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, + ) for (const id of affectedIds) { useLiveTransforms.getState().clear(id) @@ -169,12 +169,21 @@ export function startFloorplanGroupMove( nodeId, handle: GROUP_MOVE_DRAG_LABEL, }) - // Rotation pivot for mid-drag R/T — the participant DATA extents' center - // (stable across the drag; rotations re-seed around the same point). - const ext = participantExtents(starts) - const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0] + // Rotation pivot for mid-drag R/T — the START footprint's center, the same + // point the 3D body drag, the idle keyboard rotate and the rotate gizmos + // orbit. Stable across the drag; rotations re-seed around the same point. + const restCenter = planBoundsCenter(restBounds) - return { starts, links, affectedIds, candidates, restAnchors, restCenter, lastDelta: null } + return { + starts, + links, + affectedIds, + candidates, + restAnchors, + restBounds, + restCenter, + lastDelta: null, + } } const applyMove = (e: PointerEvent, s: Session) => { @@ -241,18 +250,19 @@ export function startFloorplanGroupMove( // current delta — the carried group turns exactly like the idle keyboard // rotate, and the commit stays a single updateNodes. const rotateSession = (s: Session, direction: 1 | -1) => { - const rotated = rotateGroupSnapshots( - s.starts, - s.links, - { x: s.restCenter[0], z: s.restCenter[1] }, - -direction * (Math.PI / 4), - ) + const pivot = { x: s.restCenter[0], z: s.restCenter[1] } + const delta = -direction * (Math.PI / 4) + const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta) s.starts = rotated.starts s.links = rotated.links - const ext = participantExtents(rotated.starts) - if (ext) { - s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ) - } + s.restBounds = rotatePlanBounds(s.restBounds, pivot, delta) + s.restAnchors = bboxCornerAnchors( + 'group-move', + s.restBounds.minX, + s.restBounds.minZ, + s.restBounds.maxX, + s.restBounds.maxZ, + ) sfxEmitter.emit('sfx:item-rotate') applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0) } @@ -358,7 +368,18 @@ export function startFloorplanGroupMove( const onKeyDown = (e: KeyboardEvent) => { const key = e.key.toLowerCase() if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { - if (!session) return + // Armed but still under the drag threshold: engage first (exactly what + // the next pointer-move would do) so the rotation lands inside this + // session. Falling through to the global idle arm instead would write + // the scene behind snapshots already captured here, and the first + // `applyDelta` would republish them — undoing the rotation. + if (!session) { + session = engage() + if (!session) { + removeListeners() + return + } + } e.preventDefault() e.stopPropagation() rotateSession(session, key === 'r' ? 1 : -1) @@ -415,9 +436,13 @@ export function startFloorplanGroupRotate(event: { const { starts, links } = collectParticipants(fullIds, nodes, levelId) if (starts.length === 0) return false const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] - const ext = participantExtents(starts) - if (!ext) return false - const pivot = { x: (ext.minX + ext.maxX) / 2, z: (ext.minZ + ext.maxZ) / 2 } + const { inverse: frameInv } = levelFrame(levelId) + const bounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv) + if (!bounds) return false + // Same pivot as the dashed box the handles hang off (and as the 3D rotate + // gizmo): its centre, not the anchor points' centre. + const [pivotX, pivotZ] = planBoundsCenter(bounds) + const pivot = { x: pivotX, z: pivotZ } const startPlan = clientToPlan(event.clientX, event.clientY) if (!startPlan) return false // Bearing around the pivot in the plan frame — the same atan2 x→z sense diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index d14e8775a..48ecc0174 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -40,9 +40,11 @@ import { collectParticipants, computeGroupBox, expandToComponent, + groupPlanBounds, levelFrame, - participantExtents, + planBoundsCenter, rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, type Vec2, } from './group-transform-shared' @@ -104,46 +106,14 @@ export function startGroupPickUp( if (starts.length === 0) return false const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] - // Rest bounds in the level frame. Prefer the mounted meshes' world box - // (footprint-accurate), but fall back to the participant DATA when the - // meshes aren't up yet — Duplicate starts the pick-up synchronously after - // `createNodes`, one frame before the clones' renderers mount. const { inverse: frameInv } = levelFrame(levelId) const restBox = computeGroupBox(fullIds) - let minX = Number.POSITIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY - if (restBox) { - const boxMin = restBox.min.clone().applyMatrix4(frameInv) - const boxMax = restBox.max.clone().applyMatrix4(frameInv) - minX = Math.min(boxMin.x, boxMax.x) - minZ = Math.min(boxMin.z, boxMax.z) - maxX = Math.max(boxMin.x, boxMax.x) - maxZ = Math.max(boxMin.z, boxMax.z) - } else { - const reach = (x: number, z: number) => { - minX = Math.min(minX, x) - minZ = Math.min(minZ, z) - maxX = Math.max(maxX, x) - maxZ = Math.max(maxZ, z) - } - for (const s of starts) { - if (s.kind === 'endpoint') { - reach(s.start[0], s.start[1]) - reach(s.end[0], s.end[1]) - } else if (s.kind === 'polygon') { - for (const [x, z] of s.polygon) { - reach(x, z) - } - } else { - reach(s.position[0], s.position[2]) - } - } - } - if (!Number.isFinite(minX)) return false + const startBounds = groupPlanBounds(restBox, starts, frameInv) + if (!startBounds) return false + // Mutable: mid-carry R/T re-seeds the footprint around the same pivot. + let restBounds = startBounds // Rotation pivot for mid-carry R/T; stable across the whole pick-up. - const restCenter: [number, number] = [(minX + maxX) / 2, (minZ + maxZ) / 2] + const restCenter = planBoundsCenter(restBounds) // Ground plane for the 3D surface: the meshes' base when available, floor // level otherwise. Placements live in the level frame, so both surfaces // resolve into it before measuring. @@ -157,7 +127,13 @@ export function startGroupPickUp( if (n && !movingIdSet.has(nid)) staticNodes[nid] = n } const candidates = collectAlignmentAnchors(staticNodes, '', levelId) - let restAnchors = bboxCornerAnchors('group-move', minX, minZ, maxX, maxZ) + let restAnchors = bboxCornerAnchors( + 'group-move', + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, + ) // Cursor → level-frame plan point, whichever surface the pointer is over. const ndc = new Vector2() @@ -274,18 +250,19 @@ export function startGroupPickUp( // the current delta — the carried group turns exactly like the idle // keyboard rotate, and the placement stays a single updateNodes. const rotateCarried = (direction: 1 | -1) => { - const rotated = rotateGroupSnapshots( - starts, - links, - { x: restCenter[0], z: restCenter[1] }, - -direction * (Math.PI / 4), - ) + const pivot = { x: restCenter[0], z: restCenter[1] } + const delta = -direction * (Math.PI / 4) + const rotated = rotateGroupSnapshots(starts, links, pivot, delta) starts = rotated.starts links = rotated.links - const ext = participantExtents(rotated.starts) - if (ext) { - restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ) - } + restBounds = rotatePlanBounds(restBounds, pivot, delta) + restAnchors = bboxCornerAnchors( + 'group-move', + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, + ) sfxEmitter.emit('sfx:item-rotate') applyDelta(lastDelta?.[0] ?? 0, lastDelta?.[1] ?? 0) } diff --git a/packages/editor/src/components/editor/group-move-3d.ts b/packages/editor/src/components/editor/group-move-3d.ts index 967238daf..aeecefbda 100644 --- a/packages/editor/src/components/editor/group-move-3d.ts +++ b/packages/editor/src/components/editor/group-move-3d.ts @@ -32,9 +32,12 @@ import { collectParticipants, computeGroupBox, expandToComponent, + type GroupPlanBounds, + groupPlanBounds, levelFrame, - participantExtents, + planBoundsCenter, rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, type Vec2, } from './group-transform-shared' @@ -88,6 +91,7 @@ export function armGroupMove3d(args: { affectedIds: AnyNodeId[] candidates: ReturnType restAnchors: ReturnType + restBounds: GroupPlanBounds restCenter: Vec2 plane: Plane startLocal: Vector3 @@ -130,14 +134,14 @@ export function armGroupMove3d(args: { if (n && !movingIdSet.has(nid)) staticNodes[nid] = n } const candidates = collectAlignmentAnchors(staticNodes, '', levelId) - const boxMin = restBox.min.clone().applyMatrix4(frameInv) - const boxMax = restBox.max.clone().applyMatrix4(frameInv) + const restBounds = groupPlanBounds(restBox, starts, frameInv) + if (!restBounds) return null const restAnchors = bboxCornerAnchors( 'group-move', - Math.min(boxMin.x, boxMax.x), - Math.min(boxMin.z, boxMax.z), - Math.max(boxMin.x, boxMax.x), - Math.max(boxMin.z, boxMax.z), + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, ) for (const id of affectedIds) { @@ -154,9 +158,11 @@ export function armGroupMove3d(args: { nodeId, handle: GROUP_MOVE_DRAG_LABEL, }) - // Rotation pivot for mid-drag R/T — the participant DATA extents' center. - const ext = participantExtents(starts) - const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0] + // Rotation pivot for mid-drag R/T — the START footprint's center, the same + // point the idle keyboard rotate and the rotate gizmos orbit. Fixed for the + // whole session: the snapshots are start placements, and `applyDelta` adds + // the live drag delta on top of them. + const restCenter = planBoundsCenter(restBounds) return { starts, @@ -164,6 +170,7 @@ export function armGroupMove3d(args: { affectedIds, candidates, restAnchors, + restBounds, restCenter, plane, startLocal, @@ -245,18 +252,19 @@ export function armGroupMove3d(args: { // current delta — the carried group turns exactly like the idle keyboard // rotate, and the commit stays a single updateNodes. const rotateSession = (s: Session, direction: 1 | -1) => { - const rotated = rotateGroupSnapshots( - s.starts, - s.links, - { x: s.restCenter[0], z: s.restCenter[1] }, - -direction * (Math.PI / 4), - ) + const pivot = { x: s.restCenter[0], z: s.restCenter[1] } + const delta = -direction * (Math.PI / 4) + const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta) s.starts = rotated.starts s.links = rotated.links - const ext = participantExtents(rotated.starts) - if (ext) { - s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ) - } + s.restBounds = rotatePlanBounds(s.restBounds, pivot, delta) + s.restAnchors = bboxCornerAnchors( + 'group-move', + s.restBounds.minX, + s.restBounds.minZ, + s.restBounds.maxX, + s.restBounds.maxZ, + ) sfxEmitter.emit('sfx:item-rotate') applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0) } @@ -369,7 +377,18 @@ export function armGroupMove3d(args: { const onKeyDown = (e: KeyboardEvent) => { const key = e.key.toLowerCase() if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { - if (!session) return + // Armed but still under the drag threshold: engage first (exactly what + // the next pointer-move would do) so the rotation lands inside this + // session. Falling through to the global idle arm instead would write + // the scene behind snapshots already captured here, and the first + // `applyDelta` would republish them — undoing the rotation. + if (!session) { + session = engage() + if (!session) { + removeListeners() + return + } + } e.preventDefault() e.stopPropagation() rotateSession(session, key === 'r' ? 1 : -1) diff --git a/packages/editor/src/components/editor/group-transform-shared.test.ts b/packages/editor/src/components/editor/group-transform-shared.test.ts index 5b741b45d..ff9bedfd2 100644 --- a/packages/editor/src/components/editor/group-transform-shared.test.ts +++ b/packages/editor/src/components/editor/group-transform-shared.test.ts @@ -4,7 +4,10 @@ import { z } from 'zod' import { classifyParticipant, collectParticipants, + planBoundsCenter, rotateGroupPatches, + rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, } from './group-transform-shared' @@ -40,6 +43,34 @@ function registerElevatorTestKind() { } as AnyNodeDefinition) } +// A level holding one of each rigid placement shape: an item ([x,y,z] rotation) +// and a column (numeric rotation). +function placedNodes() { + return { + building_test: { id: 'building_test', type: 'building', children: ['level_test'] }, + level_test: { + id: 'level_test', + type: 'level', + parentId: 'building_test', + children: ['item_chair', 'column_post'], + }, + item_chair: { + id: 'item_chair', + type: 'item', + parentId: 'level_test', + position: [1, 0, 3], + rotation: [0, 0.5, 0], + }, + column_post: { + id: 'column_post', + type: 'column', + parentId: 'level_test', + position: [4, 0, -1], + rotation: 1.25, + }, + } as unknown as Record +} + describe('group transform participants', () => { beforeAll(() => { registerBuildingScopedTestKind() @@ -362,6 +393,53 @@ describe('group transform participants', () => { expect(lampPatch.position).toEqual([3, 2.4, 2]) }) + test('translate patches carry the snapshot rotation for vec3 and scalar kinds', () => { + const { starts } = collectParticipants( + ['item_chair', 'column_post'], + placedNodes(), + 'level_test', + ) + const patches = Object.fromEntries(translateGroupPatches(starts, [], 1, 2)) + + expect(patches.item_chair).toEqual({ position: [2, 0, 5], rotation: [0, 0.5, 0] }) + expect(patches.column_post).toEqual({ position: [5, 0, 1], rotation: 1.25 }) + }) + + test('a mid-drag rotation survives the next translate re-publish', () => { + const { starts } = collectParticipants( + ['item_chair', 'column_post'], + placedNodes(), + 'level_test', + ) + // What a mid-drag R does: turn the snapshots, then re-apply the live delta. + const rotated = rotateGroupSnapshots(starts, [], { x: 0, z: 0 }, Math.PI / 2) + const patches = Object.fromEntries( + translateGroupPatches(rotated.starts, rotated.links, 1, 2), + ) as Record + + // Orbited 90° in the atan2 x→z sense ((x, z) → (-z, x)), then slid. + expect(patches.item_chair!.position[0]).toBeCloseTo(-2) + expect(patches.item_chair!.position[2]).toBeCloseTo(3) + expect(patches.column_post!.position[0]).toBeCloseTo(2) + expect(patches.column_post!.position[2]).toBeCloseTo(6) + // …and the facings turned with it instead of reverting to the pre-drag yaw. + expect((patches.item_chair!.rotation as number[])[1]).toBeCloseTo(0.5 - Math.PI / 2) + expect(patches.column_post!.rotation as number).toBeCloseTo(1.25 - Math.PI / 2) + }) + + test('rotating the plan bounds keeps the footprint centred on the pivot', () => { + const bounds = { minX: 0, minZ: 0, maxX: 4, maxZ: 2 } + const [pivotX, pivotZ] = planBoundsCenter(bounds) + const rotated = rotatePlanBounds(bounds, { x: pivotX, z: pivotZ }, Math.PI / 2) + + expect(rotated.minX).toBeCloseTo(1) + expect(rotated.maxX).toBeCloseTo(3) + expect(rotated.minZ).toBeCloseTo(-1) + expect(rotated.maxZ).toBeCloseTo(3) + expect(planBoundsCenter(rotated)[0]).toBeCloseTo(pivotX) + expect(planBoundsCenter(rotated)[1]).toBeCloseTo(pivotZ) + }) + test('supports legacy level-parented elevators already loaded in the editor', () => { const nodes = { building_test: { diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts index 8e077474c..408eb2cc6 100644 --- a/packages/editor/src/components/editor/group-transform-shared.ts +++ b/packages/editor/src/components/editor/group-transform-shared.ts @@ -387,12 +387,11 @@ export function rotateGroupSnapshots( return { starts: rotatedStarts, links: rotatedLinks } } +export type GroupPlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number } + // Level-frame XZ extents of the participant DATA — the mesh-free sibling of -// `computeGroupBox`, used when meshes aren't mounted yet and to re-seed -// alignment anchors after a mid-drag rotation. -export function participantExtents( - starts: ParticipantStart[], -): { minX: number; minZ: number; maxX: number; maxZ: number } | null { +// `computeGroupBox`, used when meshes aren't mounted yet. +function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null { let minX = Number.POSITIVE_INFINITY let minZ = Number.POSITIVE_INFINITY let maxX = Number.NEGATIVE_INFINITY @@ -419,8 +418,73 @@ export function participantExtents( return { minX, minZ, maxX, maxZ } } +// The one footprint every group transform measures itself against: the +// selection's mounted meshes (world box, converted into the level frame) with +// the participant DATA extents as the fallback when the meshes aren't up yet +// (Duplicate picks up its clones a frame before their renderers mount). Anchor +// points alone sit metres inside a wide selection's real footprint, so a +// gesture that pivots on the data extents orbits a different point than the +// idle keyboard rotate and the rotate gizmos, which both use the mesh box. +export function groupPlanBounds( + box: Box3 | null, + starts: ParticipantStart[], + frameInv: Matrix4, +): GroupPlanBounds | null { + if (!box) return participantExtents(starts) + const min = box.min.clone().applyMatrix4(frameInv) + const max = box.max.clone().applyMatrix4(frameInv) + return { + minX: Math.min(min.x, max.x), + minZ: Math.min(min.z, max.z), + maxX: Math.max(min.x, max.x), + maxZ: Math.max(min.z, max.z), + } +} + +export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [ + (b.minX + b.maxX) / 2, + (b.minZ + b.maxZ) / 2, +] + +// Re-seed the footprint after a mid-gesture rotation by orbiting the box +// corners and re-fitting an axis-aligned box. Re-measuring the rotated DATA +// extents instead would slide the centre off the pivot the snapshots turned +// around, dragging the alignment anchors away from the group under the cursor. +export function rotatePlanBounds( + b: GroupPlanBounds, + center: { x: number; z: number }, + delta: number, +): GroupPlanBounds { + const cos = Math.cos(delta) + const sin = Math.sin(delta) + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + const corners: Vec2[] = [ + [b.minX, b.minZ], + [b.maxX, b.minZ], + [b.maxX, b.maxZ], + [b.minX, b.maxZ], + ] + for (const [x, z] of corners) { + const dx = x - center.x + const dz = z - center.z + const rx = center.x + dx * cos - dz * sin + const rz = center.z + dx * sin + dz * cos + minX = Math.min(minX, rx) + minZ = Math.min(minZ, rz) + maxX = Math.max(maxX, rx) + maxZ = Math.max(maxZ, rz) + } + return { minX, minZ, maxX, maxZ } +} + // Rigid group slide: shift every participant (and each linked neighbour's -// shared endpoint) by the same level-frame XZ delta. Y and rotations untouched. +// shared endpoint) by the same level-frame XZ delta. Y is untouched; the +// snapshot's rotation rides along because a mid-gesture R/T turns the +// SNAPSHOTS — dropping it here would republish (and commit) the pre-rotation +// facing, orbiting the layout while every member keeps its old bearing. export function translateGroupPatches( starts: ParticipantStart[], links: LinkedNeighbor[], @@ -437,7 +501,10 @@ export function translateGroupPatches( if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift)) patches.push([s.id, patch]) } else { - patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }]) + const position: Vec3 = [s.position[0] + dx, s.position[1], s.position[2] + dz] + const rotation = + s.kind === 'vec3' ? ([s.rotation[0], s.rotation[1], s.rotation[2]] as Vec3) : s.rotation + patches.push([s.id, { position, rotation }]) } } for (const l of links) { From 6aadbb3c761446fe2f3dd168cf460091404ba932 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:52:37 -0400 Subject: [PATCH 08/27] chore(deps): three 0.186.0 No removed export is used and every peer range admits r186. Two adjustments: Renderer.dispose() is async now, so the capability probe swallows its rejection; and r186's CommonJS entry re-exports the ES module, which Bun cannot require() while the same process imports three as ESM. A bun test preload steers fiber/drei/maath/meshline (no exports map, CJS main) to their module builds, the way bundlers already resolve them. Types stay on 0.184.1 (0.185 types OOM tsgo). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- apps/editor/bunfig.toml | 4 +++ apps/editor/package.json | 2 +- apps/ifc-converter/package.json | 2 +- bun.lock | 20 +++++++------- bunfig.toml | 4 +++ package.json | 2 +- packages/capture-viewer/bunfig.toml | 4 +++ packages/capture-viewer/package.json | 4 +-- packages/core/bunfig.toml | 4 +++ packages/core/package.json | 2 +- packages/editor/bunfig.toml | 4 +++ packages/editor/package.json | 2 +- packages/nodes/bunfig.toml | 4 +++ packages/nodes/package.json | 2 +- .../shared/node-batch/source-systems.test.ts | 27 ++++++++++++++++++- packages/viewer/bunfig.toml | 4 +++ packages/viewer/package.json | 2 +- .../viewer/src/lib/renderer-capability.ts | 5 ++-- scripts/bun-esm-resolve.ts | 23 ++++++++++++++++ 19 files changed, 99 insertions(+), 22 deletions(-) create mode 100644 apps/editor/bunfig.toml create mode 100644 bunfig.toml create mode 100644 packages/capture-viewer/bunfig.toml create mode 100644 packages/core/bunfig.toml create mode 100644 packages/editor/bunfig.toml create mode 100644 packages/nodes/bunfig.toml create mode 100644 packages/viewer/bunfig.toml create mode 100644 scripts/bun-esm-resolve.ts diff --git a/apps/editor/bunfig.toml b/apps/editor/bunfig.toml new file mode 100644 index 000000000..5c3fa8ef0 --- /dev/null +++ b/apps/editor/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-esm-resolve.ts"] + +[test] +preload = ["../../scripts/bun-esm-resolve.ts"] diff --git a/apps/editor/package.json b/apps/editor/package.json index 31b4cde8a..09852b125 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -36,7 +36,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "zod": ">=4.5.4 <4.6" }, "devDependencies": { diff --git a/apps/ifc-converter/package.json b/apps/ifc-converter/package.json index 67ec77a56..c5798f6e3 100644 --- a/apps/ifc-converter/package.json +++ b/apps/ifc-converter/package.json @@ -29,7 +29,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "web-ifc": "^0.0.77" }, "devDependencies": { diff --git a/bun.lock b/bun.lock index d785effb1..e28a311eb 100644 --- a/bun.lock +++ b/bun.lock @@ -52,7 +52,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "zod": ">=4.5.4 <4.6", }, "devDependencies": { @@ -87,7 +87,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "web-ifc": "^0.0.77", }, "devDependencies": { @@ -125,7 +125,7 @@ "@types/react": "^19.2.2", "@types/three": "^0.184.0", "react": "^19.2.4", - "three": "^0.185.0", + "three": "^0.186.0", "typescript": "6.0.3", }, "peerDependencies": { @@ -135,7 +135,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "packages/cli": { @@ -178,7 +178,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "packages/editor": { @@ -244,7 +244,7 @@ "next": ">=15", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "packages/eslint-config": { @@ -321,7 +321,7 @@ "@react-three/fiber": "^9", "lucide-react": "^1", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", "zustand": "^5", }, }, @@ -369,7 +369,7 @@ "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "tooling/typescript": { @@ -383,7 +383,7 @@ "@types/three": "0.184.1", "next": "16.3.0", "react-grab": "0.1.50", - "three": "0.185.1", + "three": "0.186.0", }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -1942,7 +1942,7 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], + "three": ["three@0.186.0", "", {}, "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ=="], "three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..5a5c9671f --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["./scripts/bun-esm-resolve.ts"] + +[test] +preload = ["./scripts/bun-esm-resolve.ts"] diff --git a/package.json b/package.json index 732ddfcbf..614716ae0 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@types/three": "0.184.1", "next": "16.3.0", "react-grab": "0.1.50", - "three": "0.185.1" + "three": "0.186.0" }, "optionalDependencies": { "@tailwindcss/oxide-darwin-arm64": "4.3.0", diff --git a/packages/capture-viewer/bunfig.toml b/packages/capture-viewer/bunfig.toml new file mode 100644 index 000000000..5c3fa8ef0 --- /dev/null +++ b/packages/capture-viewer/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-esm-resolve.ts"] + +[test] +preload = ["../../scripts/bun-esm-resolve.ts"] diff --git a/packages/capture-viewer/package.json b/packages/capture-viewer/package.json index 9b7db8a29..d59369f21 100644 --- a/packages/capture-viewer/package.json +++ b/packages/capture-viewer/package.json @@ -37,7 +37,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "devDependencies": { "@pascal-app/capture-protocol": "^1.0.0-beta.4", @@ -50,7 +50,7 @@ "@types/react": "^19.2.2", "@types/three": "^0.184.0", "react": "^19.2.4", - "three": "^0.185.0", + "three": "^0.186.0", "typescript": "6.0.3" }, "keywords": [ diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml new file mode 100644 index 000000000..5c3fa8ef0 --- /dev/null +++ b/packages/core/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-esm-resolve.ts"] + +[test] +preload = ["../../scripts/bun-esm-resolve.ts"] diff --git a/packages/core/package.json b/packages/core/package.json index 1d6ff58f0..0a1f26ced 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -73,7 +73,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "dependencies": { "@pascal-app/capture-protocol": "^1.0.0-beta.4", diff --git a/packages/editor/bunfig.toml b/packages/editor/bunfig.toml new file mode 100644 index 000000000..5c3fa8ef0 --- /dev/null +++ b/packages/editor/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-esm-resolve.ts"] + +[test] +preload = ["../../scripts/bun-esm-resolve.ts"] diff --git a/packages/editor/package.json b/packages/editor/package.json index b12dc98a4..d019a2476 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -20,7 +20,7 @@ "next": ">=15", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/packages/nodes/bunfig.toml b/packages/nodes/bunfig.toml new file mode 100644 index 000000000..5c3fa8ef0 --- /dev/null +++ b/packages/nodes/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-esm-resolve.ts"] + +[test] +preload = ["../../scripts/bun-esm-resolve.ts"] diff --git a/packages/nodes/package.json b/packages/nodes/package.json index 699131dd7..d8a633e57 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -30,7 +30,7 @@ "@react-three/fiber": "^9", "lucide-react": "^1", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", "zustand": "^5" }, "devDependencies": { diff --git a/packages/nodes/src/shared/node-batch/source-systems.test.ts b/packages/nodes/src/shared/node-batch/source-systems.test.ts index c6d7d69ac..abad14e43 100644 --- a/packages/nodes/src/shared/node-batch/source-systems.test.ts +++ b/packages/nodes/src/shared/node-batch/source-systems.test.ts @@ -2,6 +2,28 @@ import { expect, test } from 'bun:test' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' +// The probe runs as `bun `, where Bun skips plugin resolution for static +// imports, so fiber's CJS main still `require("three")`s and three r186's shim +// warns once. That is the one stderr line the probe tolerates. +function stripThreeCjsDeprecation(stderr: string) { + const start = stderr.indexOf('DeprecationWarning: `require("three")`') + if (start === -1) return stderr + const lines = stderr.slice(start).split('\n') + let end = 1 + while (end < lines.length) { + const line = lines[end].trim() + if ( + line !== '' && + !line.startsWith('at ') && + !line.startsWith('Replace ') && + !line.startsWith('code:') + ) + break + end++ + } + return (stderr.slice(0, start) + lines.slice(end).join('\n')).trim() +} + function sourcePath(path: string) { return JSON.stringify(resolve(import.meta.dir, '../../../../..', path)) } @@ -51,7 +73,10 @@ function runSourceTest(body: string) { stdout: 'pipe', stderr: 'pipe', }) - expect({ code: result.exitCode, stderr: result.stderr.toString() }).toEqual({ + expect({ + code: result.exitCode, + stderr: stripThreeCjsDeprecation(result.stderr.toString()), + }).toEqual({ code: 0, stderr: '', }) diff --git a/packages/viewer/bunfig.toml b/packages/viewer/bunfig.toml new file mode 100644 index 000000000..5c3fa8ef0 --- /dev/null +++ b/packages/viewer/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-esm-resolve.ts"] + +[test] +preload = ["../../scripts/bun-esm-resolve.ts"] diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 4097c35b9..0c135fd6c 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -28,7 +28,7 @@ "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "dependencies": { "three-bvh-csg": "^0.0.18", diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts index a528fda85..53199d0a0 100644 --- a/packages/viewer/src/lib/renderer-capability.ts +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -152,7 +152,8 @@ export async function initializeGpuRenderer {}) } catch {} if (capability.backend !== 'webgpu') return { error, status: 'unsupported' } @@ -165,7 +166,7 @@ export async function initializeGpuRenderer {}) } catch {} return { error: fallbackError, status: 'unsupported' } } diff --git a/scripts/bun-esm-resolve.ts b/scripts/bun-esm-resolve.ts new file mode 100644 index 000000000..cc681e3f6 --- /dev/null +++ b/scripts/bun-esm-resolve.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { plugin } from 'bun' + +// three r186 turned its CommonJS entry into a re-export of the ES module. Bun +// cannot require() an ES module that the same process is also importing, so +// the R3F packages that ship no `exports` map (Bun then takes their CJS `main`) +// blow up the moment ESM code imports three alongside them. Steer them to the +// `module` build, which is what every bundler already does. Bun applies this +// to `bun test`; plain `bun ` skips plugin resolution for static imports. +const CJS_MAIN_THREE_CONSUMERS = /^(@react-three\/fiber|@react-three\/drei|maath|meshline)$/ + +plugin({ + name: 'prefer-esm-three-consumers', + setup(build) { + build.onResolve({ filter: CJS_MAIN_THREE_CONSUMERS }, (args) => { + const from = args.importer ? dirname(args.importer) : process.cwd() + const manifestPath = Bun.resolveSync(`${args.path}/package.json`, from) + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { module?: string } + return manifest.module ? { path: join(dirname(manifestPath), manifest.module) } : undefined + }) + }, +}) From 85cdc902df78ac4ec6775ece2fd4793b14ea1cb6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 16:47:37 -0400 Subject: [PATCH 09/27] test: pre-evaluate three in the bun test preload Bun's plugin onResolve does not run for static imports, so steering the R3F packages to their module builds never applied in CI (isolated linker) and the CJS require("three") kept racing the ESM import. Evaluating the package's own three copy first makes the later require() a cache hit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- apps/editor/bunfig.toml | 4 +-- bunfig.toml | 4 +-- packages/capture-viewer/bunfig.toml | 4 +-- packages/core/bunfig.toml | 4 +-- packages/editor/bunfig.toml | 4 +-- packages/nodes/bunfig.toml | 4 +-- .../shared/node-batch/source-systems.test.ts | 27 +------------------ packages/viewer/bunfig.toml | 4 +-- scripts/bun-esm-resolve.ts | 23 ---------------- scripts/bun-preload-three.ts | 10 +++++++ 10 files changed, 25 insertions(+), 63 deletions(-) delete mode 100644 scripts/bun-esm-resolve.ts create mode 100644 scripts/bun-preload-three.ts diff --git a/apps/editor/bunfig.toml b/apps/editor/bunfig.toml index 5c3fa8ef0..eec7d338d 100644 --- a/apps/editor/bunfig.toml +++ b/apps/editor/bunfig.toml @@ -1,4 +1,4 @@ -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] [test] -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/bunfig.toml b/bunfig.toml index 5a5c9671f..958800ceb 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,4 +1,4 @@ -preload = ["./scripts/bun-esm-resolve.ts"] +preload = ["./scripts/bun-preload-three.ts"] [test] -preload = ["./scripts/bun-esm-resolve.ts"] +preload = ["./scripts/bun-preload-three.ts"] diff --git a/packages/capture-viewer/bunfig.toml b/packages/capture-viewer/bunfig.toml index 5c3fa8ef0..eec7d338d 100644 --- a/packages/capture-viewer/bunfig.toml +++ b/packages/capture-viewer/bunfig.toml @@ -1,4 +1,4 @@ -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] [test] -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml index 5c3fa8ef0..eec7d338d 100644 --- a/packages/core/bunfig.toml +++ b/packages/core/bunfig.toml @@ -1,4 +1,4 @@ -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] [test] -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/editor/bunfig.toml b/packages/editor/bunfig.toml index 5c3fa8ef0..eec7d338d 100644 --- a/packages/editor/bunfig.toml +++ b/packages/editor/bunfig.toml @@ -1,4 +1,4 @@ -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] [test] -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/nodes/bunfig.toml b/packages/nodes/bunfig.toml index 5c3fa8ef0..eec7d338d 100644 --- a/packages/nodes/bunfig.toml +++ b/packages/nodes/bunfig.toml @@ -1,4 +1,4 @@ -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] [test] -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/nodes/src/shared/node-batch/source-systems.test.ts b/packages/nodes/src/shared/node-batch/source-systems.test.ts index abad14e43..c6d7d69ac 100644 --- a/packages/nodes/src/shared/node-batch/source-systems.test.ts +++ b/packages/nodes/src/shared/node-batch/source-systems.test.ts @@ -2,28 +2,6 @@ import { expect, test } from 'bun:test' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' -// The probe runs as `bun `, where Bun skips plugin resolution for static -// imports, so fiber's CJS main still `require("three")`s and three r186's shim -// warns once. That is the one stderr line the probe tolerates. -function stripThreeCjsDeprecation(stderr: string) { - const start = stderr.indexOf('DeprecationWarning: `require("three")`') - if (start === -1) return stderr - const lines = stderr.slice(start).split('\n') - let end = 1 - while (end < lines.length) { - const line = lines[end].trim() - if ( - line !== '' && - !line.startsWith('at ') && - !line.startsWith('Replace ') && - !line.startsWith('code:') - ) - break - end++ - } - return (stderr.slice(0, start) + lines.slice(end).join('\n')).trim() -} - function sourcePath(path: string) { return JSON.stringify(resolve(import.meta.dir, '../../../../..', path)) } @@ -73,10 +51,7 @@ function runSourceTest(body: string) { stdout: 'pipe', stderr: 'pipe', }) - expect({ - code: result.exitCode, - stderr: stripThreeCjsDeprecation(result.stderr.toString()), - }).toEqual({ + expect({ code: result.exitCode, stderr: result.stderr.toString() }).toEqual({ code: 0, stderr: '', }) diff --git a/packages/viewer/bunfig.toml b/packages/viewer/bunfig.toml index 5c3fa8ef0..eec7d338d 100644 --- a/packages/viewer/bunfig.toml +++ b/packages/viewer/bunfig.toml @@ -1,4 +1,4 @@ -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] [test] -preload = ["../../scripts/bun-esm-resolve.ts"] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/scripts/bun-esm-resolve.ts b/scripts/bun-esm-resolve.ts deleted file mode 100644 index cc681e3f6..000000000 --- a/scripts/bun-esm-resolve.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { plugin } from 'bun' - -// three r186 turned its CommonJS entry into a re-export of the ES module. Bun -// cannot require() an ES module that the same process is also importing, so -// the R3F packages that ship no `exports` map (Bun then takes their CJS `main`) -// blow up the moment ESM code imports three alongside them. Steer them to the -// `module` build, which is what every bundler already does. Bun applies this -// to `bun test`; plain `bun ` skips plugin resolution for static imports. -const CJS_MAIN_THREE_CONSUMERS = /^(@react-three\/fiber|@react-three\/drei|maath|meshline)$/ - -plugin({ - name: 'prefer-esm-three-consumers', - setup(build) { - build.onResolve({ filter: CJS_MAIN_THREE_CONSUMERS }, (args) => { - const from = args.importer ? dirname(args.importer) : process.cwd() - const manifestPath = Bun.resolveSync(`${args.path}/package.json`, from) - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { module?: string } - return manifest.module ? { path: join(dirname(manifestPath), manifest.module) } : undefined - }) - }, -}) diff --git a/scripts/bun-preload-three.ts b/scripts/bun-preload-three.ts new file mode 100644 index 000000000..d222da213 --- /dev/null +++ b/scripts/bun-preload-three.ts @@ -0,0 +1,10 @@ +// three r186's CommonJS entry is `require('./three.module.js')`. Bun cannot +// require() an ES module that is still loading, and the R3F ecosystem (fiber, +// drei, maath, meshline, troika) ships CJS mains that require("three") while +// our sources import it as ESM — so a test file that imports both races and +// dies with "require() async module is unsupported". Evaluating three first +// turns the later require() into a cache hit. Resolve from the package under +// test, not from this file: with the isolated linker each package has its own +// link and this directory would walk up to a different copy. +process.noDeprecation = true +await import(Bun.resolveSync('three', process.cwd())) From 61f2fafa0263cd284fc17c85a990f39e0eecc5ca Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 17:10:52 -0400 Subject: [PATCH 10/27] fix(roof): lift the inner cutter and deck with the shell eave The 5 cm CSG floor lifted only the outer shell, so a flat zero-height roof would have ended up with a solid cap under the deck. Compute the lift once and apply it to every volume. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../src/systems/roof/roof-system.test.ts | 30 +++++++++++++++++++ .../viewer/src/systems/roof/roof-system.tsx | 8 +++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/viewer/src/systems/roof/roof-system.test.ts b/packages/viewer/src/systems/roof/roof-system.test.ts index 657de52b6..f167b0469 100644 --- a/packages/viewer/src/systems/roof/roof-system.test.ts +++ b/packages/viewer/src/systems/roof/roof-system.test.ts @@ -36,6 +36,36 @@ describe('roof system gable geometry', () => { brushes.rakeBoards?.dispose() } }) + + test('lifts the inner cutter with the shell so a flat zero-height roof stays hollow', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'flat', + width: 8, + depth: 6, + wallHeight: 0, + wallThickness: 0.1, + pitch: 0, + }) + const brushes = getRoofSegmentBrushes(segment) + expect(brushes).not.toBeNull() + if (!brushes) return + try { + brushes.wallBrush.geometry.computeBoundingBox() + brushes.innerBrush.geometry.computeBoundingBox() + expect(brushes.wallBrush.geometry.boundingBox!.min.y).toBe(0) + expect(brushes.wallBrush.geometry.boundingBox!.max.y).toBeCloseTo(0.05, 6) + expect(brushes.innerBrush.geometry.boundingBox!.max.y).toBeCloseTo( + brushes.wallBrush.geometry.boundingBox!.max.y, + 6, + ) + } finally { + brushes.wallBrush.geometry.dispose() + brushes.innerBrush.geometry.dispose() + brushes.deckSlab.geometry.dispose() + brushes.shinSlab.geometry.dispose() + brushes.rakeBoards?.dispose() + } + }) }) describe('roof system shed geometry', () => { diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index a9a26324c..291c9f098 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1368,6 +1368,11 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe // same ratio). A hardcoded 0.25 desyncs the gablet from the parameter. const baseI = Math.min(width, depth) * node.dutchHipWidthRatio + // Keep the outer shell's CSG prism at least 5 cm tall by lifting its eave, + // never by sinking the base (the base is the wall top). Every volume gets + // the same lift so the inner cutter and the deck keep their offsets to it. + const eaveLift = Math.max(0, 0.05 - (wallHeight - (wallThickness / 2) * tanTheta)) + const getVol = ( wExt: number, vOffset: number, @@ -1380,8 +1385,7 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe const dV = Math.max(0.01, depth + 2 * wExt) const autoDrop = wExt * tanTheta - // Raise the top for CSG safety; sinking the base overlaps the supporting wall. - const whV = Math.max(baseY + 0.05, wallHeight - autoDrop + vOffset) + const whV = wallHeight - autoDrop + vOffset + eaveLift let rhV = activeRh if (activeRh > 0) { From 9d79be1514c410af44abca2d712414abf03585dd Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 17:10:52 -0400 Subject: [PATCH 11/27] fix(editor): fall back to the autosave flush when the host does not handle Cmd+S Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- packages/editor/src/components/editor/index.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 7c964c26e..8da2bcd69 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -196,10 +196,11 @@ export interface EditorProps { onLoad?: () => Promise onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise /** - * Cmd/Ctrl+S. Defaults to flushing the autosave; hosts with a richer save - * (the community version checkpoint) take the chord over. + * Cmd/Ctrl+S. Return true when the host handled the save (the community + * version checkpoint); anything else falls through to flushing the autosave, + * so the chord still saves when the host's control isn't mounted. */ - onSaveShortcut?: () => void + onSaveShortcut?: () => boolean | undefined onDirty?: () => void onSaveStatusChange?: (status: SaveStatus) => void @@ -1263,7 +1264,11 @@ function EditorContent({ isVersionPreviewMode, }) - useSaveShortcut(onSaveShortcut ?? saveNow) + const handleSaveShortcut = useCallback(() => { + if (onSaveShortcut?.() === true) return + saveNow() + }, [onSaveShortcut, saveNow]) + useSaveShortcut(handleSaveShortcut) const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) From 1651ac0d18e393ec64d356c6d2fa8097c01d1c50 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 17:28:30 -0400 Subject: [PATCH 12/27] fix(roof): floor every prism at 5 cm instead of lifting by the shell's eave A shell-derived lift left overhanging deck cutters with a negative eave. Clamp each volume's top the way main did, just at 5 cm and without the base sink, so cutters stay level with the shells they carve. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- packages/viewer/src/systems/roof/roof-system.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 291c9f098..e2d651e66 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1368,11 +1368,6 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe // same ratio). A hardcoded 0.25 desyncs the gablet from the parameter. const baseI = Math.min(width, depth) * node.dutchHipWidthRatio - // Keep the outer shell's CSG prism at least 5 cm tall by lifting its eave, - // never by sinking the base (the base is the wall top). Every volume gets - // the same lift so the inner cutter and the deck keep their offsets to it. - const eaveLift = Math.max(0, 0.05 - (wallHeight - (wallThickness / 2) * tanTheta)) - const getVol = ( wExt: number, vOffset: number, @@ -1385,7 +1380,10 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe const dV = Math.max(0.01, depth + 2 * wExt) const autoDrop = wExt * tanTheta - const whV = wallHeight - autoDrop + vOffset + eaveLift + // Floor every prism at 5 cm so CSG never sees a degenerate volume — by + // raising the top, never by sinking the base (the base is the wall top). + // One floor for all volumes keeps each cutter level with the shell it carves. + const whV = Math.max(0.05, wallHeight - autoDrop + vOffset) let rhV = activeRh if (activeRh > 0) { From f7f60e4175d24c528940661554d4dcc7ac8fd951 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 17:46:05 -0400 Subject: [PATCH 13/27] fix(stair): drop the duplicate geometry Rise control The rise-mode block already exposes the Rise field in custom mode; the geometry copy wrote totalRise behind the Follows storey toggle. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- packages/nodes/src/stair/panel.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index e6f135065..e187c8f3f 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -459,16 +459,6 @@ export default function StairPanel() { unit="m" value={Math.round((node.width ?? 1) * 100) / 100} /> - handleUpdate({ totalRise: value })} - precision={2} - step={0.05} - unit="m" - value={resolvedRise} - /> Date: Wed, 9 Sep 2026 17:46:54 -0400 Subject: [PATCH 14/27] test: import resolveSync explicitly in the three preload Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- scripts/bun-preload-three.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/bun-preload-three.ts b/scripts/bun-preload-three.ts index d222da213..00bb28137 100644 --- a/scripts/bun-preload-three.ts +++ b/scripts/bun-preload-three.ts @@ -1,3 +1,5 @@ +import { resolveSync } from 'bun' + // three r186's CommonJS entry is `require('./three.module.js')`. Bun cannot // require() an ES module that is still loading, and the R3F ecosystem (fiber, // drei, maath, meshline, troika) ships CJS mains that require("three") while @@ -7,4 +9,4 @@ // test, not from this file: with the isolated linker each package has its own // link and this directory would walk up to a different copy. process.noDeprecation = true -await import(Bun.resolveSync('three', process.cwd())) +await import(resolveSync('three', process.cwd())) From 9dfd816da592547d7d26cd570af6d5b38345fff0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 18:04:43 -0400 Subject: [PATCH 15/27] test: skip the three preload where the cwd has no three dependency Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- scripts/bun-preload-three.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/bun-preload-three.ts b/scripts/bun-preload-three.ts index 00bb28137..25f5243f7 100644 --- a/scripts/bun-preload-three.ts +++ b/scripts/bun-preload-three.ts @@ -7,6 +7,11 @@ import { resolveSync } from 'bun' // dies with "require() async module is unsupported". Evaluating three first // turns the later require() into a cache hit. Resolve from the package under // test, not from this file: with the isolated linker each package has its own -// link and this directory would walk up to a different copy. +// link and this directory would walk up to a different copy. Packages that do +// not depend on three have nothing to pre-evaluate. process.noDeprecation = true -await import(resolveSync('three', process.cwd())) +let threePath: string | null = null +try { + threePath = resolveSync('three', process.cwd()) +} catch {} +if (threePath) await import(threePath) From c50f5492d19f1944a7dfaf91c14b63f689a3f126 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 18:04:43 -0400 Subject: [PATCH 16/27] fix(editor): seed placed stairs from the elected base; keep the gesture when R/T cannot engage The stair tool seeded the flight from the storey height alone, a slab thickness too tall until syncStairRises caught up; it now subtracts the drop point's elected base like the resolver. A failed engage() on R/T no longer tears down the pointer listeners. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../editor-2d/floorplan-group-move.tsx | 6 +++- .../src/components/editor/group-move-3d.ts | 6 +++- .../src/components/tools/stair/stair-tool.tsx | 28 ++++++++++++++++--- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index 74d954cca..910097ec7 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -376,7 +376,11 @@ export function startFloorplanGroupMove( if (!session) { session = engage() if (!session) { - removeListeners() + // No plane hit yet: swallow the chord and keep the gesture armed so + // the next pointer-move can still engage; the idle arm must not run + // behind the snapshots captured here. + e.preventDefault() + e.stopPropagation() return } } diff --git a/packages/editor/src/components/editor/group-move-3d.ts b/packages/editor/src/components/editor/group-move-3d.ts index aeecefbda..f23614b97 100644 --- a/packages/editor/src/components/editor/group-move-3d.ts +++ b/packages/editor/src/components/editor/group-move-3d.ts @@ -385,7 +385,11 @@ export function armGroupMove3d(args: { if (!session) { session = engage() if (!session) { - removeListeners() + // No plane hit yet: swallow the chord and keep the gesture armed so + // the next pointer-move can still engage; the idle arm must not run + // behind the snapshots captured here. + e.preventDefault() + e.stopPropagation() return } } diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 9d68d491f..f462eff86 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -5,6 +5,7 @@ import { DEFAULT_LEVEL_HEIGHT, emitter, type GridEvent, + getFloorStackedPosition, getLevelFloorToFloorHeight, type LevelNode, movingAlignmentAnchors, @@ -108,6 +109,23 @@ function createStairPreviewGeometry(rise: number): THREE.BufferGeometry { * dropped on, not a constant: the placed stair has no explicit `totalRise`, so * this is the height `syncStairRises` immediately converges it to anyway. */ +function resolvePlacedStairRise( + nodes: Record, + levelId: LevelNode['id'], + stair: StairNode, +): number { + // Same contract as `resolveStairTotalRise` for a stair that is not in the + // scene yet: the storey height minus whatever slab lifts the drop point. + const base = getFloorStackedPosition({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId, + })[1] + return getLevelFloorToFloorHeight(levelId, nodes) - base +} + function createDefaultStairSegment(rise: number) { return StairSegmentNode.parse({ segmentType: 'stair', @@ -181,7 +199,7 @@ function commitStairPlacement( const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length const name = `Staircase ${stairCount + 1}` - const segment = createDefaultStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) + const seed = createDefaultStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) const destinationPlan = resolveStairDestinationLevel({ createMissing: true, @@ -197,10 +215,11 @@ function commitStairPlacement( nextLevelId, position, rotation, - segmentId: segment.id, + segmentId: seed.id, }), parentId: placementLevelId, }) + const segment = { ...seed, height: resolvePlacedStairRise(nodes, placementLevelId, stair) } const prospectiveNodes = { ...nodes, [stair.id]: stair, @@ -287,15 +306,16 @@ export const StairTool: React.FC = () => { nodes, }) const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId - const segment = createDefaultStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) + const seed = createDefaultStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) const stair = createDefaultStairNode({ name: 'Staircase Preview', levelId: placementLevelId, nextLevelId, position, rotation, - segmentId: segment.id, + segmentId: seed.id, }) + const segment = { ...seed, height: resolvePlacedStairRise(nodes, placementLevelId, stair) } const previewNodes = { ...nodes, ...(destinationPlan?.createdLevel From 5526120067cb5fec20443fed91f222d533a5ff83 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 18:14:10 -0400 Subject: [PATCH 17/27] fix(stair): cap the placed rise by the pointed support surface Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../src/components/tools/stair/stair-tool.tsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index f462eff86..8ef800de6 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -113,15 +113,19 @@ function resolvePlacedStairRise( nodes: Record, levelId: LevelNode['id'], stair: StairNode, + supportSurface: PointerSupportSurface | null, ): number { // Same contract as `resolveStairTotalRise` for a stair that is not in the - // scene yet: the storey height minus whatever slab lifts the drop point. + // scene yet: the storey height minus whatever slab lifts the drop point, + // capped by the surface the pointer actually aims at (a floor under an + // overlapping deck must not elect the deck). const base = getFloorStackedPosition({ node: stair, nodes, position: stair.position, rotation: stair.rotation, levelId, + maxElevation: supportSurface?.elevation ?? null, })[1] return getLevelFloorToFloorHeight(levelId, nodes) - base } @@ -219,7 +223,10 @@ function commitStairPlacement( }), parentId: placementLevelId, }) - const segment = { ...seed, height: resolvePlacedStairRise(nodes, placementLevelId, stair) } + const segment = { + ...seed, + height: resolvePlacedStairRise(nodes, placementLevelId, stair, supportSurface), + } const prospectiveNodes = { ...nodes, [stair.id]: stair, @@ -291,7 +298,11 @@ export const StairTool: React.FC = () => { lastCanonicalPositionRef.current = null supportSurfaceRef.current = null - const buildPreviewScene = (position: [number, number, number], rotation: number) => { + const buildPreviewScene = ( + position: [number, number, number], + rotation: number, + supportSurface: PointerSupportSurface | null, + ) => { const nodes = useScene.getState().nodes const placementLevelId = resolveStairPlacementLevelId( nodes, @@ -315,7 +326,10 @@ export const StairTool: React.FC = () => { rotation, segmentId: seed.id, }) - const segment = { ...seed, height: resolvePlacedStairRise(nodes, placementLevelId, stair) } + const segment = { + ...seed, + height: resolvePlacedStairRise(nodes, placementLevelId, stair, supportSurface), + } const previewNodes = { ...nodes, ...(destinationPlan?.createdLevel @@ -346,7 +360,7 @@ export const StairTool: React.FC = () => { if (key === lastPreviewKey) return lastPreviewKey = key useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation) - const preview = buildPreviewScene(position, rotation) + const preview = buildPreviewScene(position, rotation, supportSurface) const frozenPatch = preview && supportSurface?.sourceNodeId ? resolveFrozenFloorPlacementPatch(preview.stair, preview.previewNodes, { @@ -415,7 +429,7 @@ export const StairTool: React.FC = () => { z: number, rotation: number, ): ReturnType | null => { - const preview = buildPreviewScene([x, 0, z], rotation) + const preview = buildPreviewScene([x, 0, z], rotation, supportSurfaceRef.current) const moving = preview ? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation) : [] From 71122f6e9d415868596f8006ac45e1fc32d3c334 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 18:30:52 -0400 Subject: [PATCH 18/27] fix: scale the stair ghost to the placed rise; await renderer.dispose() before the WebGL fallback Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../editor/src/components/tools/stair/stair-tool.tsx | 12 ++++++++++-- packages/viewer/src/lib/renderer-capability.ts | 7 ++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 8ef800de6..a14b77742 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -280,6 +280,8 @@ export const StairTool: React.FC = () => { const previewRise = useScene((state) => currentLevelId ? getLevelFloorToFloorHeight(currentLevelId, state.nodes) : DEFAULT_LEVEL_HEIGHT, ) + const previewRiseRef = useRef(previewRise) + previewRiseRef.current = previewRise const previewGeometry = useMemo(() => createStairPreviewGeometry(previewRise), [previewRise]) useEffect(() => () => previewGeometry.dispose(), [previewGeometry]) @@ -294,7 +296,10 @@ export const StairTool: React.FC = () => { // Reset rotation when tool activates rotationRef.current = 0 useStairBuildPreview.getState().reset() - if (previewRef.current) previewRef.current.rotation.y = 0 + if (previewRef.current) { + previewRef.current.rotation.y = 0 + previewRef.current.scale.y = 1 + } lastCanonicalPositionRef.current = null supportSurfaceRef.current = null @@ -339,7 +344,7 @@ export const StairTool: React.FC = () => { [segment.id]: { ...segment, parentId: stair.id }, } as Record - return { placementLevelId, previewNodes, stair } + return { placementLevelId, previewNodes, stair, rise: segment.height } } // The preview rebuild (full-scene copy + destination-level resolution + @@ -396,6 +401,9 @@ export const StairTool: React.FC = () => { if (previewRef.current) { previewRef.current.position.set(...visualPosition) previewRef.current.rotation.y = rotation + // The ghost geometry is built for the storey height; squash it to the + // rise the placed flight will get on this surface. + previewRef.current.scale.y = preview ? preview.segment.height / previewRiseRef.current : 1 } // Forward-facing triangle (editor-side overlay). The run ascends along diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts index 53199d0a0..0bf90e696 100644 --- a/packages/viewer/src/lib/renderer-capability.ts +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -152,8 +152,9 @@ export async function initializeGpuRenderer {}) + // r186 made dispose() async: let it finish before the device is released + // and the WebGL fallback starts on the same canvas. + await renderer?.dispose?.() } catch {} if (capability.backend !== 'webgpu') return { error, status: 'unsupported' } @@ -166,7 +167,7 @@ export async function initializeGpuRenderer {}) + await renderer?.dispose?.() } catch {} return { error: fallbackError, status: 'unsupported' } } From 7aac601a34250911af7c8d7d86b8a92b1ba4371a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 18:31:26 -0400 Subject: [PATCH 19/27] fix(stair): read the placed rise from the preview scene Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- packages/editor/src/components/tools/stair/stair-tool.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index a14b77742..2d4cf3b70 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -403,7 +403,7 @@ export const StairTool: React.FC = () => { previewRef.current.rotation.y = rotation // The ghost geometry is built for the storey height; squash it to the // rise the placed flight will get on this surface. - previewRef.current.scale.y = preview ? preview.segment.height / previewRiseRef.current : 1 + previewRef.current.scale.y = preview ? preview.rise / previewRiseRef.current : 1 } // Forward-facing triangle (editor-side overlay). The run ascends along From 40eba21ffcac85ce8e59b258ffd299ea5b27990b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 18:44:13 -0400 Subject: [PATCH 20/27] fix(selection): re-fit alignment bounds from the start footprint after each R/T Rotating the previous axis-aligned fit inflated the anchors every step. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- .../src/components/editor-2d/floorplan-group-move.tsx | 7 ++++++- packages/editor/src/components/editor/group-actions.ts | 4 +++- packages/editor/src/components/editor/group-move-3d.ts | 9 ++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index 910097ec7..1c63b3a3c 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -115,7 +115,9 @@ export function startFloorplanGroupMove( affectedIds: AnyNodeId[] candidates: ReturnType restAnchors: ReturnType + startBounds: GroupPlanBounds restBounds: GroupPlanBounds + rotation: number restCenter: Vec2 lastDelta: Vec2 | null } @@ -180,7 +182,9 @@ export function startFloorplanGroupMove( affectedIds, candidates, restAnchors, + startBounds: restBounds, restBounds, + rotation: 0, restCenter, lastDelta: null, } @@ -255,7 +259,8 @@ export function startFloorplanGroupMove( const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta) s.starts = rotated.starts s.links = rotated.links - s.restBounds = rotatePlanBounds(s.restBounds, pivot, delta) + s.rotation += delta + s.restBounds = rotatePlanBounds(s.startBounds, pivot, s.rotation) s.restAnchors = bboxCornerAnchors( 'group-move', s.restBounds.minX, diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index 48ecc0174..e2c0d6876 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -112,6 +112,7 @@ export function startGroupPickUp( if (!startBounds) return false // Mutable: mid-carry R/T re-seeds the footprint around the same pivot. let restBounds = startBounds + let carriedRotation = 0 // Rotation pivot for mid-carry R/T; stable across the whole pick-up. const restCenter = planBoundsCenter(restBounds) // Ground plane for the 3D surface: the meshes' base when available, floor @@ -255,7 +256,8 @@ export function startGroupPickUp( const rotated = rotateGroupSnapshots(starts, links, pivot, delta) starts = rotated.starts links = rotated.links - restBounds = rotatePlanBounds(restBounds, pivot, delta) + carriedRotation += delta + restBounds = rotatePlanBounds(startBounds, pivot, carriedRotation) restAnchors = bboxCornerAnchors( 'group-move', restBounds.minX, diff --git a/packages/editor/src/components/editor/group-move-3d.ts b/packages/editor/src/components/editor/group-move-3d.ts index f23614b97..3a26251e5 100644 --- a/packages/editor/src/components/editor/group-move-3d.ts +++ b/packages/editor/src/components/editor/group-move-3d.ts @@ -91,7 +91,9 @@ export function armGroupMove3d(args: { affectedIds: AnyNodeId[] candidates: ReturnType restAnchors: ReturnType + startBounds: GroupPlanBounds restBounds: GroupPlanBounds + rotation: number restCenter: Vec2 plane: Plane startLocal: Vector3 @@ -170,7 +172,9 @@ export function armGroupMove3d(args: { affectedIds, candidates, restAnchors, + startBounds: restBounds, restBounds, + rotation: 0, restCenter, plane, startLocal, @@ -257,7 +261,10 @@ export function armGroupMove3d(args: { const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta) s.starts = rotated.starts s.links = rotated.links - s.restBounds = rotatePlanBounds(s.restBounds, pivot, delta) + // Re-fit from the start footprint at the accumulated angle: rotating the + // previous axis-aligned fit would inflate the box every step. + s.rotation += delta + s.restBounds = rotatePlanBounds(s.startBounds, pivot, s.rotation) s.restAnchors = bboxCornerAnchors( 'group-move', s.restBounds.minX, From 4c7439fc4203c07ccfa48b9fadca08e4a152b5a3 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 10 Sep 2026 09:44:10 -0400 Subject: [PATCH 21/27] fix(stair): switching to straight materializes a flight; level labels use the shared display name A curved stair switched to straight had no stair-segment child and drew nothing (and vanished on select). The type change now creates a default flight in the same history step and the viewer falls back to that flight for already-broken scenes. Stair and elevator panels label levels the way the level switcher does, and the rise toggle reads Follows level like walls. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --- packages/core/src/index.ts | 7 ++ .../core/src/systems/stair/stair-flight.ts | 41 ++++++++ .../systems/stair/stair-edit-system.tsx | 7 +- .../src/components/tools/stair/stair-tool.tsx | 13 ++- packages/nodes/src/elevator/panel.tsx | 13 ++- packages/nodes/src/stair/panel.tsx | 34 ++++--- packages/nodes/src/stair/stair-type.test.ts | 94 +++++++++++++++++++ packages/nodes/src/stair/stair-type.ts | 47 ++++++++++ .../viewer/src/systems/stair/stair-system.tsx | 17 ++-- 9 files changed, 235 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/systems/stair/stair-flight.ts create mode 100644 packages/nodes/src/stair/stair-type.test.ts create mode 100644 packages/nodes/src/stair/stair-type.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d250404a..91dfeed97 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -395,12 +395,19 @@ export { isSplineFence, sampleFenceSpline, } from './systems/fence/fence-spline' +export { resolveRoofElevation, resolveRoofWallTopElevation } from './systems/roof/roof-elevation' +export { RoofElevationSystem } from './systems/roof/roof-elevation-system' export { resolveSlabPlacementElevation } from './systems/slab/slab-placement' export { clampSlabElevationForWalls, getSlabElevationUpperBound, type SlabElevationClamp, } from './systems/slab/slab-support' +export { + createDefaultStairSegment, + createStairFlightFromStair, + type StairFlightOverrides, +} from './systems/stair/stair-flight' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' diff --git a/packages/core/src/systems/stair/stair-flight.ts b/packages/core/src/systems/stair/stair-flight.ts new file mode 100644 index 000000000..fcdc7e9c8 --- /dev/null +++ b/packages/core/src/systems/stair/stair-flight.ts @@ -0,0 +1,41 @@ +import type { AnyNode, StairNode } from '../../schema' +import { StairSegmentNode } from '../../schema' +import { resolveStairTotalRise } from './stair-rise' + +const MIN_STAIR_FLIGHT_RISE = 0.1 +const MIN_STAIR_FLIGHT_STEP_COUNT = 2 + +export type StairFlightOverrides = Partial< + Pick< + StairSegmentNode, + 'width' | 'length' | 'height' | 'stepCount' | 'attachmentSide' | 'fillToFloor' | 'thickness' + > +> + +/** + * The single definition of a default straight flight. Anything left out falls + * through to the `StairSegmentNode` schema defaults (length 3 m, 10 steps, + * filled to floor) rather than being spelled again per call site, so the stair + * tool's seed segment, the flight the panel materializes when a curved stair + * becomes straight, and the viewer's fallback body all describe one stair. + */ +export function createDefaultStairSegment(overrides: StairFlightOverrides = {}): StairSegmentNode { + return StairSegmentNode.parse({ segmentType: 'stair', position: [0, 0, 0], ...overrides }) +} + +/** + * The flight a straight stair implies from its own fields — used wherever a + * straight stair has to stand in for missing `stair-segment` children. + */ +export function createStairFlightFromStair( + stair: StairNode, + nodes: Record, +): StairSegmentNode { + return createDefaultStairSegment({ + width: stair.width, + height: Math.max(resolveStairTotalRise(stair, nodes), MIN_STAIR_FLIGHT_RISE), + stepCount: Math.max(MIN_STAIR_FLIGHT_STEP_COUNT, Math.round(stair.stepCount ?? 10)), + thickness: stair.thickness, + fillToFloor: stair.fillToFloor, + }) +} diff --git a/packages/editor/src/components/systems/stair/stair-edit-system.tsx b/packages/editor/src/components/systems/stair/stair-edit-system.tsx index 1831aaaa0..08b492a08 100644 --- a/packages/editor/src/components/systems/stair/stair-edit-system.tsx +++ b/packages/editor/src/components/systems/stair/stair-edit-system.tsx @@ -67,9 +67,12 @@ export const StairEditSystem = () => { const mergedMesh = group.getObjectByName('merged-stair') const segmentsWrapper = group.getObjectByName('segments-wrapper') const isActive = activeStairIds.has(stairId) + // A straight stair with no segment children has an empty wrapper, so + // edit mode would hide the merged body and leave nothing on screen. + const isEditable = !isCurved && (stairNode?.children?.length ?? 0) > 0 - if (mergedMesh) mergedMesh.visible = !(isActive || isCurved) - if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved + if (mergedMesh) mergedMesh.visible = !((isActive && isEditable) || isCurved) + if (segmentsWrapper) segmentsWrapper.visible = isActive && isEditable if (stairNode?.children?.length) { const wasActive = prevActiveStairIds.current.has(stairId) diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 2d4cf3b70..71dabb502 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -1,6 +1,7 @@ import { type AnyNode, collectAlignmentAnchors, + createDefaultStairSegment, createSurfaceOpeningPreviewController, DEFAULT_LEVEL_HEIGHT, emitter, @@ -14,7 +15,7 @@ import { resolveFrozenFloorPlacementPatch, resolveSupportSlabPatch, StairNode, - StairSegmentNode, + type StairSegmentNode, syncAutoStairOpenings, useScene, } from '@pascal-app/core' @@ -130,9 +131,8 @@ function resolvePlacedStairRise( return getLevelFloorToFloorHeight(levelId, nodes) - base } -function createDefaultStairSegment(rise: number) { - return StairSegmentNode.parse({ - segmentType: 'stair', +function createSeedStairSegment(rise: number) { + return createDefaultStairSegment({ width: DEFAULT_STAIR_WIDTH, length: DEFAULT_STAIR_LENGTH, height: rise, @@ -140,7 +140,6 @@ function createDefaultStairSegment(rise: number) { attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE, fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, thickness: DEFAULT_STAIR_THICKNESS, - position: [0, 0, 0], }) } @@ -203,7 +202,7 @@ function commitStairPlacement( const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length const name = `Staircase ${stairCount + 1}` - const seed = createDefaultStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) + const seed = createSeedStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) const destinationPlan = resolveStairDestinationLevel({ createMissing: true, @@ -322,7 +321,7 @@ export const StairTool: React.FC = () => { nodes, }) const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId - const seed = createDefaultStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) + const seed = createSeedStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) const stair = createDefaultStairNode({ name: 'Staircase Preview', levelId: placementLevelId, diff --git a/packages/nodes/src/elevator/panel.tsx b/packages/nodes/src/elevator/panel.tsx index f716e1b3c..49964283c 100644 --- a/packages/nodes/src/elevator/panel.tsx +++ b/packages/nodes/src/elevator/panel.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type ElevatorNode, ElevatorNode as ElevatorNodeSchema, + getLevelDisplayName, type LevelNode, requestElevatorLevel, useInteractive, @@ -596,7 +597,7 @@ export default function ElevatorPanel() { > {levels.map((level) => ( ))} @@ -613,7 +614,7 @@ export default function ElevatorPanel() { > {levels.map((level) => ( ))} @@ -631,7 +632,7 @@ export default function ElevatorPanel() { > {defaultLevelOptions.map((level) => ( ))} @@ -810,9 +811,7 @@ export default function ElevatorPanel() { className="flex items-center justify-between gap-2 rounded-lg border border-border/45 bg-[#2C2C2E] px-2.5 py-2" key={level.id} > - - {level.name || `Level ${level.level}`} - + {getLevelDisplayName(level)}