From ea01e09b1f64ae9a414d2eea7190cbb0f158e4e6 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Mon, 21 Sep 2026 14:49:29 +0200 Subject: [PATCH 1/8] fix(editor): WASD and orbit move the 2D-only floor plan The 2D view had no navigation keys of its own: WASD and the orbit buttons drove the 3D camera, which the plan followed. Since the 3D canvas pauses while hidden (#675), both did nothing in 2D-only view, and a session opened straight into 2D never mounted the camera at all. The plan now owns them in 2D-only view with the camera's own key state, guards and speed (lib/keyboard-pan), pans through its existing viewport pipeline and publishes the pose to 3D when the move ends. The camera stands down there, so each view mode has one owner. Split view is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017sG15rKXusC8rbBg6gjSRm --- .../editor/custom-camera-controls.tsx | 96 ++++---------- .../src/components/editor/floorplan-panel.tsx | 120 ++++++++++++++++++ packages/editor/src/lib/keyboard-pan.test.ts | 62 +++++++++ packages/editor/src/lib/keyboard-pan.ts | 87 +++++++++++++ 4 files changed, 291 insertions(+), 74 deletions(-) create mode 100644 packages/editor/src/lib/keyboard-pan.test.ts create mode 100644 packages/editor/src/lib/keyboard-pan.ts diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index b50630e191..67bbb0f555 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -31,6 +31,17 @@ import { withCameraPoseDistance, } from '../../lib/camera-pose' import { EDITOR_LAYER } from '../../lib/constants' +import { + acceptsKeyboardPan, + clearKeyboardPanKeys, + hasKeyboardPanInput, + isEditableKeyboardTarget, + isKeyboardPanKey, + type KeyboardPanState, + keyboardPanDirection, + keyboardPanSpeed, + setKeyboardPanKey, +} from '../../lib/keyboard-pan' import { editorOwnsOneFingerDrag } from '../../lib/touch-gesture-priority' import { publishCameraPose } from '../../store/camera-pose-store' import useEditor from '../../store/use-editor' @@ -51,11 +62,11 @@ const tempTarget = new Vector3() const transitionFreezePosition = new Vector3() const transitionFreezeTarget = new Vector3() const keyboardPanSpherical = new Spherical() +// In 2D-only view the canvas is paused, so the floor plan drives WASD, orbit and +// top view itself (`floorplan-panel.tsx`) and the camera stands down. +const planOwnsNavigation = () => useEditor.getState().viewMode === '2d' const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 -const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65 -const KEYBOARD_PAN_MIN_SPEED = 2 -const KEYBOARD_PAN_MAX_SPEED = 55 type CameraMode = ReturnType['cameraMode'] type CameraPoseSnapshot = { mode: CameraMode @@ -114,54 +125,6 @@ function freezeCameraControlTransition(control: CameraControlsImpl) { ) } -function isEditableKeyboardTarget(target: EventTarget | null) { - return ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - (target instanceof HTMLElement && target.isContentEditable) - ) -} - -type KeyboardPanState = { - forward: boolean - backward: boolean - left: boolean - right: boolean -} - -function setKeyboardPanKey(state: KeyboardPanState, code: string, pressed: boolean): boolean { - if (code === 'KeyW') { - const changed = state.forward !== pressed - state.forward = pressed - return changed - } - if (code === 'KeyS') { - const changed = state.backward !== pressed - state.backward = pressed - return changed - } - if (code === 'KeyA') { - const changed = state.left !== pressed - state.left = pressed - return changed - } - if (code === 'KeyD') { - const changed = state.right !== pressed - state.right = pressed - return changed - } - return false -} - -function isKeyboardPanKey(code: string): boolean { - return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD' -} - -function hasKeyboardPanInput(state: KeyboardPanState): boolean { - return state.forward || state.backward || state.left || state.right -} - type CameraViewportSize = { width: number height: number @@ -667,19 +630,14 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } } - const panKeys = keyboardPanKeys.current - const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0) - const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0) + const { horizontal, vertical } = keyboardPanDirection(keyboardPanKeys.current) if (horizontal === 0 && vertical === 0) return const control = controls.current control.getSpherical(keyboardPanSpherical, false) const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize) - const speed = Math.min( - Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED), - KEYBOARD_PAN_MAX_SPEED, - ) + const speed = keyboardPanSpeed(viewWidth) const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical) if (horizontal !== 0) control.truck(horizontal * step, 0, true) @@ -766,13 +724,6 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = let panPointerId: number | null = null let panPointerButton: number | null = null - const clearKeyboardPanKeys = () => { - keyboardPanKeys.current.forward = false - keyboardPanKeys.current.backward = false - keyboardPanKeys.current.left = false - keyboardPanKeys.current.right = false - } - const setNavigationCursor = (cursor: 'grab' | 'grabbing') => { document.body.style.cursor = cursor gl.domElement.style.cursor = cursor @@ -835,10 +786,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = const onKeyDown = (event: KeyboardEvent) => { if (isKeyboardPanKey(event.code)) { - if ( - !(event.metaKey || event.ctrlKey || event.altKey) && - !isEditableKeyboardTarget(event.target) - ) { + if (acceptsKeyboardPan(event) && !planOwnsNavigation()) { const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true) if (changed) beginLocalCameraInteraction() event.preventDefault() @@ -930,7 +878,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = const onBlur = () => { keyState.space = false - clearKeyboardPanKeys() + clearKeyboardPanKeys(keyboardPanKeys.current) panPointerId = null panPointerButton = null clearNavigationCursor() @@ -955,7 +903,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = window.removeEventListener('pointercancel', onPointerUp, true) window.removeEventListener('blur', onBlur) gl.domElement.removeEventListener('wheel', onWheel, true) - clearKeyboardPanKeys() + clearKeyboardPanKeys(keyboardPanKeys.current) clearNavigationCursor() cameraDraggingLifecycle.end() } @@ -1196,7 +1144,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } const handleTopView = () => { - if (isFirstPersonMode || !controls.current) return + if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return const currentPolarAngle = controls.current.polarAngle @@ -1208,7 +1156,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } const handleOrbitCW = () => { - if (isFirstPersonMode || !controls.current) return + if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return const currentAzimuth = controls.current.azimuthAngle const currentPolar = controls.current.polarAngle @@ -1220,7 +1168,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } const handleOrbitCCW = () => { - if (isFirstPersonMode || !controls.current) return + if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return const currentAzimuth = controls.current.azimuthAngle const currentPolar = controls.current.polarAngle diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 91e4aa0816..3ed093b346 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -94,6 +94,15 @@ import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid- import type { EditorGridEvent } from '../../lib/grid-event-presentation' import { groundHeightAt } from '../../lib/ground-surface' import { guideEmitter } from '../../lib/guide-events' +import { + acceptsKeyboardPan, + clearKeyboardPanKeys, + isKeyboardPanKey, + type KeyboardPanState, + keyboardPanDirection, + keyboardPanSpeed, + setKeyboardPanKey, +} from '../../lib/keyboard-pan' import { measurementHint, parseMeasurement } from '../../lib/measurement-parser' import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements' import { sfxEmitter } from '../../lib/sfx-bus' @@ -5036,6 +5045,7 @@ export function FloorplanPanel({ // the user closes and re-opens the 2D editor instead of restoring the // stale viewport from before they closed it. const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen) + const viewMode = useEditor((state) => state.viewMode) // Mirror for callbacks that fire outside React's render (the per-frame // navigation-pose subscriber): when the 2D panel is hidden (`display:none` in // 3D mode) it must NOT re-render on every camera-zoom frame. @@ -7784,6 +7794,116 @@ export function FloorplanPanel({ [publishFloorplanNavigationPose], ) + // WASD and the orbit buttons in 2D-only view. The 3D canvas is paused there + // (`renderPaused`), so the plan drives them itself — same keys and speed as + // the camera — and hands the pose to 3D when the move ends. + const keyboardPanKeysRef = useRef({ + forward: false, + backward: false, + left: false, + right: false, + }) + useEffect(() => { + if (viewMode !== '2d') return + const keys = keyboardPanKeysRef.current + let frame: number | null = null + let lastTime: number | null = null + const sceneRotationDeg = (userRotationDeg: number) => + FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - buildingRotationDeg + const step = (time: number) => { + const { horizontal, vertical } = keyboardPanDirection(keys) + const viewport = latestViewportRef.current ?? latestFittedViewportRef.current + if ((horizontal === 0 && vertical === 0) || !viewport) { + frame = null + lastTime = null + commitFloorplanPan() + return + } + const elapsed = lastTime === null ? 0 : Math.min((time - lastTime) / 1000, 0.05) + lastTime = time + const distance = + (keyboardPanSpeed(viewport.width) * elapsed) / Math.hypot(horizontal, vertical) + const next = { + centerX: viewport.centerX + horizontal * distance, + centerY: viewport.centerY - vertical * distance, + width: viewport.width, + } + const userRotationDeg = latestFloorplanUserRotationDegRef.current + floorplanViewportInteractionInProgressRef.current = true + applyFloorplanViewportImperatively(next) + floorplanPanPoseRef.current = { + localCenter: rotateSvgPoint( + { x: next.centerX, y: next.centerY }, + -sceneRotationDeg(userRotationDeg), + ), + userRotationDeg, + viewWidth: next.width, + } + frame = requestAnimationFrame(step) + } + const start = () => { + if (frame === null) frame = requestAnimationFrame(step) + } + const onKeyDown = (event: KeyboardEvent) => { + if (!(isKeyboardPanKey(event.code) && acceptsKeyboardPan(event))) return + if (setKeyboardPanKey(keys, event.code, true)) start() + event.preventDefault() + event.stopPropagation() + } + const onKeyUp = (event: KeyboardEvent) => { + if (isKeyboardPanKey(event.code) && setKeyboardPanKey(keys, event.code, false)) { + event.preventDefault() + event.stopPropagation() + } + } + const onBlur = () => clearKeyboardPanKeys(keys) + // Same rule as the 3D buttons: snap to the nearest quarter turn, then turn one more. + const orbit = (clockwise: boolean) => { + const viewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!viewport) return + const userRotationDeg = latestFloorplanUserRotationDegRef.current + const quarter = Math.PI / 2 + const azimuth = + Math.round(cameraAzimuthFromFloorplanRotation(userRotationDeg) / quarter) * quarter + + (clockwise ? -quarter : quarter) + const nextRotationDeg = floorplanRotationFromCameraAzimuth(azimuth, userRotationDeg) + const localCenter = rotateSvgPoint( + { x: viewport.centerX, y: viewport.centerY }, + -sceneRotationDeg(userRotationDeg), + ) + smoothFloorplanNavigationView(localCenter, nextRotationDeg, viewport.width) + publishFloorplanNavigationPose(localCenter, nextRotationDeg, viewport.width) + } + const orbitClockwise = () => orbit(true) + const orbitCounterClockwise = () => orbit(false) + document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) + emitter.on('camera-controls:orbit-cw', orbitClockwise) + emitter.on('camera-controls:orbit-ccw', orbitCounterClockwise) + return () => { + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) + emitter.off('camera-controls:orbit-cw', orbitClockwise) + emitter.off('camera-controls:orbit-ccw', orbitCounterClockwise) + // A key released while 3D owns the keys would otherwise still read as + // held when the plan takes them back; the camera clears its own the same way. + clearKeyboardPanKeys(keys) + if (frame !== null) { + cancelAnimationFrame(frame) + commitFloorplanPan() + } + } + }, [ + applyFloorplanViewportImperatively, + buildingRotationDeg, + commitFloorplanPan, + publishFloorplanNavigationPose, + smoothFloorplanNavigationView, + viewMode, + ]) + useLayoutEffect(() => { flushFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef) }) diff --git a/packages/editor/src/lib/keyboard-pan.test.ts b/packages/editor/src/lib/keyboard-pan.test.ts new file mode 100644 index 0000000000..1c12674663 --- /dev/null +++ b/packages/editor/src/lib/keyboard-pan.test.ts @@ -0,0 +1,62 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { + acceptsKeyboardPan, + clearKeyboardPanKeys, + hasKeyboardPanInput, + isKeyboardPanKey, + type KeyboardPanState, + keyboardPanDirection, + keyboardPanSpeed, + setKeyboardPanKey, +} from './keyboard-pan' + +const idle = (): KeyboardPanState => ({ + forward: false, + backward: false, + left: false, + right: false, +}) +const key = (init: Partial) => ({ target: null, ...init }) as KeyboardEvent + +// No DOM in this runner: stand in for the element classes the editable check reads. +const DOM_CLASSES = ['HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'HTMLSelectElement'] +const stubbed: string[] = [] +beforeAll(() => { + const scope = globalThis as Record + for (const name of DOM_CLASSES) { + if (scope[name]) continue + scope[name] = class {} + stubbed.push(name) + } +}) +afterAll(() => { + for (const name of stubbed) delete (globalThis as Record)[name] +}) + +test('physical WASD keys drive a screen-space direction; letters on other layouts do not', () => { + const state = idle() + expect(isKeyboardPanKey('KeyZ')).toBe(false) + expect(setKeyboardPanKey(state, 'KeyW', true)).toBe(true) + expect(setKeyboardPanKey(state, 'KeyW', true)).toBe(false) + setKeyboardPanKey(state, 'KeyD', true) + expect(keyboardPanDirection(state)).toEqual({ horizontal: 1, vertical: 1 }) + setKeyboardPanKey(state, 'KeyA', true) + expect(keyboardPanDirection(state)).toEqual({ horizontal: 0, vertical: 1 }) + clearKeyboardPanKeys(state) + expect(hasKeyboardPanInput(state)).toBe(false) +}) + +test('modifier chords stay shortcuts, and typing in a field never pans', () => { + expect(acceptsKeyboardPan(key({}))).toBe(true) + for (const modifier of ['metaKey', 'ctrlKey', 'altKey'] as const) + expect(acceptsKeyboardPan(key({ [modifier]: true }))).toBe(false) + const Input = (globalThis as unknown as { HTMLInputElement: new () => EventTarget }) + .HTMLInputElement + expect(acceptsKeyboardPan(key({ target: new Input() }))).toBe(false) +}) + +test('speed scales with the visible width within fixed bounds', () => { + expect(keyboardPanSpeed(0)).toBe(2) + expect(keyboardPanSpeed(20)).toBeCloseTo(13) + expect(keyboardPanSpeed(1000)).toBe(55) +}) diff --git a/packages/editor/src/lib/keyboard-pan.ts b/packages/editor/src/lib/keyboard-pan.ts new file mode 100644 index 0000000000..1427603b27 --- /dev/null +++ b/packages/editor/src/lib/keyboard-pan.ts @@ -0,0 +1,87 @@ +// WASD navigation shared by the 3D camera and the 2D floor plan, so both views +// move the same way. Keys match by physical position (`event.code`): the +// cluster stays under the left hand on any layout (Z/Q/S/D on AZERTY). + +export type KeyboardPanState = { + forward: boolean + backward: boolean + left: boolean + right: boolean +} + +const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65 +const KEYBOARD_PAN_MIN_SPEED = 2 +const KEYBOARD_PAN_MAX_SPEED = 55 + +export function isEditableKeyboardTarget(target: EventTarget | null) { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable) + ) +} + +export function setKeyboardPanKey( + state: KeyboardPanState, + code: string, + pressed: boolean, +): boolean { + if (code === 'KeyW') { + const changed = state.forward !== pressed + state.forward = pressed + return changed + } + if (code === 'KeyS') { + const changed = state.backward !== pressed + state.backward = pressed + return changed + } + if (code === 'KeyA') { + const changed = state.left !== pressed + state.left = pressed + return changed + } + if (code === 'KeyD') { + const changed = state.right !== pressed + state.right = pressed + return changed + } + return false +} + +export function isKeyboardPanKey(code: string): boolean { + return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD' +} + +export function hasKeyboardPanInput(state: KeyboardPanState): boolean { + return state.forward || state.backward || state.left || state.right +} + +export function clearKeyboardPanKeys(state: KeyboardPanState) { + state.forward = false + state.backward = false + state.left = false + state.right = false +} + +/** Pan keys are ignored with a modifier held (shortcuts) or while typing. */ +export function acceptsKeyboardPan(event: KeyboardEvent) { + return !(event.metaKey || event.ctrlKey || event.altKey) && !isEditableKeyboardTarget(event.target) +} + +/** Screen-space direction: `horizontal` +1 is right, `vertical` +1 is forward (up). */ +export function keyboardPanDirection(state: KeyboardPanState) { + return { + horizontal: (state.right ? 1 : 0) - (state.left ? 1 : 0), + vertical: (state.forward ? 1 : 0) - (state.backward ? 1 : 0), + } +} + +/** World units per second for a view `viewWidth` wide. */ +export function keyboardPanSpeed(viewWidth: number) { + return Math.min( + Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED), + KEYBOARD_PAN_MAX_SPEED, + ) +} From 7a18b6a7e9d7f6ce1e4101231656072396c49781 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Mon, 21 Sep 2026 15:34:57 +0200 Subject: [PATCH 2/8] fix(editor): drawing onto an existing wall in 2D places the point instead of selecting A registry entry selected itself on pointer-down and stopped the click, so with the wall tool armed, clicking an existing wall to start or end a T junction selected that wall and the tool never saw the click. Only door and window placement passed through. Entries now yield to the active tool in build mode, matching 3D where the selection manager only runs in select mode; select and delete keep selecting. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017sG15rKXusC8rbBg6gjSRm --- .../floorplan-registry-layer.test.ts | 10 ++++ .../renderers/floorplan-registry-layer.tsx | 47 +++++++++++++------ 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index e89a86edcf..ef3450a3e5 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -25,6 +25,7 @@ import { floorplanAffordanceReshapeScope, floorplanHandleDoubleClickAffordance, InteractiveGeometry, + floorplanEntryYieldsToTool, isFloorplanOpeningPlacementState, resolveFloorplanHandleUnitsPerPixel, siteToFloorplanTransform, @@ -780,3 +781,12 @@ describe('collectFloorplanLinkedLevelNodes', () => { ).toEqual([]) }) }) + +describe('floorplan entry routing while a tool is active', () => { + test('build tools get presses on entries, as in 3D; select and delete keep selecting', () => { + expect(floorplanEntryYieldsToTool({ mode: 'build', openingPlacement: false })).toBe(true) + expect(floorplanEntryYieldsToTool({ mode: 'select', openingPlacement: true })).toBe(true) + expect(floorplanEntryYieldsToTool({ mode: 'select', openingPlacement: false })).toBe(false) + expect(floorplanEntryYieldsToTool({ mode: 'delete', openingPlacement: false })).toBe(false) + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 8f66c21bbb..ba96c12ccb 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -91,7 +91,7 @@ import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import { paintZoneMembership } from '../../../lib/units' import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' import useDrawingView from '../../../store/use-drawing-view' -import useEditor, { isAngleSnapActive } from '../../../store/use-editor' +import useEditor, { isAngleSnapActive, type Mode } from '../../../store/use-editor' import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility' import useFloorplanMode from '../../../store/use-floorplan-mode' import useInteractionScope, { @@ -456,15 +456,32 @@ export function isFloorplanOpeningPlacementState({ ) } -function isFloorplanOpeningPlacementActiveNow(): boolean { +/** + * Whether a press on an entry belongs to the active tool instead of selecting + * the entry. Build tools own the plan the way their 3D tools own the canvas, + * where selection only runs in select mode: a wall drawn onto another wall + * must place its point (T-junction), not select the wall under the cursor. + */ +export function floorplanEntryYieldsToTool(state: { + mode: Mode + openingPlacement: boolean +}): boolean { + return state.mode === 'build' || state.openingPlacement +} + +function floorplanEntryYieldsToToolNow(): boolean { const { phase, mode, tool } = useEditor.getState() const movingNode = getMovingNode() - return isFloorplanOpeningPlacementState({ - phase, + return floorplanEntryYieldsToTool({ mode, - tool, - movingNodeHasWallOpeningPlacement: - movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement, + openingPlacement: isFloorplanOpeningPlacementState({ + phase, + mode, + tool, + movingNodeHasWallOpeningPlacement: + movingNode != null && + !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement, + }), }) } @@ -558,13 +575,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0 const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) - // Door / window placement (both build and move) needs the SVG's - // background click handler to run — it finds the closest wall via - // `findClosestWallPoint` and emits `wall:click` for the door / window - // tool. When the user clicks *on top of* a wall in this mode, the + // Build tools and door / window placement (both build and move) need the + // SVG's background handlers to run — wall drafting places its point there, + // and opening placement finds the closest wall via `findClosestWallPoint` + // and emits `wall:click`. When the user clicks *on top of* a wall, the // wall's registry entry would otherwise swallow the click via - // `handleClickStop` / `handleSelect`, so the placement never fires. - // Pass clicks through in that case. + // `handleClickStop` / `handleSelect`, so the tool never sees it. Pass + // clicks through in that case (`floorplanEntryYieldsToToolNow`). const editorMode = useEditor((s) => s.mode) const structureLayer = useEditor((s) => s.structureLayer) const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool) @@ -722,7 +739,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ) const handleClickStop = useCallback((event: React.MouseEvent) => { - if (isFloorplanOpeningPlacementActiveNow()) return + if (floorplanEntryYieldsToToolNow()) return event.stopPropagation() }, []) @@ -983,7 +1000,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // the stores at event time. Commit clears the interaction scope before // React paints the next frame; a render-time `undefined` handler leaves // a short dead zone where the first post-placement selection is lost. - if (isFloorplanOpeningPlacementActiveNow()) return + if (floorplanEntryYieldsToToolNow()) return if (startDirectMoveDrag(id, event)) return if (startDirectRotateDrag(id, event)) return if (startGroupMoveDrag(id, event)) return From 08482bf6c3e01c54656e6fcbaaabea8f561ebcff Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Mon, 21 Sep 2026 23:04:05 +0200 Subject: [PATCH 3/8] fix(nodes): openings place on hidden walls and keep their metadata when moved The window tool only heard wall raycasts, so with walls hidden (cutaway, 2D-driven placement) it could not place: grid events now carry the surface hit under the pointer and the tool resolves the wall from it (wallEventFromGrid). Moving a door or window rewrote its metadata to {}, dropping persistent ownership (arrays, assets); commitOpeningMove strips only the draft flags. A transient preview child never hosts a wall attachment. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017sG15rKXusC8rbBg6gjSRm --- packages/editor/src/hooks/use-grid-events.ts | 25 +++-- packages/nodes/src/door/move-tool.tsx | 7 +- .../nodes/src/shared/commit-opening-move.ts | 9 ++ .../nodes/src/shared/wall-attach-target.ts | 2 +- .../src/shared/wall-event-from-grid.test.ts | 104 ++++++++++++++++++ .../nodes/src/shared/wall-event-from-grid.ts | 46 ++++++++ packages/nodes/src/window/move-tool.tsx | 10 +- packages/nodes/src/window/tool.tsx | 24 ++++ 8 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 packages/nodes/src/shared/commit-opening-move.ts create mode 100644 packages/nodes/src/shared/wall-event-from-grid.test.ts create mode 100644 packages/nodes/src/shared/wall-event-from-grid.ts diff --git a/packages/editor/src/hooks/use-grid-events.ts b/packages/editor/src/hooks/use-grid-events.ts index 3e4fd8802e..ef671f443b 100644 --- a/packages/editor/src/hooks/use-grid-events.ts +++ b/packages/editor/src/hooks/use-grid-events.ts @@ -3,6 +3,7 @@ import { type EventSuffix, emitter, type GridEvent, + hiddenWallPointerEventsHeld, nodeRegistry, sceneRegistry, useScene, @@ -132,7 +133,10 @@ export function useGridEvents(gridY: number) { // Architectural meshes are expensive to raycast and are meaningful only // to an active placement/drafting interaction. Floor tools retain the // ordinary terrain/grid intersection without scanning every wall. - const surfaceHit = semanticSurfaceQueryRef.current ? getSurfaceIntersection() : null + const surfaceHit = + semanticSurfaceQueryRef.current || hiddenWallPointerEventsHeld() + ? getSurfaceIntersection() + : null // A semantic architectural hit is the authoritative cursor position. // Do not replace it with the terrain/grid intersection below: that would @@ -244,16 +248,15 @@ export function useGridEvents(gridY: number) { : undefined, surfaceNormal: localNormal ? [localNormal.x, localNormal.y, localNormal.z] : undefined, surfaceObject: point.surface?.object, - surfaceHit: - semanticSurfaceQueryRef.current && point.surface - ? { - kind: point.surface.descriptor.kind, - hostId: point.surface.hostId, - face: classifiedFace?.face ?? 'unknown', - levelId: useViewer.getState().selection.levelId ?? undefined, - side: classifiedFace?.side, - } - : undefined, + surfaceHit: point.surface + ? { + kind: point.surface.descriptor.kind, + hostId: point.surface.hostId, + face: classifiedFace?.face ?? 'unknown', + levelId: useViewer.getState().selection.levelId ?? undefined, + side: classifiedFace?.side, + } + : undefined, nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent } diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 479ba7eed6..228bbde641 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -1,3 +1,4 @@ +import { commitOpeningMove } from '../shared/commit-opening-move' import { type AnyNodeId, DoorNode, @@ -573,14 +574,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => }) history.commitStep(() => { - useScene.getState().updateNode(movingDoorNode.id, { + commitOpeningMove(movingDoorNode.id, { position: [target.clampedX, target.clampedY, 0], rotation: [0, target.itemRotation, 0], side: target.side, parentId: target.wallId, wallId: target.wallId, roofSegmentId: undefined, - metadata: {}, visible: true, }) }) @@ -835,7 +835,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => }) history.commitStep(() => { - useScene.getState().updateNode(movingDoorNode.id, { + commitOpeningMove(movingDoorNode.id, { position: target.position, rotation: [0, 0, 0], side: 'front', @@ -843,7 +843,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: undefined, roofSegmentId: segmentId, roofFace: target.face.id, - metadata: {}, visible: true, }) }) diff --git a/packages/nodes/src/shared/commit-opening-move.ts b/packages/nodes/src/shared/commit-opening-move.ts new file mode 100644 index 0000000000..23c38c4e5b --- /dev/null +++ b/packages/nodes/src/shared/commit-opening-move.ts @@ -0,0 +1,9 @@ +import { type AnyNodeId, type DoorNode, type WindowNode, useScene } from '@pascal-app/core' + +/** A move changes placement, not persistent ownership (Array, assets, etc.). */ +export function commitOpeningMove(id: AnyNodeId, patch: Partial) { + const node = useScene.getState().nodes[id] + if (!node || (node.type !== 'window' && node.type !== 'door')) return + const { isNew: _new, isTransient: _transient, ...metadata } = node.metadata + useScene.getState().updateNode(id, { ...patch, metadata }) +} diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index 39fe520206..3c16647d1d 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -353,7 +353,7 @@ export function hasWallChildOverlap( for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { if (childId === ignoreId) continue const child = nodes[childId as AnyNodeId] - if (!child) continue + if (!child || child.metadata.isTransient) continue let childLeft: number let childRight: number diff --git a/packages/nodes/src/shared/wall-event-from-grid.test.ts b/packages/nodes/src/shared/wall-event-from-grid.test.ts new file mode 100644 index 0000000000..4f5035afb1 --- /dev/null +++ b/packages/nodes/src/shared/wall-event-from-grid.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from 'bun:test' +import { BuildingNode, type GridEvent, LevelNode, WallNode } from '@pascal-app/core' +import { BoxGeometry, Group, Matrix3, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { wallEventFromGrid } from './wall-event-from-grid' + +function fixture() { + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: building.id }) + const wall = WallNode.parse({ parentId: level.id, start: [2, 4], end: [2, 14] }) + const frame = new Group() + const object = new Mesh(new BoxGeometry(10, 3, 0.2), new MeshBasicMaterial()) + frame.position.set(20, 6, -10) + frame.rotation.y = 0.63 + object.position.set(2, 0.2, 4) + object.rotation.y = -Math.PI / 2 + frame.add(object) + frame.updateMatrixWorld(true) + const point = object.localToWorld(new Vector3(2, 1.4, 0.1)) + const normal = new Vector3(0, 0, 1).applyNormalMatrix( + new Matrix3().getNormalMatrix(object.matrixWorld), + ) + const localNormal = normal + .clone() + .applyNormalMatrix(new Matrix3().getNormalMatrix(frame.matrixWorld.clone().invert())) + const event: GridEvent = { + position: point.toArray(), + localPosition: frame.worldToLocal(point.clone()).toArray(), + localFrameId: building.id, + surfaceNormal: localNormal.toArray(), + surfaceHit: { kind: 'wall', hostId: wall.id, face: 'side' }, + nativeEvent: { timeStamp: 42 } as GridEvent['nativeEvent'], + } + return { + building, + level, + wall, + frame, + object, + point, + normal, + event, + nodes: { [building.id]: building, [level.id]: level, [wall.id]: wall }, + objects: new Map([ + [building.id, frame], + [wall.id, object], + ]), + } +} + +test('an actual batched wall surface gives Window a wall-local target without a wall mesh event', () => { + const f = fixture() + f.object.layers.set(5) + const ray = new Raycaster(f.point.clone().addScaledVector(f.normal, 3), f.normal.clone().negate()) + ray.layers.enable(5) + const hit = ray.intersectObject(f.object)[0]! + expect(hit).toBeDefined() + const event = wallEventFromGrid( + { ...f.event, position: hit.point.toArray() }, + f.level.id, + f.nodes, + f.objects, + )! + expect(event.node.id).toBe(f.wall.id) + expect(new Vector3(...event.localPosition).distanceTo(new Vector3(2, 1.4, 0.1))).toBeLessThan( + 1e-8, + ) + expect(new Vector3(...event.normal!).distanceTo(new Vector3(0, 0, 1))).toBeLessThan(1e-8) + expect(event.nativeEvent.timeStamp).toBe(42) +}) + +test('fallback placement still rejects other floors, non-wall surfaces, tops and curved walls', () => { + const f = fixture() + expect(wallEventFromGrid(f.event, LevelNode.parse({}).id, f.nodes, f.objects)).toBeNull() + expect( + wallEventFromGrid({ ...f.event, surfaceHit: undefined }, f.level.id, f.nodes, f.objects), + ).toBeNull() + expect( + wallEventFromGrid( + { ...f.event, surfaceHit: { kind: 'slab', hostId: f.wall.id, face: 'top' } }, + f.level.id, + f.nodes, + f.objects, + ), + ).toBeNull() + expect( + wallEventFromGrid({ ...f.event, surfaceNormal: [0, 1, 0] }, f.level.id, f.nodes, f.objects), + ).toBeNull() + expect( + wallEventFromGrid( + f.event, + f.level.id, + { ...f.nodes, [f.wall.id]: { ...f.wall, curveOffset: 1 } }, + f.objects, + ), + ).toBeNull() + expect( + wallEventFromGrid( + f.event, + f.level.id, + { ...f.nodes, [f.wall.id]: { ...f.wall, visible: false } }, + f.objects, + ), + ).toBeNull() +}) diff --git a/packages/nodes/src/shared/wall-event-from-grid.ts b/packages/nodes/src/shared/wall-event-from-grid.ts new file mode 100644 index 0000000000..501bc16d50 --- /dev/null +++ b/packages/nodes/src/shared/wall-event-from-grid.ts @@ -0,0 +1,46 @@ +import { + type AnyNode, + type AnyNodeId, + type GridEvent, + isCurvedWall, + type WallEvent, +} from '@pascal-app/core' +import { Matrix3, type Object3D, Vector3 } from 'three' + +/** Canvas surface queries still reach the host when a rendered child consumes mesh events. */ +export function wallEventFromGrid( + event: GridEvent, + activeLevelId: AnyNodeId | null, + nodes: Record, + objects: ReadonlyMap, +): WallEvent | null { + if (event.surfaceHit?.kind !== 'wall' || !event.surfaceNormal) return null + const wall = nodes[event.surfaceHit.hostId] + if ( + wall?.type !== 'wall' || + wall.visible === false || + wall.metadata.isTransient || + wall.parentId !== activeLevelId || + isCurvedWall(wall) + ) + return null + const object = objects.get(wall.id) + const frame = event.localFrameId ? objects.get(event.localFrameId) : undefined + if (!object || (event.localFrameId && !frame)) return null + object.updateWorldMatrix(true, false) + frame?.updateWorldMatrix(true, false) + const normal = new Vector3(...event.surfaceNormal) + if (frame) normal.applyNormalMatrix(new Matrix3().getNormalMatrix(frame.matrixWorld)) + normal.applyNormalMatrix(new Matrix3().getNormalMatrix(object.matrixWorld.clone().invert())) + if (Math.abs(normal.z) <= 0.7) return null + const position = object.worldToLocal(new Vector3(...event.position)) + return { + node: wall, + object, + position: event.position, + localPosition: position.toArray(), + normal: normal.toArray(), + nativeEvent: event.nativeEvent, + stopPropagation: () => {}, + } +} diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index ee9c383397..07f8faed09 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,3 +1,4 @@ +import { commitOpeningMove } from '../shared/commit-opening-move' import { type AnyNodeId, type DormerEvent, @@ -642,14 +643,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) history.commitStep(() => { - useScene.getState().updateNode(movingWindowNode.id, { + commitOpeningMove(movingWindowNode.id, { position: [target.clampedX, target.clampedY, 0], rotation: [0, target.itemRotation, 0], side: target.side, parentId: target.wallId, wallId: target.wallId, roofSegmentId: undefined, - metadata: {}, visible: true, }) }) @@ -892,7 +892,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode visible: original.visible, }) history.commitStep(() => { - useScene.getState().updateNode(movingWindowNode.id, { + commitOpeningMove(movingWindowNode.id, { position: target.position, rotation, side, @@ -902,7 +902,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: undefined, roofSegmentId: undefined, roofFace: undefined, - metadata: {}, visible: true, }) }) @@ -1102,7 +1101,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) history.commitStep(() => { - useScene.getState().updateNode(movingWindowNode.id, { + commitOpeningMove(movingWindowNode.id, { position: target.position, rotation: [0, 0, 0], side: 'front', @@ -1110,7 +1109,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: undefined, roofSegmentId: segmentId, roofFace: target.face.id, - metadata: {}, visible: true, }) }) diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index feec01c2dd..37c4f1583e 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -58,6 +58,7 @@ import { resolveRoofWallOpeningTarget, worldToSelectedBuildingLocal, } from '../shared/roof-wall-opening-placement' +import { wallEventFromGrid } from '../shared/wall-event-from-grid' import { collectWallOpeningAlignmentCandidates, resolveWallSlideAlignment, @@ -741,6 +742,16 @@ const WindowTool: React.FC = () => { // floor follow this tick. const ts = event.nativeEvent?.timeStamp ?? -1 if (ts === lastMeshEventTime) return + const wallEvent = wallEventFromGrid( + event, + activeLevelId, + useScene.getState().nodes, + sceneRegistry.nodes, + ) + if (wallEvent) { + onWallHover(wallEvent) + return + } // Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft // and free-follow the cursor with the invalid (unplaceable) ghost. hostKind = null @@ -750,6 +761,17 @@ const WindowTool: React.FC = () => { showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], y) } + const onGridPointerUp = (event: GridEvent) => { + if (isCameraDragging() || !draftRef.current) return + const wallEvent = wallEventFromGrid( + event, + activeLevelId, + useScene.getState().nodes, + sceneRegistry.nodes, + ) + if (wallEvent) onWallClick(wallEvent) + } + // ── Dormer wall faces ────────────────────────────────────────── // Dormer windows use the same WindowNode mesh and inspector as regular // windows, but their host frame is supplied by DormerRenderer. @@ -1011,6 +1033,7 @@ const WindowTool: React.FC = () => { emitter.on('window:click', onDormerWindowClick) emitter.on('window:leave', onDormerWindowLeave) emitter.on('grid:move', onGridFreeFollow) + emitter.on('grid:pointerup', onGridPointerUp) emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) // Placement tracks the cursor through wall events; keep walls hidden by @@ -1044,6 +1067,7 @@ const WindowTool: React.FC = () => { emitter.off('window:click', onDormerWindowClick) emitter.off('window:leave', onDormerWindowLeave) emitter.off('grid:move', onGridFreeFollow) + emitter.off('grid:pointerup', onGridPointerUp) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) } From 9a1b7e7365d249c75b6e11b2592f60d4258106e2 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Mon, 21 Sep 2026 23:04:05 +0200 Subject: [PATCH 4/8] fix(editor): the mobile layout marks the viewer bounds for panel clamping panel-wrapper clamps floating panels to the closest [data-viewer-bounds]; the mobile layout never set it, so panels could slide under its chrome. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017sG15rKXusC8rbBg6gjSRm --- packages/editor/src/components/editor/editor-layout-mobile.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/editor/src/components/editor/editor-layout-mobile.tsx b/packages/editor/src/components/editor/editor-layout-mobile.tsx index 093a2f9fd1..07a1444603 100644 --- a/packages/editor/src/components/editor/editor-layout-mobile.tsx +++ b/packages/editor/src/components/editor/editor-layout-mobile.tsx @@ -236,6 +236,7 @@ export function EditorLayoutMobile({
{viewerContent}
{overlays && (
From 365e6412f041a4c324df999a9fa8f1f55fa91516 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Mon, 21 Sep 2026 16:02:32 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20wall=20lifecycle=20=E2=80=94=20rect?= =?UTF-8?q?angle=20walls,=20loop-cut=20split=20and=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rectangle walls: R toggles line/rectangle inside the wall tool, shown as a Shape chip in the HUD; in 2D the rectangle drafts through the panel's own cursor, snapping, mitered footprints and measurement plates, and in 3D it keeps the wall icon and the line draft's look. A side drawn along an existing wall adds only the uncovered remainder (uncoveredWallSegments). - Split: a HUD-driven loop cut. Scrolling sets 1-32 cuts, one cut follows the pointer with the snapping modes (Shift/Ctrl/Alt as elsewhere), several divide the wall evenly, and a click commits them as one undo step (core planWallDivision/planWallDivisions). - Merge: selecting walls that continue each other shows Merge next to Split. core planWallMerge keeps the wall with the most attachments and never turns it around; an absorbed wall read backwards mirrors its children's local frame (side, depth, yaw); every child is re-hosted and rooms follow. - Openings keep their metadata when moved, transient previews never host walls, and windows place through grid events on hidden walls. Behaviour changes on main that come with the shared planners: - planWallInsertion (the line tool too) rewrites a room's boundaryWallIds when it splits one of its walls; the room used to keep the removed id. - The delete heal's joining rule and attachment re-hosting moved into systems/wall/wall-merge.ts; at a start-to-start joint the heal no longer reverses the kept wall. Split and merge live in packages/nodes/src/wall/ (session, store, preview, pointer, 3D tool, plan layer, actions). The editor only gains kind-agnostic seams: a reshape name without a dedicated ToolManager arm mounts def.affordanceTools[reshape]; the plan mounts the floorplan extension's reshapeLayers[reshape] during that scope; HelperManager renders def.affordanceHints[reshape]; NodeActionMenu renders the floorplan extension's actionMenu.actions for the selected kinds. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017sG15rKXusC8rbBg6gjSRm --- packages/core/src/index.ts | 7 + packages/core/src/registry/types.ts | 7 + .../core/src/store/actions/node-actions.ts | 148 +------- .../core/src/systems/wall/wall-merge.test.ts | 248 ++++++++++++++ packages/core/src/systems/wall/wall-merge.ts | 316 ++++++++++++++++++ .../src/systems/wall/wall-operations.test.ts | 254 ++++++++++++++ .../core/src/systems/wall/wall-operations.ts | 176 ++++++++++ .../src/systems/wall/wall-topology.test.ts | 32 +- .../core/src/systems/wall/wall-topology.ts | 70 +++- .../floorplan-registered-tool-layer.tsx | 45 ++- .../floorplan-draft-wall-measurement.tsx | 166 +++++++++ .../floorplan-registry-layer.test.ts | 2 +- .../src/components/editor/floorplan-panel.tsx | 253 +++++--------- .../components/editor/node-action-menu.tsx | 2 + .../editor/registry-action-contributions.tsx | 53 +++ .../tools/shared/draft-measurement-label.tsx | 38 +++ .../src/components/tools/tool-manager.tsx | 31 ++ .../components/ui/helpers/helper-manager.tsx | 12 + .../ui/primitives/shortcut-token.tsx | 4 + .../editor/src/hooks/use-keyboard.test.ts | 7 + packages/editor/src/hooks/use-keyboard.ts | 10 +- packages/editor/src/index.tsx | 12 + .../src/lib/floorplan/floorplan-extension.ts | 15 + packages/editor/src/lib/interaction/scope.ts | 10 +- packages/editor/src/lib/keyboard-pan.ts | 4 +- .../src/store/use-floorplan-draft-preview.ts | 11 +- packages/nodes/src/door/move-tool.tsx | 2 +- packages/nodes/src/fence/tool.tsx | 2 +- .../nodes/src/shared/commit-opening-move.ts | 2 +- .../nodes/src/shared/draft-axis-guides.tsx | 33 +- packages/nodes/src/wall/actions.tsx | 97 ++++++ packages/nodes/src/wall/definition.ts | 61 ++++ packages/nodes/src/wall/drawing-mode.ts | 42 +++ packages/nodes/src/wall/floorplan-tool.tsx | 123 +++++++ packages/nodes/src/wall/rectangle-command.ts | 36 ++ packages/nodes/src/wall/rectangle-tool.tsx | 222 ++++++++++++ .../nodes/src/wall/split-floorplan-layer.tsx | 129 +++++++ packages/nodes/src/wall/split-pointer.test.ts | 101 ++++++ packages/nodes/src/wall/split-pointer.ts | 75 +++++ packages/nodes/src/wall/split-preview.test.ts | 140 ++++++++ packages/nodes/src/wall/split-preview.ts | 163 +++++++++ packages/nodes/src/wall/split-session.ts | 156 +++++++++ packages/nodes/src/wall/split-store.test.ts | 103 ++++++ packages/nodes/src/wall/split-store.ts | 21 ++ packages/nodes/src/wall/split-tool.tsx | 181 ++++++++++ packages/nodes/src/wall/tool.tsx | 22 +- packages/nodes/src/window/move-tool.tsx | 2 +- 47 files changed, 3276 insertions(+), 370 deletions(-) create mode 100644 packages/core/src/systems/wall/wall-merge.test.ts create mode 100644 packages/core/src/systems/wall/wall-merge.ts create mode 100644 packages/core/src/systems/wall/wall-operations.test.ts create mode 100644 packages/core/src/systems/wall/wall-operations.ts create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx create mode 100644 packages/editor/src/components/editor/registry-action-contributions.tsx create mode 100644 packages/editor/src/components/tools/shared/draft-measurement-label.tsx create mode 100644 packages/nodes/src/wall/actions.tsx create mode 100644 packages/nodes/src/wall/drawing-mode.ts create mode 100644 packages/nodes/src/wall/floorplan-tool.tsx create mode 100644 packages/nodes/src/wall/rectangle-command.ts create mode 100644 packages/nodes/src/wall/rectangle-tool.tsx create mode 100644 packages/nodes/src/wall/split-floorplan-layer.tsx create mode 100644 packages/nodes/src/wall/split-pointer.test.ts create mode 100644 packages/nodes/src/wall/split-pointer.ts create mode 100644 packages/nodes/src/wall/split-preview.test.ts create mode 100644 packages/nodes/src/wall/split-preview.ts create mode 100644 packages/nodes/src/wall/split-session.ts create mode 100644 packages/nodes/src/wall/split-store.test.ts create mode 100644 packages/nodes/src/wall/split-store.ts create mode 100644 packages/nodes/src/wall/split-tool.tsx diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b952a0b253..7df199bbfb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -454,6 +454,7 @@ export { getWallPlanFootprint, getWallThickness, } from './systems/wall/wall-footprint' +export { planWallMerge } from './systems/wall/wall-merge' export { calculateLevelMiters, getAdjacentWallIds, @@ -475,6 +476,12 @@ export { type WallMoveLinkedWallTargetPlan, type WallPlanPoint, } from './systems/wall/wall-move' +export { + planWallDivision, + planWallDivisions, + planWallRectangle, + wallRectangleCorners, +} from './systems/wall/wall-operations' export { MIN_WALL_HEIGHT, resolveWallEffectiveHeight, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index b8dcd751e2..afe938abd2 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1411,6 +1411,13 @@ export type NodeDefinition> = { */ toolHints?: ToolHint[] + /** + * HUD hints for the kind's own reshapes, keyed by reshape name (see + * `affordanceTools`): shown while a node of this kind is in that `reshaping` + * scope, with the same chips and visibility rules as `toolHints`. + */ + affordanceHints?: Record + /** * Pick-one option rows for this kind's build tool, rendered by the shared * `` in whichever sidebar the host mounts it (see diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index a2d8e93999..44d4970bc9 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -14,8 +14,6 @@ import { type GutterNode, generateId, getDefaultGutterSide, - getEffectiveWallSurfaceMaterial, - getWallSurfaceMaterialSignature, isAutoGutterEnabled, isAutoRidgeVentEnabled, isDefaultDownspoutNode, @@ -29,6 +27,14 @@ import { } from '../../schema' import type { CollectionId } from '../../schema/collections' import { constrainWallCurveOffsetToAvoidIntersections } from '../../systems/wall/wall-curve' +import { + areWallStylesCompatible, + areWallsCollinearAcrossPoint, + buildMergedWallAttachmentUpdates, + getWallEndpointAtPoint, + resolveMergedWallEndpoints, + type WallAttachmentUpdate, +} from '../../systems/wall/wall-merge' import { activeSceneCommitNodeIds, addActiveSceneCommitNodeIds, @@ -40,7 +46,6 @@ type AnyContainerNode = AnyNode & { children: string[] } type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId } type NodeUpdateOp = { id: AnyNodeId; data: Partial } type NodeDeleteOp = AnyNodeId -type WallAttachmentUpdate = { id: AnyNodeId; data: Partial } type WallMergePlan = { primaryWallId: AnyNodeId secondaryWallId: AnyNodeId @@ -1008,141 +1013,6 @@ function refreshDefaultGuttersForRoofIds( let pendingRafId: number | null = null let pendingUpdates: Set = new Set() -function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) { - const dx = a[0] - b[0] - const dz = a[1] - b[1] - return dx * dx + dz * dz <= tolerance * tolerance -} - -function wallLength(wall: Pick) { - return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) -} - -function getWallEndpointAtPoint( - wall: Pick, - point: [number, number], -): 'start' | 'end' | null { - if (pointsEqual(wall.start, point)) return 'start' - if (pointsEqual(wall.end, point)) return 'end' - return null -} - -function getWallFreeEndpoint(wall: Pick, sharedPoint: [number, number]) { - return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start -} - -function areWallStylesCompatible(a: WallNode, b: WallNode) { - const aInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'interior')) - const bInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'interior')) - const aExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'exterior')) - const bExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'exterior')) - - return ( - (a.parentId ?? null) === (b.parentId ?? null) && - Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && - Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && - // Absent height means plane-bound (follows the storey), which must never - // merge with an explicit height — even one that currently matches the plane. - (a.height == null) === (b.height == null) && - Math.abs((a.height ?? 0) - (b.height ?? 0)) <= 1e-6 && - aInterior === bInterior && - aExterior === bExterior && - a.frontSide === b.frontSide && - a.backSide === b.backSide && - a.visible === b.visible - ) -} - -function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) { - const freeA = getWallFreeEndpoint(a, sharedPoint) - const freeB = getWallFreeEndpoint(b, sharedPoint) - const ax = freeA[0] - sharedPoint[0] - const az = freeA[1] - sharedPoint[1] - const bx = freeB[0] - sharedPoint[0] - const bz = freeB[1] - sharedPoint[1] - const lenA = Math.hypot(ax, az) - const lenB = Math.hypot(bx, bz) - - if (lenA < 1e-6 || lenB < 1e-6) return false - - const cross = (ax * bz - az * bx) / (lenA * lenB) - const dot = (ax * bx + az * bz) / (lenA * lenB) - return Math.abs(cross) <= 1e-4 && dot < -0.999 -} - -function resolveMergedWallEndpoints( - primary: WallNode, - secondary: WallNode, - sharedPoint: [number, number], -): { start: [number, number]; end: [number, number] } { - const primaryEndpoint = getWallEndpointAtPoint(primary, sharedPoint) - const secondaryEndpoint = getWallEndpointAtPoint(secondary, sharedPoint) - - if (primaryEndpoint === 'end' && secondaryEndpoint === 'start') { - return { start: primary.start, end: secondary.end } - } - if (primaryEndpoint === 'start' && secondaryEndpoint === 'end') { - return { start: secondary.start, end: primary.end } - } - if (primaryEndpoint === 'start' && secondaryEndpoint === 'start') { - return { start: primary.end, end: secondary.end } - } - - return { start: primary.start, end: secondary.start } -} - -function buildMergedWallAttachmentUpdates( - primary: WallNode, - secondary: WallNode, - mergedWallId: AnyNodeId, - mergedStart: [number, number], - mergedEnd: [number, number], - nodes: Record, -): WallAttachmentUpdate[] { - const mergedLength = Math.max( - Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), - 1e-6, - ) - const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength - const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength - const updates: WallAttachmentUpdate[] = [] - - const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[] - for (const childId of wallChildren) { - const child = nodes[childId] - if (!(child && 'position' in child && Array.isArray(child.position))) { - continue - } - - const sourceWall = child.parentId === secondary.id ? secondary : primary - const sourceLength = Math.max(wallLength(sourceWall), 1e-6) - const localX = typeof child.position[0] === 'number' ? child.position[0] : 0 - const worldX = - sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength - const worldZ = - sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength - const nextLocalX = Math.max( - 0, - Math.min( - mergedLength, - (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ, - ), - ) - - updates.push({ - id: childId, - data: { - parentId: mergedWallId, - wallId: mergedWallId, - position: [nextLocalX, child.position[1], child.position[2]] as typeof child.position, - ...('wallT' in child ? { wallT: nextLocalX / mergedLength } : {}), - } as Partial, - }) - } - - return updates -} - function buildWallMergePlans( nodes: Record, idsToDelete: AnyNodeId[], @@ -1162,7 +1032,7 @@ function buildWallMergePlans( if (node?.type !== 'wall') return false if (skippedWallIds.has(node.id) || usedWallIds.has(node.id)) return false if ((node.parentId ?? null) !== (deletedWall.parentId ?? null)) return false - return pointsEqual(node.start, junction) || pointsEqual(node.end, junction) + return getWallEndpointAtPoint(node, junction) !== null }) if (candidates.length !== 2) { diff --git a/packages/core/src/systems/wall/wall-merge.test.ts b/packages/core/src/systems/wall/wall-merge.test.ts new file mode 100644 index 0000000000..e39218f1e4 --- /dev/null +++ b/packages/core/src/systems/wall/wall-merge.test.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + DoorNode, + LevelNode, + WallNode, + WindowNode, + ZoneNode, +} from '../../schema' +import useScene from '../../store/use-scene' +import { planWallMerge } from './wall-merge' +import { planWallDivision } from './wall-operations' +import type { WallTopologyChanges } from './wall-topology' + +const level = LevelNode.parse({ id: 'level_merge', children: [] }) +const map = (nodes: AnyNode[]) => + Object.fromEntries([level, ...nodes].map((n) => [n.id, n])) as Record +const apply = (nodes: Record, changes: WallTopologyChanges) => { + const next = { ...nodes } + for (const { node, parentId } of changes.create) + next[node.id] = (parentId ? { ...node, parentId } : node) as AnyNode + for (const { id, data } of changes.update) next[id] = { ...next[id]!, ...data } as AnyNode + for (const id of changes.delete) delete next[id] + return next +} +const wall = (start: [number, number], end: [number, number], extra: Partial = {}) => + WallNode.parse({ parentId: level.id, start, end, ...extra }) + +describe('explicit wall merge', () => { + test('undoes a split: one wall again, openings back where they were', () => { + const original = wall([0, 0], [8, 0]) + const window = WindowNode.parse({ + parentId: original.id, + wallId: original.id, + position: [7, 1.5, 0], + width: 1, + }) + original.children = [window.id] + const split = apply( + map([original, window]), + planWallDivision(map([original, window]), original.id, 3).changes, + ) + const ids = Object.values(split) + .filter((n): n is WallNode => n.type === 'wall') + .map((w) => w.id) + expect(ids).toHaveLength(2) + + const plan = planWallMerge(split, ids) + const merged = apply(split, plan.changes) + const walls = Object.values(merged).filter((n): n is WallNode => n.type === 'wall') + expect(walls).toHaveLength(1) + expect([walls[0]!.start, walls[0]!.end]).toEqual([ + [0, 0], + [8, 0], + ]) + expect(plan.wallId).toBe(walls[0]!.id) + const moved = merged[window.id] as WindowNode + expect(moved.parentId).toBe(plan.wallId) + expect(moved.position).toEqual([7, 1.5, 0]) + expect(split[ids[0]!]).toBeDefined() + }) + + test('a straight run of three becomes one wall and rooms keep one reference', () => { + const a = wall([0, 0], [2, 0]) + const b = wall([2, 0], [5, 0]) + const c = wall([5, 0], [9, 0]) + const zone = ZoneNode.parse({ + name: 'Room', + parentId: level.id, + polygon: [ + [0, 0], + [9, 0], + [9, 4], + [0, 4], + ], + boundaryWallIds: [a.id, b.id, c.id], + }) + const nodes = map([a, b, c, zone]) + const plan = planWallMerge(nodes, [c.id, a.id, b.id]) + const merged = apply(nodes, plan.changes) + const kept = merged[plan.wallId] as WallNode + expect([kept.start, kept.end].sort()).toEqual([ + [0, 0], + [9, 0], + ]) + expect(plan.changes.delete).toHaveLength(2) + expect((merged[zone.id] as ZoneNode).boundaryWallIds).toEqual([plan.wallId]) + }) + + test('walls that look alike merge even when room sides or height mode differ', () => { + const storeyLevel = { ...level, height: 3 } as AnyNode + const a = wall([0, 0], [4, 0], { frontSide: 'interior', backSide: 'exterior' }) + const b = wall([4, 0], [8, 0], { height: 3 }) + const nodes = { + ...map([a, b]), + [level.id]: storeyLevel, + } as Record + expect(() => planWallMerge(nodes, [a.id, b.id])).not.toThrow() + const taller = wall([4, 0], [8, 0], { height: 3.5 }) + expect(() => + planWallMerge( + { ...map([a, taller]), [level.id]: storeyLevel } as Record, + [a.id, taller.id], + ), + ).toThrow('These walls have a different height.') + }) + + test('refuses anything that would change the layout', () => { + const a = wall([0, 0], [4, 0]) + const b = wall([4, 0], [8, 0]) + const tee = wall([4, 0], [4, 3]) + expect(() => planWallMerge(map([a, b, tee]), [a.id, b.id])).toThrow('Another wall') + const corner = wall([4, 0], [4, 4]) + expect(() => planWallMerge(map([a, corner]), [a.id, corner.id])).toThrow('straight line') + const thick = wall([4, 0], [8, 0], { thickness: 0.3 }) + expect(() => planWallMerge(map([a, thick]), [a.id, thick.id])).toThrow( + 'These walls have a different thickness.', + ) + const curved = wall([4, 0], [8, 0], { curveOffset: 0.5 }) + expect(() => planWallMerge(map([a, curved]), [a.id, curved.id])).toThrow('straight walls') + const apart = wall([5, 0], [8, 0]) + expect(() => planWallMerge(map([a, apart]), [a.id, apart.id])).toThrow('end to end') + expect(() => planWallMerge(map([a]), [a.id])).toThrow('two or more') + }) +}) + +describe('explicit wall merge through the store', () => { + let saved: ReturnType + const raf = globalThis.requestAnimationFrame + beforeEach(() => { + saved = useScene.getState() + globalThis.requestAnimationFrame = () => 0 + }) + afterEach(() => { + useScene.setState(saved) + globalThis.requestAnimationFrame = raf + }) + + test('the absorbed wall is deleted without taking its moved window along', () => { + const kept = wall([0, 0], [4, 0]) + const absorbed = wall([4, 0], [8, 0]) + const windows = [ + WindowNode.parse({ parentId: kept.id, wallId: kept.id, position: [1, 1.5, 0], width: 0.8 }), + WindowNode.parse({ parentId: kept.id, wallId: kept.id, position: [3, 1.5, 0], width: 0.8 }), + WindowNode.parse({ + parentId: absorbed.id, + wallId: absorbed.id, + position: [2, 1.5, 0], + width: 0.8, + }), + ] + kept.children = [windows[0]!.id, windows[1]!.id] + absorbed.children = [windows[2]!.id] + const levelNode = { ...level, children: [kept.id, absorbed.id] } + useScene.setState({ + nodes: Object.fromEntries( + [levelNode, kept, absorbed, ...windows].map((n) => [n.id, n]), + ) as Record, + rootNodeIds: [level.id], + readOnly: false, + }) + + const plan = planWallMerge(useScene.getState().nodes, [kept.id, absorbed.id]) + expect(plan.wallId).toBe(kept.id) + useScene.getState().applyNodeChanges(plan.changes) + + const nodes = useScene.getState().nodes + expect(nodes[absorbed.id]).toBeUndefined() + const merged = nodes[kept.id] as WallNode + expect(merged.end).toEqual([8, 0]) + expect(merged.children).toEqual(windows.map((w) => w.id)) + const moved = nodes[windows[2]!.id] as WindowNode + expect(moved.parentId).toBe(kept.id) + expect(moved.position).toEqual([6, 1.5, 0]) + expect((nodes[level.id] as LevelNode).children).toEqual([kept.id]) + }) + test('walls meeting start-to-start or end-to-end keep the kept wall facing; absorbed openings mirror', () => { + for (const [kept, absorbed, absorbedWindowX, expectedX, expectedRun] of [ + // Kept runs 4→8, absorbed runs 4→0: merged must still run 0→8. + [wall([4, 0], [8, 0]), wall([4, 0], [0, 0]), 1, 3, [0, 8]], + // Kept runs 0→4, absorbed runs 8→4. + [wall([0, 0], [4, 0]), wall([8, 0], [4, 0]), 1, 7, [0, 8]], + ] as const) { + const keptWindows = [0.5, 2, 3.5].map((x) => + WindowNode.parse({ parentId: kept.id, wallId: kept.id, position: [x, 1.5, 0], width: 0.6 }), + ) + const absorbedWindow = WindowNode.parse({ + parentId: absorbed.id, + wallId: absorbed.id, + position: [absorbedWindowX, 1.5, 0], + width: 0.6, + side: 'front', + }) + const absorbedDoor = DoorNode.parse({ + parentId: absorbed.id, + wallId: absorbed.id, + position: [absorbedWindowX + 1.5, 0, 0.05], + width: 0.9, + rotation: [0, 0.25, 0], + hingesSide: 'left', + handleSide: 'right', + }) + kept.children = keptWindows.map((w) => w.id) + absorbed.children = [absorbedWindow.id, absorbedDoor.id] + const graph = map([kept, absorbed, ...keptWindows, absorbedWindow, absorbedDoor]) + const plan = planWallMerge(graph, [kept.id, absorbed.id]) + expect(plan.wallId).toBe(kept.id) + const nodes = apply(graph, plan.changes) + const merged = nodes[kept.id] as WallNode + expect([merged.start[0], merged.end[0]]).toEqual([...expectedRun]) + const moved = nodes[absorbedWindow.id] as WindowNode + expect(moved.position[0]).toBeCloseTo(expectedX) + expect(moved.side).toBe('back') + // A door on the reversed wall turns with its frame — depth and yaw — while + // its hinges and handle, hung off that yaw, keep their stored side. + const door = nodes[absorbedDoor.id] as DoorNode + expect(door.position[2]).toBeCloseTo(-0.05) + expect(door.rotation[1]).toBeCloseTo(0.25 - Math.PI) + expect(door.hingesSide).toBe('left') + expect(door.handleSide).toBe('right') + // The kept wall's own openings keep their side and their world place. + const keptFirst = nodes[keptWindows[0]!.id] as WindowNode + expect(keptFirst.side).toBeUndefined() + expect(keptFirst.position[0]).toBeCloseTo(kept.start[0] + 0.5 - merged.start[0]) + } + }) + test('a child without a position moves to the kept wall too, and stale child ids are dropped', () => { + const kept = wall([0, 0], [4, 0]) + const absorbed = wall([4, 0], [8, 0]) + // The wall with the most attachments is kept; three windows outnumber the two ids below. + const windows = [0.5, 2, 3.5].map((x) => + WindowNode.parse({ parentId: kept.id, wallId: kept.id, position: [x, 1.5, 0], width: 0.8 }), + ) + kept.children = windows.map((w) => w.id) + // Stands in for a hosted kind the planner cannot re-place along the wall. + const tag = { ...ZoneNode.parse({ name: 'Tag', polygon: [] }), parentId: absorbed.id } + absorbed.children = [tag.id, 'window_gone'] + + const graph = map([kept, absorbed, ...windows, tag]) + const plan = planWallMerge(graph, [kept.id, absorbed.id]) + expect(plan.wallId).toBe(kept.id) + expect(plan.changes.delete).toEqual([absorbed.id]) + const nodes = apply(graph, plan.changes) + expect(nodes[tag.id]?.parentId).toBe(kept.id) + expect((nodes[kept.id] as WallNode).children).toEqual([...windows.map((w) => w.id), tag.id]) + }) +}) diff --git a/packages/core/src/systems/wall/wall-merge.ts b/packages/core/src/systems/wall/wall-merge.ts new file mode 100644 index 0000000000..f6f85d0e9b --- /dev/null +++ b/packages/core/src/systems/wall/wall-merge.ts @@ -0,0 +1,316 @@ +import { getWallEffectiveHeightForNodes } from '../../hooks/spatial-grid/spatial-grid-manager' +import { + type AnyNode, + type AnyNodeId, + getEffectiveWallSurfaceMaterial, + getWallSurfaceMaterialSignature, + type WallNode, +} from '../../schema' +import type { WallTopologyChanges } from './wall-topology' + +// Joining two walls that continue each other at a shared end: the delete heal +// (store `deleteNodes`) and the explicit merge share the geometry and style checks. + +export type WallAttachmentUpdate = { id: AnyNodeId; data: Partial } + +function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz <= tolerance * tolerance +} + +function wallLength(wall: Pick) { + return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) +} + +export function getWallEndpointAtPoint( + wall: Pick, + point: [number, number], +): 'start' | 'end' | null { + if (pointsEqual(wall.start, point)) return 'start' + if (pointsEqual(wall.end, point)) return 'end' + return null +} + +function getWallFreeEndpoint(wall: Pick, sharedPoint: [number, number]) { + return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start +} + +/** + * The first thing two walls disagree on (for the merge's explanation), or null. + * The delete heal is strict: room sides must match, and a wall following the + * storey never joins one with an explicit height. An explicit merge compares + * what is visible instead — `heightOf` resolves each wall's actual height — and + * leaves the sides to room detection, which reclassifies the merged wall. + */ +export function wallStyleMismatch( + a: WallNode, + b: WallNode, + options: { sides: boolean; heightOf?: (wall: WallNode) => number }, +): string | null { + if ((a.parentId ?? null) !== (b.parentId ?? null)) return 'floor' + if (Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) > 1e-6) return 'curve' + if (Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) > 1e-6) return 'thickness' + const { heightOf } = options + if ( + heightOf + ? Math.abs(heightOf(a) - heightOf(b)) > 1e-6 + : (a.height == null) !== (b.height == null) || + Math.abs((a.height ?? 0) - (b.height ?? 0)) > 1e-6 + ) + return 'height' + for (const side of ['interior', 'exterior'] as const) { + if ( + getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, side)) !== + getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, side)) + ) + return `${side} finish` + } + if (options.sides && (a.frontSide !== b.frontSide || a.backSide !== b.backSide)) + return 'room sides' + if (a.visible !== b.visible) return 'visibility' + return null +} + +export function areWallStylesCompatible(a: WallNode, b: WallNode) { + return wallStyleMismatch(a, b, { sides: true }) === null +} + +export function areWallsCollinearAcrossPoint( + a: WallNode, + b: WallNode, + sharedPoint: [number, number], +) { + const freeA = getWallFreeEndpoint(a, sharedPoint) + const freeB = getWallFreeEndpoint(b, sharedPoint) + const ax = freeA[0] - sharedPoint[0] + const az = freeA[1] - sharedPoint[1] + const bx = freeB[0] - sharedPoint[0] + const bz = freeB[1] - sharedPoint[1] + const lenA = Math.hypot(ax, az) + const lenB = Math.hypot(bx, bz) + + if (lenA < 1e-6 || lenB < 1e-6) return false + + const cross = (ax * bz - az * bx) / (lenA * lenB) + const dot = (ax * bx + az * bz) / (lenA * lenB) + return Math.abs(cross) <= 1e-4 && dot < -0.999 +} + +export function resolveMergedWallEndpoints( + primary: WallNode, + secondary: WallNode, + sharedPoint: [number, number], +): { start: [number, number]; end: [number, number] } { + const primaryEndpoint = getWallEndpointAtPoint(primary, sharedPoint) + const secondaryEndpoint = getWallEndpointAtPoint(secondary, sharedPoint) + + if (primaryEndpoint === 'end' && secondaryEndpoint === 'start') { + return { start: primary.start, end: secondary.end } + } + if (primaryEndpoint === 'start' && secondaryEndpoint === 'end') { + return { start: secondary.start, end: primary.end } + } + // The kept wall never turns around: its sides, finishes and hosted faces stay + // where they are. Meeting start-to-start or end-to-end, the absorbed wall is + // the one read backwards. + if (primaryEndpoint === 'start' && secondaryEndpoint === 'start') { + return { start: secondary.end, end: primary.end } + } + + return { start: primary.start, end: secondary.start } +} + +/** + * Hosted nodes live in the wall's local frame (+X along it, +Z its front, yaw + * 0 front / π back). A wall read backwards turns that frame by 180° about Y, + * so the face and the yaw swap and the caller mirrors depth. Hinges, handles + * and swing hang off the node's own yaw (the plan symbol flips them from it), + * so they stay as stored. + */ +function reversedWallChildPatch(child: AnyNode): Partial { + const patch: Record = {} + if ('side' in child && (child.side === 'front' || child.side === 'back')) { + patch.side = child.side === 'front' ? 'back' : 'front' + } + if ('rotation' in child && Array.isArray(child.rotation)) { + const yaw = child.rotation[1] + Math.PI + patch.rotation = [ + child.rotation[0], + Math.atan2(Math.sin(yaw), Math.cos(yaw)), + child.rotation[2], + ] + } + return patch as Partial +} + +export function buildMergedWallAttachmentUpdates( + primary: WallNode, + secondary: WallNode, + mergedWallId: AnyNodeId, + mergedStart: [number, number], + mergedEnd: [number, number], + nodes: Record, +): WallAttachmentUpdate[] { + const mergedLength = Math.max( + Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), + 1e-6, + ) + const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength + const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength + const updates: WallAttachmentUpdate[] = [] + + const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[] + for (const childId of wallChildren) { + const child = nodes[childId] + if (!child) continue + // Every child moves to the kept wall: deleting the absorbed wall would + // otherwise take a child left on it along. Only positioned ones re-place. + const rehost = { parentId: mergedWallId, wallId: mergedWallId } + if (!('position' in child && Array.isArray(child.position))) { + updates.push({ id: childId, data: rehost as Partial }) + continue + } + + const sourceWall = child.parentId === secondary.id ? secondary : primary + const sourceLength = Math.max(wallLength(sourceWall), 1e-6) + const reversed = + (sourceWall.end[0] - sourceWall.start[0]) * tangentX + + (sourceWall.end[1] - sourceWall.start[1]) * tangentZ < + 0 + const mirrored = reversed ? reversedWallChildPatch(child) : {} + const localX = typeof child.position[0] === 'number' ? child.position[0] : 0 + const worldX = + sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength + const worldZ = + sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength + const nextLocalX = Math.max( + 0, + Math.min( + mergedLength, + (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ, + ), + ) + + updates.push({ + id: childId, + data: { + ...rehost, + ...mirrored, + position: [ + nextLocalX, + child.position[1], + reversed ? -child.position[2] : child.position[2], + ] as typeof child.position, + ...('wallT' in child ? { wallT: nextLocalX / mergedLength } : {}), + } as Partial, + }) + } + + return updates +} + +/** + * Merges a straight run of adjoining walls into one — the inverse of a split. + * Every joint must hold exactly these walls (a T or a cross stays split), and + * neighbours must continue in line and look alike (`wallStyleMismatch`: same + * thickness, visible height and finish). The wall with the most attachments + * keeps its id and height mode; openings and wall items keep their world + * position on it. + */ +export function planWallMerge( + nodes: Record, + wallIds: readonly AnyNodeId[], +): { changes: WallTopologyChanges; wallId: WallNode['id'] } { + const walls = [...new Set(wallIds)] + .map((id) => nodes[id]) + .filter((node): node is WallNode => node?.type === 'wall') + if (walls.length < 2 || walls.length !== new Set(wallIds).size) + throw Error('Select two or more walls to merge.') + if (walls.some((wall) => (wall.curveOffset ?? 0) !== 0)) + throw Error('Only straight walls can be merged.') + const levelId = walls[0]!.parentId ?? null + if (walls.some((wall) => (wall.parentId ?? null) !== levelId)) + throw Error('Merge walls on the same floor.') + + const virtual = { ...nodes } + const [primary, ...rest] = [...walls].sort( + (a, b) => (b.children?.length ?? 0) - (a.children?.length ?? 0) || a.id.localeCompare(b.id), + ) + let merged = primary! + let remaining = rest + while (remaining.length > 0) { + let next: WallNode | undefined + let joint: [number, number] | undefined + for (const wall of remaining) { + joint = [merged.start, merged.end].find((end) => getWallEndpointAtPoint(wall, end) !== null) + if (joint) { + next = wall + break + } + } + if (!(next && joint)) throw Error('Merge walls that touch end to end.') + const atJoint = Object.values(virtual).filter( + (node) => + node?.type === 'wall' && + (node.parentId ?? null) === levelId && + getWallEndpointAtPoint(node, joint) !== null, + ) + if (atJoint.length !== 2) + throw Error('Another wall meets this joint, so merging would disconnect it.') + if (!areWallsCollinearAcrossPoint(merged, next, joint)) + throw Error('Merge walls that continue in a straight line.') + const mismatch = wallStyleMismatch(merged, next, { + sides: false, + heightOf: (wall) => getWallEffectiveHeightForNodes(wall, virtual), + }) + if (mismatch) throw Error(`These walls have a different ${mismatch}.`) + + const { start, end } = resolveMergedWallEndpoints(merged, next, joint) + for (const update of buildMergedWallAttachmentUpdates( + merged, + next, + merged.id, + start, + end, + virtual, + )) { + virtual[update.id] = { ...virtual[update.id]!, ...update.data } as AnyNode + } + merged = { + ...merged, + start, + end, + // Ids without a node are dropped rather than carried onto the kept wall. + children: [ + ...new Set([ + ...(merged.children ?? []), + ...(next.children ?? []).filter((id) => virtual[id as AnyNodeId]), + ]), + ], + } as WallNode + virtual[merged.id] = merged + delete virtual[next.id] + for (const node of Object.values(virtual)) { + if (node?.type !== 'zone' || !node.boundaryWallIds?.includes(next.id)) continue + virtual[node.id] = { + ...node, + boundaryWallIds: [ + ...new Set(node.boundaryWallIds.map((id) => (id === next.id ? merged.id : id))), + ], + } + } + remaining = remaining.filter((wall) => wall !== next) + } + + return { + changes: { + create: [], + update: Object.values(virtual) + .filter((node) => nodes[node.id] && node !== nodes[node.id]) + .map((node) => ({ id: node.id, data: node })), + delete: walls.filter((wall) => !virtual[wall.id]).map((wall) => wall.id), + }, + wallId: merged.id, + } +} diff --git a/packages/core/src/systems/wall/wall-operations.test.ts b/packages/core/src/systems/wall/wall-operations.test.ts new file mode 100644 index 0000000000..83a115c380 --- /dev/null +++ b/packages/core/src/systems/wall/wall-operations.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + LevelNode, + WallNode, + WindowNode, + ZoneNode, +} from '../../schema' +import { getWallCurveFrameAt, getWallCurveLength } from './wall-curve' +import { + planWallDivision, + planWallDivisions, + planWallRectangle, + wallRectangleCorners, +} from './wall-operations' + +const level = LevelNode.parse({ id: 'level_creation', children: [] }) +const map = (nodes: AnyNode[]) => + Object.fromEntries([level, ...nodes].map((n) => [n.id, n])) as Record +const rectangle = (offset = 0) => { + const points = wallRectangleCorners([offset, 0], [offset + 8, 6]) + return points.map((start, i) => + WallNode.parse({ parentId: level.id, start, end: points[(i + 1) % 4] }), + ) +} +describe('wall creation operations', () => { + test('rectangle is closed in every drag direction and the planning graph is untouched', () => { + const nodes = map([]) + const before = JSON.stringify(nodes) + for (const [a, b] of [ + [ + [0, 0], + [8, 6], + ], + [ + [8, 0], + [0, 6], + ], + [ + [8, 6], + [0, 0], + ], + ]) { + const plan = planWallRectangle(nodes, { + levelId: level.id, + start: a as [number, number], + end: b as [number, number], + wallDefaults: { thickness: 0.22 }, + }) + expect(plan.walls).toHaveLength(4) + plan.walls.forEach((w, i) => { + expect(w.end).toEqual(plan.walls[(i + 1) % 4]!.start) + expect(w.thickness).toBe(0.22) + }) + } + expect(JSON.stringify(nodes)).toBe(before) + }) + test('adjacent rectangles reuse their shared wall', () => { + const existing = rectangle() + const plan = planWallRectangle(map(existing), { + levelId: level.id, + start: [8, 0], + end: [12, 6], + }) + expect(plan.changes.create).toHaveLength(3) + expect(plan.changes.delete).toHaveLength(0) + }) + test('a side drawn along an existing wall and past its end adds only the overhang', () => { + const existing = rectangle() + const plan = planWallRectangle(map(existing), { + levelId: level.id, + start: [8, 0], + end: [12, 8], + }) + expect(plan.changes.create).toHaveLength(4) + expect(plan.changes.delete).toHaveLength(0) + const alongSharedLine = plan.walls.filter((w) => w.start[0] === 8 && w.end[0] === 8) + expect(alongSharedLine).toHaveLength(1) + expect([alongSharedLine[0]!.start[1], alongSharedLine[0]!.end[1]].sort()).toEqual([6, 8]) + }) + test('an existing wall in the middle of a side leaves a remainder on each side of it', () => { + const middle = WallNode.parse({ parentId: level.id, start: [2, 0], end: [5, 0] }) + const plan = planWallRectangle(map([middle]), { + levelId: level.id, + start: [0, 0], + end: [8, 6], + }) + expect(plan.changes.create).toHaveLength(5) + const bottom = plan.walls + .filter((w) => w.start[1] === 0 && w.end[1] === 0) + .map((w) => [w.start[0], w.end[0]].sort((a, b) => a - b)) + .sort((a, b) => a[0]! - b[0]!) + expect(bottom).toEqual([ + [0, 2], + [5, 8], + ]) + }) + test('a rectangle side T-joining a room wall keeps the room on the replacement walls', () => { + const existing = rectangle() + const room = ZoneNode.parse({ + parentId: level.id, + name: 'Room', + polygon: existing.map((w) => w.start), + autoFromWalls: true, + boundaryWallIds: existing.map((w) => w.id), + }) + const plan = planWallRectangle(map([...existing, room]), { + levelId: level.id, + start: [4, 6], + end: [8, 10], + }) + const top = existing[2]! + expect(plan.changes.delete).toEqual([top.id]) + const zone = plan.changes.update.find((op) => op.id === room.id)?.data as + | { boundaryWallIds: string[] } + | undefined + expect(zone?.boundaryWallIds).toHaveLength(5) + expect(zone?.boundaryWallIds).not.toContain(top.id) + const createdIds = plan.changes.create.map((op) => op.node.id) + for (const id of zone?.boundaryWallIds ?? []) { + expect(createdIds.includes(id) || existing.some((w) => w.id === id)).toBe(true) + } + }) + test('a gap too short to be a wall counts as covered', () => { + const almost = WallNode.parse({ parentId: level.id, start: [8, 0], end: [8, 5.995] }) + const plan = planWallRectangle(map([almost]), { + levelId: level.id, + start: [8, 0], + end: [12, 6], + }) + expect(plan.changes.create).toHaveLength(3) + }) + test('invalid rectangle has no partial result', () => { + for (const end of [ + [0, 5], + [5, 0.01], + [NaN, 5], + ]) + expect(() => + planWallRectangle(map([]), { + levelId: level.id, + start: [0, 0], + end: end as [number, number], + }), + ).toThrow() + }) + test('split preserves original id and remaps openings without changing their world placement', () => { + const wall = WallNode.parse({ + parentId: level.id, + start: [2, 3], + end: [8, 11], + thickness: 0.3, + slots: { exterior: 'preset:brick' }, + }) + const opening = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [8, 1.5, 0], + width: 1, + }) + wall.children = [opening.id] + const zone = ZoneNode.parse({ + name: 'Room', + polygon: [ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ], + parentId: level.id, + boundaryWallIds: [wall.id], + autoFromWalls: true, + }) + const plan = planWallDivision(map([wall, opening, zone]), wall.id, 4) + expect(plan.changes.delete).toEqual([]) + expect(plan.changes.create).toHaveLength(1) + const first = plan.changes.update.find((op) => op.id === wall.id)!.data as WallNode + const second = plan.changes.create[0]!.node as WallNode + expect(first.end).toEqual(second.start) + expect(second.slots).toEqual(wall.slots) + const moved = plan.changes.update.find((op) => op.id === opening.id)!.data as WindowNode + expect(moved.position).toEqual([4, 1.5, 0]) + expect(moved.parentId).toBe(second.id) + expect(plan.changes.update.find((op) => op.id === zone.id)!.data).toMatchObject({ + boundaryWallIds: [wall.id, second.id], + }) + }) + test('split refuses cuts through windows and near endpoints', () => { + const wall = rectangle()[0]! + const opening = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [4, 1.5, 0], + width: 2, + }) + wall.children = [opening.id] + for (const distance of [0, 0.01, 4, 8, NaN]) + expect(() => planWallDivision(map([wall, opening]), wall.id, distance)).toThrow() + }) + test('several cuts commit as one plan: first id kept, openings follow their segment', () => { + const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) + const opening = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [7, 1.5, 0], + width: 1, + }) + wall.children = [opening.id] + const nodes = map([wall, opening]) + const plan = planWallDivisions(nodes, wall.id, [2, 4, 6]) + expect(plan.points).toEqual([ + [2, 0], + [4, 0], + [6, 0], + ]) + expect(plan.changes.delete).toEqual([]) + const walls = [ + plan.changes.update.find((op) => op.id === wall.id)!.data as WallNode, + ...plan.changes.create.map((op) => op.node as WallNode), + ].sort((a, b) => a.start[0] - b.start[0]) + expect(walls.map((w) => [w.start[0], w.end[0]])).toEqual([ + [0, 2], + [2, 4], + [4, 6], + [6, 8], + ]) + expect(walls[0]!.id).toBe(wall.id) + const moved = plan.changes.update.find((op) => op.id === opening.id)!.data as WindowNode + expect(moved.parentId).toBe(walls[3]!.id) + expect(moved.position).toEqual([1, 1.5, 0]) + expect(nodes[wall.id]).toBe(wall) + }) + test('cuts closer than 5 cm apart are refused as a whole', () => { + const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) + expect(() => planWallDivisions(map([wall]), wall.id, [4, 4.02])).toThrow() + }) + test('curved splits preserve total arc length and curve endpoint', () => { + const wall = WallNode.parse({ + parentId: level.id, + start: [0, 0], + end: [8, 0], + curveOffset: 1, + }) + const length = getWallCurveLength(wall) + const expected = getWallCurveFrameAt(wall, 0.4).point + const plan = planWallDivision(map([wall]), wall.id, length * 0.4) + const first = plan.changes.update[0]!.data as WallNode + const second = plan.changes.create[0]!.node as WallNode + expect(first.end[0]).toBeCloseTo(expected.x) + expect(first.end[1]).toBeCloseTo(expected.y) + expect(getWallCurveLength(first) + getWallCurveLength(second)).toBeCloseTo(length) + }) +}) diff --git a/packages/core/src/systems/wall/wall-operations.ts b/packages/core/src/systems/wall/wall-operations.ts new file mode 100644 index 0000000000..516f65fd9b --- /dev/null +++ b/packages/core/src/systems/wall/wall-operations.ts @@ -0,0 +1,176 @@ +import type { AnyNode, AnyNodeId, WallNode } from '../../schema' +import { getWallCurveFrameAt, getWallCurveLength } from './wall-curve' +import type { WallPlanPoint } from './wall-move' +import { + planWallInsertion, + planWallSplitAtPoint, + uncoveredWallSegments, + type WallTopologyChanges, +} from './wall-topology' + +export function wallRectangleCorners(a: WallPlanPoint, b: WallPlanPoint): WallPlanPoint[] { + if ( + ![...a, ...b].every(Number.isFinite) || + Math.abs(a[0] - b[0]) < 0.05 || + Math.abs(a[1] - b[1]) < 0.05 + ) + return [] + const x = Math.min(a[0], b[0]) + const z = Math.min(a[1], b[1]) + const right = Math.max(a[0], b[0]) + const top = Math.max(a[1], b[1]) + return [ + [x, z], + [right, z], + [right, top], + [x, top], + ] +} + +/** Plan against a scratch graph so four sides commit atomically, including junction splits. */ +export function planWallRectangle( + nodes: Record, + args: { + levelId: AnyNodeId + start: WallPlanPoint + end: WallPlanPoint + wallDefaults?: Partial + }, +): { changes: WallTopologyChanges; walls: WallNode[] } { + if (nodes[args.levelId]?.type !== 'level') throw Error('Select an editable floor.') + const corners = wallRectangleCorners(args.start, args.end) + if (!corners.length) throw Error('Both rectangle dimensions must be at least 5 cm.') + const virtual = { ...nodes } + const added = new Set() + for (let i = 0; i < 4; i++) { + // A side that runs along existing walls only adds what they don't cover, + // so snapping to a wall and dragging past its end reuses it rather than + // doubling it. + const levelWalls = Object.values(virtual).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === args.levelId, + ) + for (const [start, end] of uncoveredWallSegments( + corners[i]!, + corners[(i + 1) % 4]!, + levelWalls, + )) { + const result = planWallInsertion(virtual, { ...args, start, end, joinRadius: 0 }) + if (!result.ok) throw Error('The rectangle cannot be drawn here.') + for (const id of result.plan.changes.delete) delete virtual[id] + for (const { node, parentId } of result.plan.changes.create) { + virtual[node.id] = { ...node, ...(parentId ? { parentId } : {}) } as AnyNode + } + for (const wall of result.plan.insertedWalls) added.add(wall.id) + for (const { id, data } of result.plan.changes.update) { + if (virtual[id]) virtual[id] = { ...virtual[id], ...data } as AnyNode + } + } + } + const created = Object.values(virtual).filter((n) => !nodes[n.id]) + return { + changes: { + create: created.map((node) => ({ node, parentId: node.parentId as AnyNodeId })), + update: Object.values(virtual) + .filter((n) => nodes[n.id] && n !== nodes[n.id]) + .map((n) => ({ id: n.id, data: n })), + delete: Object.keys(nodes).filter((id) => !virtual[id as AnyNodeId]) as AnyNodeId[], + }, + walls: created.filter((n): n is WallNode => n.type === 'wall' && added.has(n.id)), + } +} + +/** Explicit split keeps the first wall's identity and reuses the junction planner's host migration. */ +export function planWallDivision( + nodes: Record, + wallId: WallNode['id'], + distance: number, +) { + const wall = nodes[wallId] + if (wall?.type !== 'wall' || !wall.parentId) throw Error('Select a wall.') + const length = getWallCurveLength(wall) + if (!Number.isFinite(distance) || distance < 0.05 || distance > length - 0.05) + throw Error('Keep at least 5 cm on each side of the split.') + const point = getWallCurveFrameAt(wall, distance / length).point + const result = planWallSplitAtPoint(nodes, { + levelId: wall.parentId as AnyNodeId, + point: [point.x, point.y], + radius: 0.001, + ignoreWallIds: Object.values(nodes) + .filter((n) => n.type === 'wall' && n.id !== wallId) + .map((n) => n.id), + }) + if (!result.ok || result.plan.changes.create.length !== 2) + throw Error('Move the split away from doors, windows and mounted objects.') + const [first, second] = result.plan.changes.create + const firstId = first!.node.id + const changes: WallTopologyChanges = { + create: [second!], + delete: [], + update: [ + { + id: wallId, + data: { ...first!.node, id: wallId, parentId: wall.parentId } as Partial, + }, + ...result.plan.changes.update.map((op) => ({ + ...op, + data: { + ...op.data, + ...(op.data.parentId === firstId ? { parentId: wallId } : {}), + ...('wallId' in op.data && op.data.wallId === firstId ? { wallId } : {}), + } as Partial, + })), + ], + } + for (const node of Object.values(nodes)) { + if ( + node.type === 'zone' && + node.parentId === wall.parentId && + node.boundaryWallIds?.includes(wallId) + ) { + changes.update.push({ + id: node.id, + data: { + boundaryWallIds: node.boundaryWallIds.flatMap((id) => + id === wallId ? [wallId, second!.node.id as WallNode['id']] : [id], + ), + }, + }) + } + } + return { changes, point: [point.x, point.y] as WallPlanPoint } +} + +/** + * Several cuts as one edit. Cutting farthest first keeps the first wall's + * identity at every step, so the nearer distances stay valid on it. + */ +export function planWallDivisions( + nodes: Record, + wallId: WallNode['id'], + distances: readonly number[], +): { changes: WallTopologyChanges; points: WallPlanPoint[] } { + const virtual = { ...nodes } + const points: WallPlanPoint[] = [] + for (const distance of [...distances].sort((a, b) => b - a)) { + const plan = planWallDivision(virtual, wallId, distance) + for (const { node, parentId } of plan.changes.create) { + virtual[node.id] = { ...node, ...(parentId ? { parentId } : {}) } as AnyNode + } + for (const { id, data } of plan.changes.update) { + if (virtual[id]) virtual[id] = { ...virtual[id], ...data } as AnyNode + } + points.push(plan.point) + } + return { + changes: { + create: Object.values(virtual) + .filter((n) => !nodes[n.id]) + .map((node) => ({ node, parentId: node.parentId as AnyNodeId })), + update: Object.values(virtual) + .filter((n) => nodes[n.id] && n !== nodes[n.id]) + .map((n) => ({ id: n.id, data: n })), + delete: [], + }, + points: points.reverse(), + } +} diff --git a/packages/core/src/systems/wall/wall-topology.test.ts b/packages/core/src/systems/wall/wall-topology.test.ts index 5ff3ab8044..a6a8e2ce9f 100644 --- a/packages/core/src/systems/wall/wall-topology.test.ts +++ b/packages/core/src/systems/wall/wall-topology.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import { GROUND_SUPPORT_ID } from '../../hooks/spatial-grid/support-host-id' import { encodeTerrainField } from '../../lib/terrain-codec' import { applyHeightPatch, createTerrainField, flattenPatch } from '../../lib/terrain-field' -import { type AnyNode, type AnyNodeId, DoorNode, WallNode } from '../../schema' +import { type AnyNode, type AnyNodeId, DoorNode, WallNode, ZoneNode } from '../../schema' import { getWallArcData, getWallCurveFrameAt } from './wall-curve' import { planWallInsertion, planWallSplitAtPoint } from './wall-topology' @@ -106,6 +106,36 @@ describe('planWallInsertion', () => { ]) }) + test('a room keeps naming its boundary when a T-join splits one of its walls', () => { + const host = WallNode.parse({ parentId: LEVEL_ID, start: [0, 0], end: [8, 0] }) + const other = WallNode.parse({ parentId: LEVEL_ID, start: [8, 0], end: [8, 6] }) + const room = ZoneNode.parse({ + parentId: LEVEL_ID, + name: 'Room', + polygon: [ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ], + autoFromWalls: true, + boundaryWallIds: [host.id, other.id], + }) + const result = planWallInsertion(nodeMap([host, other, room]), { + levelId: LEVEL_ID, + start: [4, 0], + end: [4, -3], + joinRadius: 0.1, + }) + expect(result.ok).toBe(true) + if (!result.ok) return + const replacementIds = result.plan.changes.create + .map((op) => op.node.id) + .filter((id) => !result.plan.insertedWalls.some((wall) => wall.id === id)) + expect(replacementIds).toHaveLength(2) + const zoneUpdate = result.plan.changes.update.find((op) => op.id === room.id) + expect(zoneUpdate?.data).toEqual({ boundaryWallIds: [...replacementIds, other.id] }) + }) test('moves an attached opening to the replacement wall that contains it', () => { const door = DoorNode.parse({ id: 'door_attached', diff --git a/packages/core/src/systems/wall/wall-topology.ts b/packages/core/src/systems/wall/wall-topology.ts index f4db5ae81c..d97477a9ed 100644 --- a/packages/core/src/systems/wall/wall-topology.ts +++ b/packages/core/src/systems/wall/wall-topology.ts @@ -64,11 +64,16 @@ function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint) { return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH } -function wallSegmentsCoverSegment(start: WallPlanPoint, end: WallPlanPoint, walls: WallNode[]) { +/** Where straight walls run along `start→end`, as sorted parameter intervals of it. */ +function collinearWallIntervals( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], +): { intervals: Array<[number, number]>; length: number } | null { const dx = end[0] - start[0] const dz = end[1] - start[1] const lengthSquared = dx * dx + dz * dz - if (lengthSquared <= WALL_INTERSECTION_EPSILON * WALL_INTERSECTION_EPSILON) return false + if (lengthSquared <= WALL_INTERSECTION_EPSILON * WALL_INTERSECTION_EPSILON) return null const length = Math.sqrt(lengthSquared) const intervals: Array<[number, number]> = [] @@ -89,11 +94,16 @@ function wallSegmentsCoverSegment(start: WallPlanPoint, end: WallPlanPoint, wall const intervalEnd = Math.min(1, Math.max(wallStartT, wallEndT)) if (intervalEnd >= intervalStart) intervals.push([intervalStart, intervalEnd]) } - intervals.sort((left, right) => left[0] - right[0]) - const parameterTolerance = WALL_INTERSECTION_EPSILON / length + return { intervals, length } +} + +function wallSegmentsCoverSegment(start: WallPlanPoint, end: WallPlanPoint, walls: WallNode[]) { + const collinear = collinearWallIntervals(start, end, walls) + if (!collinear) return false + const parameterTolerance = WALL_INTERSECTION_EPSILON / collinear.length let coveredUntil = 0 - for (const [intervalStart, intervalEnd] of intervals) { + for (const [intervalStart, intervalEnd] of collinear.intervals) { if (intervalStart > coveredUntil + parameterTolerance) return false coveredUntil = Math.max(coveredUntil, intervalEnd) if (coveredUntil >= 1 - parameterTolerance) return true @@ -101,6 +111,35 @@ function wallSegmentsCoverSegment(start: WallPlanPoint, end: WallPlanPoint, wall return false } +/** + * The parts of `start→end` that no straight wall already runs along, in order. + * A side drawn along an existing wall and past its end yields the overhang + * only, so the existing wall is reused instead of doubled. Gaps too short to + * be a wall are treated as covered. + */ +export function uncoveredWallSegments( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], +): Array<[WallPlanPoint, WallPlanPoint]> { + const collinear = collinearWallIntervals(start, end, walls) + if (!collinear) return [] + const minimumGap = WALL_MIN_LENGTH / collinear.length + const at = (t: number): WallPlanPoint => [ + start[0] + (end[0] - start[0]) * t, + start[1] + (end[1] - start[1]) * t, + ] + const segments: Array<[WallPlanPoint, WallPlanPoint]> = [] + let coveredUntil = 0 + for (const [intervalStart, intervalEnd] of collinear.intervals) { + if (intervalStart > coveredUntil + minimumGap) + segments.push([at(coveredUntil), at(intervalStart)]) + coveredUntil = Math.max(coveredUntil, intervalEnd) + } + if (coveredUntil < 1 - minimumGap) segments.push([at(coveredUntil), end]) + return segments +} + function projectPointOntoWallCenterline( point: WallPlanPoint, wall: WallNode, @@ -527,13 +566,32 @@ export function planWallInsertion( return split ? [[wallId, split] as const] : [] }) const replacementWalls = splitPlans.flatMap(([, split]) => split.create) + // Rooms name the walls that enclose them; a split wall's replacements take + // its place, as they do for an explicit division or a merge. + const replacements = new Map( + splitPlans.map(([wallId, split]) => [wallId, split.create.map((wall) => wall.id)] as const), + ) + const zoneUpdates = Object.values(nodes).flatMap((node) => + node.type === 'zone' && + node.parentId === args.levelId && + node.boundaryWallIds.some((id) => replacements.has(id)) + ? [ + { + id: node.id, + data: { + boundaryWallIds: node.boundaryWallIds.flatMap((id) => replacements.get(id) ?? [id]), + }, + }, + ] + : [], + ) const plan: WallInsertionPlan = { changes: { create: [...replacementWalls, ...insertedWalls].map((node) => ({ node, parentId: args.levelId, })), - update: splitPlans.flatMap(([, split]) => split.update), + update: [...splitPlans.flatMap(([, split]) => split.update), ...zoneUpdates], delete: splitPlans.map(([wallId]) => wallId as AnyNodeId), }, insertedWalls, diff --git a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx index 12de92939d..c30bdcf6fc 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx @@ -14,8 +14,19 @@ import { } from '../../lib/floorplan/floorplan-mode' import useEditor from '../../store/use-editor' import useFloorplanMode from '../../store/use-floorplan-mode' +import useInteractionScope, { useReshapingNode } from '../../store/use-interaction-scope' -const lazyToolCache = new WeakMap<() => Promise, ComponentType>() +type Loader = () => Promise<{ default: ComponentType }> +const lazyToolCache = new WeakMap>() + +function lazyTool(loader: Loader | undefined): ComponentType | null { + if (!loader) return null + const cached = lazyToolCache.get(loader) + if (cached) return cached + const component = lazy(loader) + lazyToolCache.set(loader, component) + return component +} function registeredFloorplanTool( tool: string | null, @@ -24,13 +35,16 @@ function registeredFloorplanTool( if (!tool) return null const extension = getFloorplanNodeExtension(nodeRegistry.get(tool)) if (!isFloorplanToolAvailableInMode(extension?.availableModes, mode)) return null - const loader = extension?.tool - if (!loader) return null - const cached = lazyToolCache.get(loader) - if (cached) return cached - const component = lazy(loader) - lazyToolCache.set(loader, component) - return component + return lazyTool(extension?.tool) +} + +/** The plan sibling of `def.affordanceTools[reshape]`: a kind's layer for its own reshape. */ +function registeredReshapeLayer( + kind: string | null, + reshape: string | null, +): ComponentType | null { + if (!(kind && reshape)) return null + return lazyTool(getFloorplanNodeExtension(nodeRegistry.get(kind))?.reshapeLayers?.[reshape]) } export function FloorplanRegisteredToolLayer() { @@ -42,6 +56,10 @@ export function FloorplanRegisteredToolLayer() { const toolDefaults = useEditor((state) => state.tool ? (state.toolDefaults[state.tool] ?? null) : null, ) + const reshape = useInteractionScope((state) => + state.scope.kind === 'reshaping' ? state.scope.reshape : null, + ) + const reshapingNode = useReshapingNode() const activeLevelId = useViewer((state) => state.selection.levelId) const unit = useViewer((state) => state.unit) const metricNotation = useViewer((state) => state.metricNotation) @@ -55,11 +73,14 @@ export function FloorplanRegisteredToolLayer() { useEditor.getState().setTool(null) useEditor.getState().setMode('select') }, []) - if (mode !== 'build') return null - const Tool = registeredToolEnabled ? registeredFloorplanTool(tool, floorplanMode) : null - return Tool ? ( + const Active = + registeredReshapeLayer(reshapingNode?.type ?? null, reshape) ?? + (mode === 'build' && registeredToolEnabled + ? registeredFloorplanTool(tool, floorplanMode) + : null) + return Active ? ( - 90) labelAngleDeg -= 180 + else if (screenDeg <= -90) labelAngleDeg += 180 + + // Push the plate perpendicular to the wall so the dashed footprint + // stays visible underneath. + const perpX = -measurement.direction[1] + const perpY = measurement.direction[0] + const offset = upx * 18 + const cx = measurement.midpoint[0] + perpX * offset + const cy = measurement.midpoint[1] + perpY * offset + + const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2 + const lengthPlateW = lengthTextWidth + padX * 2 + const lengthPlateH = fontSize + padY * 2 + + const arcSampleCount = 32 + + return ( + + + + + {measurement.lengthLabel} + + + + {measurement.angleLabels.map((arc) => { + // Sample the arc as a polyline — avoids the SVG arc command's + // sweep-flag direction quirks across negative/positive sweeps. + const points: string[] = [] + for (let i = 0; i <= arcSampleCount; i += 1) { + const t = i / arcSampleCount + const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t + const px = arc.center[0] + Math.cos(a) * arc.radius + const py = arc.center[1] + Math.sin(a) * arc.radius + points.push(`${px},${py}`) + } + + const aFontSize = Math.max(upx * 9, 0.075) + const aPadX = upx * 5 + const aPadY = upx * 2.5 + const aTextWidth = arc.label.length * upx * 6.2 + const aPlateW = aTextWidth + aPadX * 2 + const aPlateH = aFontSize + aPadY * 2 + + const labelDist = arc.radius + upx * 16 + const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist + const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist + + return ( + + + + + + {arc.label} + + + + ) + })} + + ) +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index ef3450a3e5..40af99c705 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -23,9 +23,9 @@ import { collectFloorplanLinkedLevelNodes, computeAffectedSiblingIds, floorplanAffordanceReshapeScope, + floorplanEntryYieldsToTool, floorplanHandleDoubleClickAffordance, InteractiveGeometry, - floorplanEntryYieldsToTool, isFloorplanOpeningPlacementState, resolveFloorplanHandleUnitsPerPixel, siteToFloorplanTransform, diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 3ed093b346..fc56af0736 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -55,6 +55,7 @@ import { type WindowNode, WindowNode as WindowNodeSchema, wallClosesRoom, + wallRectangleCorners, ZoneNode as ZoneNodeSchema, type ZoneNode as ZoneNodeType, } from '@pascal-app/core' @@ -146,6 +147,7 @@ import { import { FloorplanSnapBeaconLayer } from '../editor-2d/floorplan-snap-beacon-layer' import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer' import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer' +import { FloorplanDraftWallMeasurement } from '../editor-2d/renderers/floorplan-draft-wall-measurement' import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer' import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer' import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer' @@ -2329,168 +2331,6 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin } } -type DraftWallMeasurement = { - lengthLabel: string - midpoint: WallPlanPoint - direction: WallPlanPoint - angleLabels: { - id: string - label: string - center: WallPlanPoint - radius: number - startAngle: number - endAngle: number - midAngle: number - }[] -} - -function FloorplanDraftWallMeasurement({ - measurement, - measurementStroke, - labelBackground, - labelText, - sceneRotationDeg, - unitsPerPixel, -}: { - measurement: DraftWallMeasurement - measurementStroke: string - labelBackground: string - labelText: string - sceneRotationDeg: number - unitsPerPixel: number -}) { - const stroke = measurementStroke - const labelBg = labelBackground - - const upx = unitsPerPixel - const fontSize = Math.max(upx * 10, 0.08) - const padX = upx * 6 - const padY = upx * 3 - - // Length plate: rotates to follow the wall direction, but flips 180° - // when its on-screen orientation would read upside-down (same trick as - // `floorplan-registry-layer.tsx` for dimension labels). - const wallAngleDeg = - (Math.atan2(measurement.direction[1], measurement.direction[0]) * 180) / Math.PI - let labelAngleDeg = wallAngleDeg - let screenDeg = wallAngleDeg + sceneRotationDeg - screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180 - if (screenDeg > 90) labelAngleDeg -= 180 - else if (screenDeg <= -90) labelAngleDeg += 180 - - // Push the plate perpendicular to the wall so the dashed footprint - // stays visible underneath. - const perpX = -measurement.direction[1] - const perpY = measurement.direction[0] - const offset = upx * 18 - const cx = measurement.midpoint[0] + perpX * offset - const cy = measurement.midpoint[1] + perpY * offset - - const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2 - const lengthPlateW = lengthTextWidth + padX * 2 - const lengthPlateH = fontSize + padY * 2 - - const arcSampleCount = 32 - - return ( - - - - - {measurement.lengthLabel} - - - - {measurement.angleLabels.map((arc) => { - // Sample the arc as a polyline — avoids the SVG arc command's - // sweep-flag direction quirks across negative/positive sweeps. - const points: string[] = [] - for (let i = 0; i <= arcSampleCount; i += 1) { - const t = i / arcSampleCount - const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t - const px = arc.center[0] + Math.cos(a) * arc.radius - const py = arc.center[1] + Math.sin(a) * arc.radius - points.push(`${px},${py}`) - } - - const aFontSize = Math.max(upx * 9, 0.075) - const aPadX = upx * 5 - const aPadY = upx * 2.5 - const aTextWidth = arc.label.length * upx * 6.2 - const aPlateW = aTextWidth + aPadX * 2 - const aPlateH = aFontSize + aPadY * 2 - - const labelDist = arc.radius + upx * 16 - const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist - const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist - - return ( - - - - - - {arc.label} - - - - ) - })} - - ) -} - function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean { return a[0] === b[0] && a[1] === b[1] } @@ -4579,7 +4419,7 @@ function FloorplanDraftCursorLayer({ )} {cursorPoint && ( - + s.fenceDraftEnd) const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd) const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn) + const wallRectangleDraftStart = useFloorplanDraftPreview((s) => s.wallRectangleDraftStart) + // The live cursor is the rectangle's opposite corner; select it only while a + // rectangle draft is open so idle moves don't re-render this layer. + const wallRectangleDraftEnd = useFloorplanDraftPreview((s) => + s.wallRectangleDraftStart ? s.cursorPoint : null, + ) const draftPolygon = useMemo(() => { if ( @@ -4706,6 +4552,53 @@ function FloorplanLinearDraftLayer({ return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA) }, [levelId, wallDraftStart, wallDraftEnd]) + // Rectangle mode drafts four walls at once; they read exactly like a line + // draft — same mitered footprints, measurement plates and axis guides. + const rectangleDraft = useMemo(() => { + if (!(levelId && isWallBuildActive && wallRectangleDraftStart && wallRectangleDraftEnd)) { + return null + } + const corners = wallRectangleCorners(wallRectangleDraftStart, wallRectangleDraftEnd) + if (corners.length !== 4) return null + const draftWalls = corners.map((start, index) => + getSharedFloorplanWall({ + ...buildDraftWall(levelId, start, corners[(index + 1) % 4]!), + id: `wall_draft_${index}` as WallNode['id'], + }), + ) + const miterData = calculateLevelMiters(draftWalls) + const polygons = draftWalls.map((wall) => + formatPolygonPoints(getWallPlanFootprint(wall, miterData)), + ) + // Measure the two sides meeting at the cursor corner. Corners wind + // counter-clockwise, so each side is measured end → start to push its + // plate outside the rectangle. + const cursorIndex = corners.findIndex( + ([x, z]) => x === wallRectangleDraftEnd[0] && z === wallRectangleDraftEnd[1], + ) + const measurements = [cursorIndex - 1, cursorIndex].map((side) => { + const from = corners[(side + 5) % 4]! + const to = corners[(side + 4) % 4]! + const dx = to[0] - from[0] + const dy = to[1] - from[1] + const length = Math.hypot(dx, dy) + return { + lengthLabel: formatMeasurement(length, unit, null, metricNotation), + midpoint: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] as WallPlanPoint, + direction: [dx / length, dy / length] as WallPlanPoint, + angleLabels: [], + } + }) + return { polygons, measurements } + }, [ + isWallBuildActive, + levelId, + metricNotation, + unit, + wallRectangleDraftEnd, + wallRectangleDraftStart, + ]) + const draftPolygonPoints = useMemo(() => { if (isRoofBuildActive && roofDraftStart && roofDraftEnd) { const minX = Math.min(roofDraftStart[0], roofDraftEnd[0]) @@ -4873,6 +4766,10 @@ function FloorplanLinearDraftLayer({ if (isWallBuildActive && wallDraftStart && wallDraftEnd) { pushDraft(wallDraftStart, wallDraftEnd) } + if (isWallBuildActive && wallRectangleDraftStart && wallRectangleDraftEnd) { + pushCross(wallRectangleDraftStart) + pushCross(wallRectangleDraftEnd) + } if (isFenceBuildActive && fenceDraftStart && fenceDraftEnd) { pushDraft(fenceDraftStart, fenceDraftEnd) } @@ -4884,6 +4781,8 @@ function FloorplanLinearDraftLayer({ fenceDraftStart, wallDraftEnd, wallDraftStart, + wallRectangleDraftEnd, + wallRectangleDraftStart, ]) return ( @@ -4941,6 +4840,34 @@ function FloorplanLinearDraftLayer({ unitsPerPixel={unitsPerPixel} /> )} + + {rectangleDraft?.polygons.map((points, index) => ( + + ))} + + {rectangleDraft?.measurements.map((measurement, index) => ( + + ))} ) } diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index bb1ca78dad..8c8eea59f1 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -3,6 +3,7 @@ import { Icon } from '@iconify/react' import { Copy, Group, Move, PencilRuler, Search, Spline, Trash2, Ungroup } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler } from 'react' +import { RegistryActionContributions } from './registry-action-contributions' type NodeActionMenuProps = { onFind?: MouseEventHandler @@ -111,6 +112,7 @@ export function NodeActionMenu({ )} + {onDuplicate && ( + ) +} + +/** Joins selected walls that continue each other into one — the inverse of Split. */ +function MergeWallsAction() { + const selected = useViewer((s) => s.selection.selectedIds) as AnyNodeId[] + const nodes = useScene((s) => s.nodes) + const readOnly = useScene((s) => s.readOnly) + // null: not a wall selection (render nothing); otherwise why it can't merge, or null. + const merge = useMemo(() => { + if (selected.length < 2 || selected.some((id) => nodes[id]?.type !== 'wall')) return null + try { + planWallMerge(nodes, selected) + return { reason: null } + } catch (error) { + return { reason: error instanceof Error ? error.message : 'These walls cannot be merged.' } + } + }, [nodes, selected]) + if (!merge) return null + const { reason } = merge + const button = ( + + ) + if (!reason) return button + // A disabled button gets no hover events, so the reason hangs off a wrapper. + return ( + + + {button} + + {reason} + + ) +} diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 1b3e3a6c1a..88a880db80 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -12,6 +12,7 @@ import { } from '@pascal-app/editor' import { buildWallContextualDimensions } from './contextual-dimensions' import { hasWallCurveBlockingChildren } from './curve-eligibility' +import { useWallDrawingMode } from './drawing-mode' import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan' import { wallCurveAffordance, @@ -30,6 +31,9 @@ import { wallParametrics } from './parametrics' import { wallQuickMeasurement } from './quick-measurement' import { WallNode } from './schema' import { wallSlots } from './slots' +import { WALL_SPLIT_MAX_CUTS } from './split-preview' +import { setWallSplitCuts } from './split-session' +import { useWallSplit } from './split-store' /** * Wall — the Phase 3 stress test of the registry-driven node model. @@ -46,6 +50,10 @@ import { wallSlots } from './slots' * floorplan-panel.tsx's `wallPolygons` short-circuits to [] when * wall is registered. */ +const SPLIT_CUT_COUNTS = Array.from({ length: WALL_SPLIT_MAX_CUTS }, (_, index) => + String(index + 1), +) + export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', @@ -71,8 +79,11 @@ export const wallDefinition: NodeDefinition = { }, } satisfies DraftingSurfaceExtension, 'pascal:editor/floorplan': { + tool: () => import('./floorplan-tool'), + reshapeLayers: { split: () => import('./split-floorplan-layer') }, contextualDimensions: buildWallContextualDimensions, actionMenu: { + actions: () => import('./actions'), canCurve: ({ node, nodes }) => !hasWallCurveBlockingChildren( node.children.flatMap((childId) => { @@ -152,6 +163,7 @@ export const wallDefinition: NodeDefinition = { curve: () => import('./curve-tool'), 'move-endpoint': () => import('./move-endpoint-tool'), move: () => import('./move-tool'), + split: () => import('./split-tool'), }, renderer: { @@ -188,8 +200,57 @@ export const wallDefinition: NodeDefinition = { floorplanSiblingOverrides: wallFloorplanSiblingOverrides, toolHints: [ { key: 'Left click', label: 'Set wall start / end' }, + { + key: 'R', + label: 'Shape', + chip: { + subscribe: (onChange) => useWallDrawingMode.subscribe(onChange), + value: () => useWallDrawingMode.getState().mode, + cycle: () => useWallDrawingMode.getState().toggle(), + labels: { line: 'Shape: Line', rectangle: 'Shape: Rectangle' }, + icons: { line: 'lucide:minus', rectangle: 'lucide:square' }, + tooltip: 'Wall shape — click or press R to toggle', + }, + }, { key: 'Esc', label: 'Cancel' }, ], + // The split session (`split-session.ts`) is the wall's own reshape; the HUD + // shows these while it runs. The snapping chip comes from the scope. + affordanceHints: { + split: [ + { key: 'Left click', label: 'Split at the marks' }, + { + key: 'Scroll', + label: 'Cuts', + chip: { + subscribe: (onChange) => useWallSplit.subscribe(onChange), + value: () => String(useWallSplit.getState().draft?.cuts ?? 1), + cycle: () => { + const cuts = useWallSplit.getState().draft?.cuts ?? 1 + setWallSplitCuts(cuts >= WALL_SPLIT_MAX_CUTS ? 1 : cuts + 1) + }, + labels: Object.fromEntries( + SPLIT_CUT_COUNTS.map((count) => [ + count, + count === '1' ? 'Cuts: 1' : `Cuts: ${count}, even`, + ]), + ), + icons: Object.fromEntries(SPLIT_CUT_COUNTS.map((count) => [count, 'lucide:scissors'])), + tooltip: 'Number of cuts — scroll or click to change', + }, + }, + { + key: 'Alt', + label: 'Free placement', + // Several cuts are evenly spaced, so there is nothing to place freely. + visible: { + subscribe: (onChange) => useWallSplit.subscribe(onChange), + value: () => (useWallSplit.getState().draft?.cuts ?? 1) === 1, + }, + }, + { key: 'Esc', label: 'Cancel' }, + ], + }, presentation: { label: 'Wall', diff --git a/packages/nodes/src/wall/drawing-mode.ts b/packages/nodes/src/wall/drawing-mode.ts new file mode 100644 index 0000000000..19ee6508a3 --- /dev/null +++ b/packages/nodes/src/wall/drawing-mode.ts @@ -0,0 +1,42 @@ +import { emitter } from '@pascal-app/core' +import { isEditableKeyboardTarget } from '@pascal-app/editor' +import { useEffect } from 'react' +import { create } from 'zustand' + +export const useWallDrawingMode = create<{ + mode: 'line' | 'rectangle' + toggle: () => void +}>((set, get) => ({ + mode: 'line', + toggle: () => { + emitter.emit('tool:cancel') + set({ mode: get().mode === 'rectangle' ? 'line' : 'rectangle' }) + }, +})) + +const onKeyDown = (e: KeyboardEvent) => { + if (e.repeat || e.metaKey || e.ctrlKey || e.altKey) return + if (e.key !== 'r' && e.key !== 'R') return + if (isEditableKeyboardTarget(e.target)) return + e.preventDefault() + useWallDrawingMode.getState().toggle() +} +let owners = 0 + +/** + * R toggles line / rectangle drawing (the HUD's Shape chip cycles the same + * store). Both wall tools mount this: the 3D tool only exists once the canvas + * does, and split view mounts both, so one listener serves whichever is up and + * the mode returns to line when the last one closes. + */ +export function useWallDrawingModeKeys() { + useEffect(() => { + if (owners++ === 0) window.addEventListener('keydown', onKeyDown) + return () => { + if (--owners === 0) { + window.removeEventListener('keydown', onKeyDown) + useWallDrawingMode.setState({ mode: 'line' }) + } + } + }, []) +} diff --git a/packages/nodes/src/wall/floorplan-tool.tsx b/packages/nodes/src/wall/floorplan-tool.tsx new file mode 100644 index 0000000000..1e1f8b22e9 --- /dev/null +++ b/packages/nodes/src/wall/floorplan-tool.tsx @@ -0,0 +1,123 @@ +'use client' +import { + emitter, + resolveTerrainWallConstructionOptions, + useScene, + type WallPlanPoint, +} from '@pascal-app/core' +import { + type FloorplanToolContext, + markToolCancelConsumed, + triggerSFX, + useEditor, + useFloorplanDraftPreview, + useFloorplanRender, + useInteractionScope, +} from '@pascal-app/editor' +import { useEffect, useRef, useState } from 'react' +import { useWallDrawingMode, useWallDrawingModeKeys } from './drawing-mode' +import { createWallRectangle } from './rectangle-command' + +/** The wall's plan tool: line drafting stays with the panel; rectangle mode mounts its own tool. */ +export default function WallFloorplanTool(props: FloorplanToolContext) { + useWallDrawingModeKeys() + const mode = useWallDrawingMode((s) => s.mode) + return mode === 'rectangle' ? : null +} + +/** + * Rectangle mode in the floor plan. Pointer moves stay with the panel, so the + * cursor, snapping, alignment guides and snap beacon are the line wall's own; + * this tool only claims clicks and publishes the first corner, and the panel's + * linear draft layer draws the four draft walls from it. + */ +function RectangleFloorplanTool({ activeLevelId }: FloorplanToolContext) { + const group = useRef(null) + const renderContext = useFloorplanRender() + const [error, setError] = useState<{ message: string; at: WallPlanPoint } | null>(null) + useEffect(() => { + const svg = group.current?.ownerSVGElement + if (!svg || !activeLevelId) return + const draft = useFloorplanDraftPreview.getState + let down: [number, number] | null = null + let construction: ReturnType | undefined + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'wall' }) + const claim = (e: Event) => { + e.preventDefault() + e.stopImmediatePropagation() + } + const onDown = (e: PointerEvent) => { + if (e.button !== 0 || e.metaKey || e.ctrlKey) return + claim(e) + down = [e.clientX, e.clientY] + } + const cancel = () => { + if (draft().wallRectangleDraftStart) markToolCancelConsumed() + draft().setWallRectangleDraftStart(null) + down = null + setError(null) + } + const onClick = (e: MouseEvent) => { + if (e.button !== 0 || e.metaKey || e.ctrlKey) return + claim(e) + if (!down || Math.hypot(e.clientX - down[0], e.clientY - down[1]) > 5) { + down = null + return + } + down = null + // The panel's snapped cursor, exactly where a line wall would land. + const point = draft().cursorPoint + if (!point) return + const start = draft().wallRectangleDraftStart + if (!start) { + construction = resolveTerrainWallConstructionOptions( + useScene.getState().nodes, + activeLevelId, + point, + useEditor.getState().toolDefaults.wall, + ) + draft().setWallRectangleDraftStart(point) + setError(null) + return + } + try { + createWallRectangle( + activeLevelId, + start, + point, + useEditor.getState().toolDefaults.wall ?? {}, + construction, + ) + cancel() + triggerSFX('sfx:structure-build') + } catch (e) { + setError({ message: (e as Error).message, at: point }) + } + } + const stopDouble = (e: MouseEvent) => { + if (e.button === 0) claim(e) + } + svg.addEventListener('pointerdown', onDown, true) + svg.addEventListener('click', onClick, true) + svg.addEventListener('dblclick', stopDouble, true) + emitter.on('tool:cancel', cancel) + return () => { + svg.removeEventListener('pointerdown', onDown, true) + svg.removeEventListener('click', onClick, true) + svg.removeEventListener('dblclick', stopDouble, true) + emitter.off('tool:cancel', cancel) + draft().setWallRectangleDraftStart(null) + useInteractionScope.getState().endIf((s) => s.kind === 'drafting' && s.tool === 'wall') + } + }, [activeLevelId]) + const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01 + return ( + + {error && ( + + {error.message} + + )} + + ) +} diff --git a/packages/nodes/src/wall/rectangle-command.ts b/packages/nodes/src/wall/rectangle-command.ts new file mode 100644 index 0000000000..d137608fbb --- /dev/null +++ b/packages/nodes/src/wall/rectangle-command.ts @@ -0,0 +1,36 @@ +import { + type AnyNodeId, + planWallRectangle, + resolveWallConstruction, + runAsSingleSceneHistoryStep, + useScene, + type WallConstructionOptions, + type WallNode, + type WallPlanPoint, +} from '@pascal-app/core' + +export function createWallRectangle( + levelId: AnyNodeId, + start: WallPlanPoint, + end: WallPlanPoint, + defaults: Partial = {}, + options?: WallConstructionOptions, +) { + const scene = useScene.getState() + if (scene.readOnly) throw Error('This scene is read-only.') + const plan = planWallRectangle(scene.nodes, { levelId, start, end, wallDefaults: defaults }) + if (!plan.changes.create.length && !plan.changes.update.length && !plan.changes.delete.length) + return [] + const construction = resolveWallConstruction(scene.nodes, levelId, plan.walls, options) + const walls = new Map(construction.walls.map((w) => [w.id, w])) + const changes = { + ...plan.changes, + create: plan.changes.create.map((op) => ({ + ...op, + node: walls.get(op.node.id as WallNode['id']) ?? op.node, + })), + } + if (construction.sourceSupportUpdate) changes.update.push(construction.sourceSupportUpdate) + runAsSingleSceneHistoryStep(useScene, () => scene.applyNodeChanges(changes)) + return construction.walls +} diff --git a/packages/nodes/src/wall/rectangle-tool.tsx b/packages/nodes/src/wall/rectangle-tool.tsx new file mode 100644 index 0000000000..106fbb317c --- /dev/null +++ b/packages/nodes/src/wall/rectangle-tool.tsx @@ -0,0 +1,222 @@ +'use client' +import { + emitter, + type GridEvent, + useScene, + type WallNode, + type WallPlanPoint, + wallRectangleCorners, +} from '@pascal-app/core' +import { + CursorSphere, + clearPlacementSurface, + DRAFT_LABEL_Y_OFFSET, + DraftMeasurementLabel, + EDITOR_LAYER, + formatLinearMeasurement, + isMagneticSnapActive, + markToolCancelConsumed, + NO_RAYCAST, + publishHorizontalConstructionPlane, + resolveEventConstructionPlane, + snapWallDraftPointDetailed, + triggerSFX, + useEditor, + useInteractionScope, + useLinearDisplay, + useRegistryToolContext, + useWallSnapIndicator, +} from '@pascal-app/editor' +import { getSceneTheme, useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' +import { useEffect, useState } from 'react' +import { DoubleSide } from 'three' +import { createWallRectangle } from './rectangle-command' + +export default function RectangleWallTool() { + const { activeLevelId, isCameraDragging } = useRegistryToolContext() + const canvas = useThree((s) => s.gl.domElement) + const { isImperial } = useLinearDisplay('m', 2) + const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const measurementColor = isDark ? '#ffffff' : '#111111' + const measurementShadowColor = isDark ? '#111111' : '#ffffff' + const defaults = useEditor((s) => s.toolDefaults.wall) + const [draft, setDraft] = useState<{ + start: WallPlanPoint + end: WallPlanPoint + y: number + } | null>(null) + const [message, setMessage] = useState('') + const [cursor, setCursor] = useState<[number, number, number] | null>(null) + const levelHeight = useScene((s) => + activeLevelId && s.nodes[activeLevelId]?.type === 'level' ? s.nodes[activeLevelId].height : 3, + ) + const height = typeof defaults?.height === 'number' ? defaults.height : (levelHeight ?? 3) + const thickness = typeof defaults?.thickness === 'number' ? defaults.thickness : 0.1 + useEffect(() => { + if (!activeLevelId) return + setDraft(null) + setMessage('') + let start: WallPlanPoint | null = null + let plane: ReturnType | null = null + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'wall' }) + const pointFor = (e: GridEvent) => { + const result = snapWallDraftPointDetailed({ + point: [e.localPosition[0], e.localPosition[2]], + walls: Object.values(useScene.getState().nodes).filter( + (n): n is WallNode => n.type === 'wall' && n.parentId === activeLevelId, + ), + magnetic: isMagneticSnapActive(), + }) + useWallSnapIndicator.getState().set( + result.snap + ? { + x: result.point[0], + z: result.point[1], + kind: result.snap, + wallIds: result.targetWallIds, + } + : null, + ) + return result.point + } + const move = (e: GridEvent) => { + if (e.nativeEvent.target !== canvas || isCameraDragging()) return + const point = pointFor(e) + const hoverPlane = plane ?? resolveEventConstructionPlane(e, null) + if (plane) publishHorizontalConstructionPlane(e, plane) + setCursor([point[0], hoverPlane.localY, point[1]]) + setMessage('') + if (start) setDraft({ start, end: point, y: hoverPlane.localY }) + } + const leave = () => { + setCursor(null) + useWallSnapIndicator.getState().clear() + } + const cancel = () => { + if (start) markToolCancelConsumed() + start = null + plane = null + setDraft(null) + setMessage('') + leave() + clearPlacementSurface() + } + const click = (e: GridEvent) => { + if (e.nativeEvent.target !== canvas || e.nativeEvent.button !== 0 || isCameraDragging()) + return + const point = pointFor(e) + if (!start) { + start = point + plane = resolveEventConstructionPlane(e, null) + publishHorizontalConstructionPlane(e, plane) + setCursor([point[0], plane.localY, point[1]]) + setDraft({ start, end: point, y: plane.localY }) + setMessage('') + return + } + try { + createWallRectangle( + activeLevelId, + start, + point, + useEditor.getState().toolDefaults.wall ?? {}, + { + constructionElevation: plane?.elevation, + preferredSupportSlabId: plane?.supportSlabId, + constructionHeight: height, + }, + ) + cancel() + triggerSFX('sfx:structure-build') + } catch (error) { + setMessage((error as Error).message) + } + } + emitter.on('grid:move', move) + emitter.on('grid:click', click) + emitter.on('tool:cancel', cancel) + canvas.addEventListener('pointerleave', leave) + return () => { + emitter.off('grid:move', move) + emitter.off('grid:click', click) + emitter.off('tool:cancel', cancel) + canvas.removeEventListener('pointerleave', leave) + useWallSnapIndicator.getState().clear() + clearPlacementSurface() + useInteractionScope.getState().endIf((s) => s.kind === 'drafting' && s.tool === 'wall') + } + }, [activeLevelId, height, canvas, isCameraDragging]) + const corners = draft ? wallRectangleCorners(draft.start, draft.end) : [] + const unit = isImperial ? 'imperial' : 'metric' + // Label the two sides meeting at the cursor corner, as the floor plan does. + const cursorIndex = draft + ? corners.findIndex(([x, z]) => x === draft.end[0] && z === draft.end[1]) + : -1 + const sideLabels = + draft && cursorIndex >= 0 + ? [cursorIndex + 3, cursorIndex].map((side) => { + const a = corners[side % 4]! + const b = corners[(side + 1) % 4]! + return { + label: formatLinearMeasurement(Math.hypot(b[0] - a[0], b[1] - a[1]), unit), + position: [ + (a[0] + b[0]) / 2, + draft.y + height + DRAFT_LABEL_Y_OFFSET, + (a[1] + b[1]) / 2, + ] as [number, number, number], + } + }) + : [] + return ( + + + {corners.map((a, i) => { + const b = corners[(i + 1) % 4]! + return ( + + + + + ) + })} + {draft && message ? ( + + ) : ( + sideLabels.map((side, index) => ( + + )) + )} + + ) +} diff --git a/packages/nodes/src/wall/split-floorplan-layer.tsx b/packages/nodes/src/wall/split-floorplan-layer.tsx new file mode 100644 index 0000000000..60d8b41c9c --- /dev/null +++ b/packages/nodes/src/wall/split-floorplan-layer.tsx @@ -0,0 +1,129 @@ +'use client' +import { + getWallCurveFrameAt, + getWallCurveLength, + getWallThickness, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + clientToPlan, + FloorplanDraftWallMeasurement, + type FloorplanToolContext, + formatLinearMeasurement, + useFloorplanRender, +} from '@pascal-app/editor' +import { getSceneTheme, useViewer } from '@pascal-app/viewer' +import { useEffect } from 'react' +import { bindWallSplitPointer } from './split-pointer' +import { wallSplitDistance, wallSplitMarkerColor, wallSplitSegmentLabels } from './split-preview' +import { useWallSplit } from './split-store' + +/** The plan side of the wall's `split` reshape (`reshapeLayers.split`): the same draft as 3D. */ +export default function WallSplitFloorplanLayer(_props: FloorplanToolContext) { + const draft = useWallSplit((s) => s.draft) + const wallId = draft?.wallId + const wall = useScene((s) => (draft ? s.nodes[draft.wallId] : undefined)) + const levelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) + const isDark = useViewer((s) => getSceneTheme(s.sceneTheme).appearance === 'dark') + const context = useFloorplanRender() + const upp = context?.unitsPerPixel ?? 0.01 + useEffect(() => { + if (!wallId || useScene.getState().nodes[wallId]?.parentId !== levelId) return + const scene = document.querySelector('g[data-floorplan-scene]') + const surface = scene?.ownerSVGElement + if (!surface) return + return bindWallSplitPointer(surface, (event) => { + if (!(event.target instanceof Node) || !surface.contains(event.target)) return null + const point = clientToPlan(event.clientX, event.clientY) + const current = useScene.getState().nodes[wallId] + if (!point || current?.type !== 'wall') return null + const distance = wallSplitDistance(current, point) + // Only the wall's own stroke is a target, including its endpoint exclusion zones. + const frame = frameAt(current, distance) + if ( + Math.hypot(frame.point.x - point[0], frame.point.y - point[1]) > hitHalfWidth(current, upp) + ) + return null + return distance + }) + }, [wallId, levelId, upp]) + if (!draft || wall?.type !== 'wall' || wall.parentId !== levelId) return null + const { preview, snap } = draft + const half = hitHalfWidth(wall, upp) + const color = wallSplitMarkerColor(preview.valid) + const cut = preview.frames[0]?.point + return ( + + {snap?.kind === 'alignment' && cut && ( + + )} + {preview.frames.map(({ point, normal }, index) => { + const line = { + x1: point.x - normal.x * half, + y1: point.y - normal.y * half, + x2: point.x + normal.x * half, + y2: point.y + normal.y * half, + } + return ( + + + + + ) + })} + {!preview.valid && cut ? ( + + {preview.message} + + ) : ( + wallSplitSegmentLabels(wall, preview).map((segment, index) => ( + 1 ? ` × ${segment.count}` : ''}`, + midpoint: segment.midpoint, + direction: segment.direction, + angleLabels: [], + }} + measurementStroke={context?.palette.measurementStroke ?? color} + sceneRotationDeg={context?.sceneRotationDeg ?? 0} + unitsPerPixel={upp} + /> + )) + )} + + ) +} + +/** The wall's stroke plus a finger's width, so the cut can be grabbed at any zoom. */ +function hitHalfWidth(wall: WallNode, upp: number) { + return getWallThickness(wall) / 2 + 8 * upp +} + +function frameAt(wall: WallNode, distance: number) { + const length = getWallCurveLength(wall) + return getWallCurveFrameAt(wall, length > 0 ? distance / length : 0) +} diff --git a/packages/nodes/src/wall/split-pointer.test.ts b/packages/nodes/src/wall/split-pointer.test.ts new file mode 100644 index 0000000000..f735ab501b --- /dev/null +++ b/packages/nodes/src/wall/split-pointer.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { LevelNode, useScene, WallNode } from '@pascal-app/core' +import { useEditor } from '@pascal-app/editor' +import { bindWallSplitPointer } from './split-pointer' +import { closeWallSplit, openWallSplit } from './split-session' +import { useWallSplit } from './split-store' + +// Restore by descriptor: assigning `undefined` back would leave an own `window` +// property behind, and the next file's DOM shim would then believe one exists. +const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') +const previousNode = Object.getOwnPropertyDescriptor(globalThis, 'Node') +const events = new EventTarget() +const surface = new EventTarget() as EventTarget & { contains: (node: unknown) => boolean } +surface.contains = (node) => node === surface +const level = LevelNode.parse({ children: [] }) +const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) +level.children = [wall.id] +let cleanup = () => {} +const emit = (type: string, distance: number, button = 0, altKey = false) => { + const event = new Event(type, { cancelable: true }) + Object.assign(event, { clientX: distance, pointerId: 1, button, buttons: 0, altKey }) + events.dispatchEvent(event) + return event +} +const scroll = (deltaY: number, target: unknown = surface) => { + const event = new Event('wheel', { cancelable: true }) + Object.assign(event, { deltaY, deltaMode: 0, ctrlKey: false }) + Object.defineProperty(event, 'target', { value: target }) + events.dispatchEvent(event) + return event +} +const cutAt = () => useWallSplit.getState().draft?.preview.distances +beforeEach(() => { + globalThis.window = events as unknown as Window & typeof globalThis + // `instanceof Node` guards the wheel target; the fake surface stands in for one. + ;(globalThis as { Node?: unknown }).Node = EventTarget + useEditor.setState((s) => ({ + snappingModeByContext: { ...s.snappingModeByContext, polygon: 'grid' }, + gridSnapStep: 0.5, + })) + useScene.setState({ + nodes: { [level.id]: level, [wall.id]: wall }, + rootNodeIds: [level.id], + readOnly: false, + }) + useScene.temporal.getState().clear() + openWallSplit(wall) + cleanup = bindWallSplitPointer(surface as unknown as Element, (event) => + event.clientX >= 0 ? event.clientX : null, + ) +}) +afterEach(() => { + cleanup() + closeWallSplit() + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow) + else Reflect.deleteProperty(globalThis, 'window') + if (previousNode) Object.defineProperty(globalThis, 'Node', previousNode) + else Reflect.deleteProperty(globalThis, 'Node') +}) + +test('the single cut follows the pointer on the grid; Alt places it freely', () => { + emit('pointermove', 2.2) + expect(cutAt()).toEqual([2]) + emit('pointermove', 3.3, 0, true) + expect(cutAt()).toEqual([3.3]) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) +}) +test('scrolling over the viewport changes the cut count; elsewhere it scrolls', () => { + expect(scroll(-120).defaultPrevented).toBe(true) + expect(useWallSplit.getState().draft?.cuts).toBe(3) + scroll(60) + expect(cutAt()).toEqual([8 / 3, 16 / 3]) + expect(scroll(-120, {}).defaultPrevented).toBe(false) + expect(useWallSplit.getState().draft?.cuts).toBe(2) + // Evenly spaced cuts ignore the pointer. + emit('pointermove', 1) + expect(cutAt()).toEqual([8 / 3, 16 / 3]) +}) +test('left click commits the release mark once; orbit and out-of-wall clicks do not cut', () => { + expect(emit('pointerdown', 3, 2).defaultPrevented).toBe(false) + emit('pointerup', 3, 2) + emit('pointerdown', -1) + emit('pointerup', -1) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + expect(emit('pointerdown', 2).defaultPrevented).toBe(true) + emit('pointermove', 3.5) + expect(emit('pointerup', 3.5).defaultPrevented).toBe(true) + expect((useScene.getState().nodes[wall.id] as WallNode).end).toEqual([3.5, 0]) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) +}) +test('cancelled pointer or release off-wall never commits a stale mark', () => { + emit('pointerdown', 2) + emit('pointercancel', 2) + emit('pointerup', 2) + emit('pointerdown', 3) + emit('pointerup', -1) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + cleanup() + emit('pointermove', 6) + expect(cutAt()).toEqual([3]) +}) diff --git a/packages/nodes/src/wall/split-pointer.ts b/packages/nodes/src/wall/split-pointer.ts new file mode 100644 index 0000000000..3bf965d79c --- /dev/null +++ b/packages/nodes/src/wall/split-pointer.ts @@ -0,0 +1,75 @@ +import { commitWallSplit, hoverWallSplit, setWallSplitCuts } from './split-session' +import { useWallSplit } from './split-store' + +// Pixels of wheel travel per cut: one mouse notch, or a short trackpad swipe. +const WHEEL_STEP_PX = 60 +const WHEEL_LINE_PX = 33 + +/** Both viewports feed a wall distance; neither writes scene nodes while hovering. */ +export function bindWallSplitPointer( + surface: Element, + distanceAt: (event: PointerEvent) => number | null, +) { + let pressed: number | null = null + let wheelTravel = 0 + const move = (event: PointerEvent) => { + if (event.buttons && pressed !== event.pointerId) return + const distance = distanceAt(event) + if (distance !== null) hoverWallSplit(distance, event.altKey) + } + const down = (event: PointerEvent) => { + if (event.button !== 0) return + const distance = distanceAt(event) + if (distance === null) return + pressed = event.pointerId + event.preventDefault() + event.stopImmediatePropagation() + hoverWallSplit(distance, event.altKey) + } + const up = (event: PointerEvent) => { + if (pressed !== event.pointerId) return + pressed = null + event.preventDefault() + event.stopImmediatePropagation() + const distance = distanceAt(event) + if (distance === null) return + hoverWallSplit(distance, event.altKey) + // Retain ownership through the click dispatched after pointerup, so normal + // selection cannot consume that same click after the cut closes its session. + const swallow = (click: Event) => { + click.preventDefault() + click.stopImmediatePropagation() + } + surface.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => surface.removeEventListener('click', swallow, true), 0) + commitWallSplit() + } + const cancel = () => { + pressed = null + } + // Scrolling over the viewport changes the cut count, as in a loop cut; + // pinch (Ctrl + wheel) still zooms. + const wheel = (event: WheelEvent) => { + if (event.ctrlKey || !(event.target instanceof Node) || !surface.contains(event.target)) return + event.preventDefault() + event.stopImmediatePropagation() + wheelTravel += event.deltaMode === 1 ? event.deltaY * WHEEL_LINE_PX : event.deltaY + const steps = Math.trunc(wheelTravel / WHEEL_STEP_PX) + if (!steps) return + wheelTravel -= steps * WHEEL_STEP_PX + const draft = useWallSplit.getState().draft + if (draft) setWallSplitCuts(draft.cuts - steps) + } + window.addEventListener('pointermove', move, true) + window.addEventListener('pointerdown', down, true) + window.addEventListener('pointerup', up, true) + window.addEventListener('pointercancel', cancel, true) + window.addEventListener('wheel', wheel, { capture: true, passive: false }) + return () => { + window.removeEventListener('pointermove', move, true) + window.removeEventListener('pointerdown', down, true) + window.removeEventListener('pointerup', up, true) + window.removeEventListener('pointercancel', cancel, true) + window.removeEventListener('wheel', wheel, true) + } +} diff --git a/packages/nodes/src/wall/split-preview.test.ts b/packages/nodes/src/wall/split-preview.test.ts new file mode 100644 index 0000000000..d46cb9c88b --- /dev/null +++ b/packages/nodes/src/wall/split-preview.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + getWallCurveFrameAt, + getWallCurveLength, + LevelNode, + planWallDivision, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { + snapWallSplitDistance, + wallSplitAnchors, + wallSplitDistance, + wallSplitDistances, + wallSplitPreview, + wallSplitSegmentLabels, +} from './split-preview' + +const level = LevelNode.parse({ children: [] }) +const graph = (...nodes: AnyNode[]) => + Object.fromEntries([level, ...nodes].map((n) => [n.id, n])) as Record +const free = { gridStep: null, anchors: null, tolerance: 0.2 } + +describe('wall split marker', () => { + test('a mouse projection and numeric distance identify the same angled cut', () => { + const wall = WallNode.parse({ parentId: level.id, start: [2, 3], end: [8, 11] }) + const nodes = graph(wall) + const distance = wallSplitDistance(wall, [4, 7.75]) + expect(distance).toBeCloseTo(5) + const preview = wallSplitPreview(nodes, wall, [distance]) + const plan = planWallDivision(nodes, wall.id, distance) + expect(preview.valid).toBe(true) + expect([preview.frames[0]!.point.x, preview.frames[0]!.point.y]).toEqual(plan.point) + expect(preview.frames).toEqual(wallSplitPreview(nodes, wall, [5]).frames) + }) + test.each([ + 1, -1, + ])('curved wall mouse target agrees with arc-length widget, offset %p', (offset) => { + const wall = WallNode.parse({ + parentId: level.id, + start: [2, 3], + end: [10, 5], + curveOffset: offset, + }) + for (const t of [0.1, 0.37, 0.9]) { + const frame = getWallCurveFrameAt(wall, t) + const distance = wallSplitDistance(wall, [ + frame.point.x + frame.normal.x * 0.15, + frame.point.y + frame.normal.y * 0.15, + ]) + expect(distance).toBeCloseTo(t * getWallCurveLength(wall), 7) + const preview = wallSplitPreview(graph(wall), wall, [distance]) + expect(preview.valid).toBe(true) + expect(preview.frames[0]!.point.x).toBeCloseTo(frame.point.x) + expect(preview.frames[0]!.point.y).toBeCloseTo(frame.point.y) + } + }) + test('invalid cut stays where the pointer is and does not silently snap past an opening', () => { + const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) + const window = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + width: 2, + position: [4, 1.5, 0], + }) + wall.children = [window.id] + const nodes = graph(wall, window) + const before = JSON.stringify(nodes) + for (const distance of [0, 0.02, 4, 7.99, 8]) { + const preview = wallSplitPreview(nodes, wall, [distance]) + expect(preview.valid).toBe(false) + expect(preview.frames[0]!.point.x).toBeCloseTo(distance) + expect(preview.message.length).toBeGreaterThan(0) + } + expect(wallSplitPreview(nodes, wall, [2]).valid).toBe(true) + // Three even cuts put one through the window at 4 m. + expect(wallSplitPreview(nodes, wall, wallSplitDistances(8, 3, 0)).valid).toBe(false) + expect(JSON.stringify(nodes)).toBe(before) + }) + test('linked copies cannot bypass make-real via direct mouse input', () => { + const wall = WallNode.parse({ + parentId: level.id, + start: [0, 0], + end: [8, 0], + metadata: { linkedArray: { sourceId: 'wall_original' } }, + }) + expect(wallSplitPreview(graph(wall), wall, [3]).message).toContain('Make the linked array real') + }) +}) + +describe('loop-cut counts and snapping', () => { + const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) + test('one cut follows the pointer; more cuts divide the wall evenly', () => { + expect(wallSplitDistances(8, 1, 3.3)).toEqual([3.3]) + expect(wallSplitDistances(8, 3, 3.3)).toEqual([2, 4, 6]) + }) + test('grid steps count from the wall start and never land on an end', () => { + expect(snapWallSplitDistance(wall, 3.3, { ...free, gridStep: 0.5 })).toEqual({ + distance: 3.5, + snap: { kind: 'grid' }, + }) + expect(snapWallSplitDistance(wall, 7.9, { ...free, gridStep: 0.5 }).snap).toBeNull() + }) + test("'lines' catches the midpoint or another wall's end within tolerance", () => { + const partition = WallNode.parse({ parentId: level.id, start: [2.6, 0.2], end: [2.6, 4] }) + const anchors = wallSplitAnchors(graph(wall, partition), wall) + expect(snapWallSplitDistance(wall, 3.9, { ...free, anchors })).toEqual({ + distance: 4, + snap: { kind: 'midpoint' }, + }) + expect(snapWallSplitDistance(wall, 2.5, { ...free, anchors })).toEqual({ + distance: 2.6, + snap: { kind: 'alignment', anchor: [2.6, 0.2] }, + }) + expect(snapWallSplitDistance(wall, 3.1, { ...free, anchors })).toEqual({ + distance: 3.1, + snap: null, + }) + }) + test('labels show both sides of one cut, or one length repeated across even cuts', () => { + const nodes = graph(wall) + expect( + wallSplitSegmentLabels(wall, wallSplitPreview(nodes, wall, [3])).map((s) => [ + s.length, + s.count, + ]), + ).toEqual([ + [3, 1], + [5, 1], + ]) + expect( + wallSplitSegmentLabels(wall, wallSplitPreview(nodes, wall, [2, 4, 6])).map((s) => [ + s.length, + s.count, + ]), + ).toEqual([[2, 4]]) + }) +}) diff --git a/packages/nodes/src/wall/split-preview.ts b/packages/nodes/src/wall/split-preview.ts new file mode 100644 index 0000000000..02414b0ff4 --- /dev/null +++ b/packages/nodes/src/wall/split-preview.ts @@ -0,0 +1,163 @@ +import { + type AnyNode, + type AnyNodeId, + getWallArcData, + getWallCurveFrameAt, + getWallCurveLength, + planWallDivisions, + type WallNode, + type WallPlanPoint, +} from '@pascal-app/core' + +export const WALL_SPLIT_MAX_CUTS = 32 + +/** Cut markers in both views: warm when the cuts can commit, red when they can't. */ +export function wallSplitMarkerColor(valid: boolean) { + return valid ? '#c86f45' : '#a63d2e' +} +// The planner refuses cuts closer than this to either end. +const END_CLEARANCE = 0.05 + +/** The pointer projected onto the wall, as a distance from its start; curved walls included. */ +export function wallSplitDistance(wall: WallNode, point: readonly [number, number]): number { + const length = getWallCurveLength(wall) + const arc = getWallArcData(wall) + if (arc) { + const angle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) + const turn = Math.PI * 2 + const delta = (((arc.direction * (angle - arc.startAngle)) % turn) + turn) % turn + if (delta <= Math.abs(arc.delta)) return (delta / Math.abs(arc.delta)) * length + return Math.hypot(point[0] - wall.start[0], point[1] - wall.start[1]) <= + Math.hypot(point[0] - wall.end[0], point[1] - wall.end[1]) + ? 0 + : length + } + if (length === 0) return 0 + return Math.max( + 0, + Math.min( + length, + ((point[0] - wall.start[0]) * (wall.end[0] - wall.start[0]) + + (point[1] - wall.start[1]) * (wall.end[1] - wall.start[1])) / + length, + ), + ) +} + +/** Like a loop cut: one cut follows the pointer, more cuts divide the wall evenly. */ +export function wallSplitDistances(length: number, cuts: number, distance: number): number[] { + if (cuts <= 1) return [distance] + return Array.from({ length: cuts }, (_, index) => (length * (index + 1)) / (cuts + 1)) +} + +export type WallSplitSnap = + | { kind: 'grid' } + | { kind: 'midpoint' } + | { kind: 'alignment'; anchor: WallPlanPoint } + | null + +export type WallSplitSnapOptions = { + /** Grid step while the 'grid' snapping mode is active. */ + gridStep: number | null + /** Other wall ends to align with while the 'lines' mode is active. */ + anchors: readonly WallPlanPoint[] | null + /** How far (m) the pointer may be from an alignment target to catch it. */ + tolerance: number +} + +/** + * Snaps a single cut along its wall with the active snapping mode: grid steps + * counted from the wall start, or — in 'lines' — the midpoint and where the + * level's other wall ends project onto the wall. + */ +export function snapWallSplitDistance( + wall: WallNode, + raw: number, + options: WallSplitSnapOptions, +): { distance: number; snap: WallSplitSnap } { + const length = getWallCurveLength(wall) + const inside = (distance: number) => + distance >= END_CLEARANCE && distance <= length - END_CLEARANCE + if (options.gridStep) { + const snapped = Math.round(raw / options.gridStep) * options.gridStep + return inside(snapped) + ? { distance: snapped, snap: { kind: 'grid' } } + : { distance: raw, snap: null } + } + if (!options.anchors) return { distance: raw, snap: null } + let best: { distance: number; snap: WallSplitSnap; gap: number } = { + distance: raw, + snap: null, + gap: options.tolerance, + } + const consider = (distance: number, snap: WallSplitSnap) => { + const gap = Math.abs(distance - raw) + if (gap < best.gap && inside(distance)) best = { distance, snap, gap } + } + consider(length / 2, { kind: 'midpoint' }) + for (const anchor of options.anchors) { + consider(wallSplitDistance(wall, anchor), { kind: 'alignment', anchor }) + } + return { distance: best.distance, snap: best.snap } +} + +/** Ends of the level's other walls, for 'lines' alignment. */ +export function wallSplitAnchors( + nodes: Record, + wall: WallNode, +): WallPlanPoint[] { + const own = (point: WallPlanPoint) => + [wall.start, wall.end].some((end) => end[0] === point[0] && end[1] === point[1]) + return Object.values(nodes).flatMap((node) => + node.type === 'wall' && node.id !== wall.id && node.parentId === wall.parentId + ? [node.start, node.end].filter((point) => !own(point)) + : [], + ) +} + +export function wallSplitPreview( + nodes: Record, + wall: WallNode, + distances: readonly number[], +) { + const length = getWallCurveLength(wall) + const frames = distances.map((distance) => + getWallCurveFrameAt(wall, length > 0 ? distance / length : 0), + ) + let message = '' + if ( + [wall, ...Object.values(nodes).filter((n) => n.parentId === wall.id)].some( + (n) => n.metadata.arrayModifier || n.metadata.linkedArray, + ) + ) { + message = 'Make the linked array real before splitting this wall.' + } else { + try { + planWallDivisions(nodes, wall.id, distances) + } catch (error) { + message = error instanceof Error ? error.message : 'This wall cannot be split here.' + } + } + return { distances: [...distances], length, frames, valid: !message, message } +} + +export type WallSplitPreview = ReturnType + +/** What to label: both sides of a single cut, or one length repeated across even cuts. */ +export function wallSplitSegmentLabels(wall: WallNode, preview: WallSplitPreview) { + const bounds = [0, ...preview.distances, preview.length] + const segments = bounds.slice(1).map((end, index) => [bounds[index]!, end] as const) + const count = preview.distances.length === 1 ? 1 : segments.length + return (count === 1 ? segments : segments.slice(0, 1)).map(([from, to]) => { + const { point, tangent } = getWallCurveFrameAt( + wall, + preview.length > 0 ? (from + to) / 2 / preview.length : 0, + ) + return { + length: to - from, + count, + midpoint: [point.x, point.y] as WallPlanPoint, + direction: [tangent.x, tangent.y] as WallPlanPoint, + } + }) +} diff --git a/packages/nodes/src/wall/split-session.ts b/packages/nodes/src/wall/split-session.ts new file mode 100644 index 0000000000..1dcbd29f2a --- /dev/null +++ b/packages/nodes/src/wall/split-session.ts @@ -0,0 +1,156 @@ +import { + getWallCurveLength, + planWallDivisions, + runAsSingleSceneHistoryStep, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + isGridSnapActive, + isMagneticSnapActive, + useEditor, + useInteractionScope, +} from '@pascal-app/editor' +import { + snapWallSplitDistance, + WALL_SPLIT_MAX_CUTS, + wallSplitAnchors, + wallSplitDistances, + wallSplitPreview, +} from './split-preview' +import { useWallSplit, type WallSplitDraft } from './split-store' + +// Pointer distance (m) within which a 'lines' alignment target catches the cut. +const ALIGNMENT_TOLERANCE = 0.2 + +const ownsSplit = () => { + const { scope } = useInteractionScope.getState() + return scope.kind === 'reshaping' && scope.reshape === 'split' +} +const currentWall = (draft: WallSplitDraft) => { + const wall = useScene.getState().nodes[draft.wallId] + return wall?.type === 'wall' ? wall : null +} +const withPreview = (draft: Omit, wall: WallNode): WallSplitDraft => ({ + ...draft, + preview: wallSplitPreview( + useScene.getState().nodes, + wall, + wallSplitDistances(getWallCurveLength(wall), draft.cuts, draft.distance), + ), +}) + +let teardown: (() => void) | null = null + +/** + * A split session: the wall's `reshaping` scope (so the snapping chip and the + * kind's HUD hints resolve), one centred cut, and the watchers that end it — + * Esc, the wall or its edit rights going away, another interaction, leaving + * select mode. Both views render the same draft; either can commit. + */ +export function openWallSplit(wall: WallNode) { + if (useScene.getState().readOnly) return + closeWallSplit() + useEditor.getState().setMode('select') + useInteractionScope + .getState() + .begin({ kind: 'reshaping', nodeId: wall.id, reshape: 'split', driver: 'tool' }) + useWallSplit + .getState() + .setDraft( + withPreview( + { wallId: wall.id, cuts: 1, distance: getWallCurveLength(wall) / 2, snap: null }, + wall, + ), + ) + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + event.preventDefault() + event.stopImmediatePropagation() + closeWallSplit() + } + const stopScene = useScene.subscribe((state, previous) => { + if (state.readOnly || state.nodes[wall.id]?.type !== 'wall') closeWallSplit() + else if (state.nodes !== previous.nodes) refreshWallSplit() + }) + const stopScope = useInteractionScope.subscribe(({ scope }) => { + if (!(scope.kind === 'reshaping' && scope.reshape === 'split')) closeWallSplit() + }) + const stopEditor = useEditor.subscribe((state) => { + if ( + state.mode !== 'select' || + state.isCaptureMode || + state.isPreviewMode || + state.isFirstPersonMode + ) + closeWallSplit() + }) + const hasWindow = typeof window !== 'undefined' + if (hasWindow) window.addEventListener('keydown', onKey, true) + teardown = () => { + if (hasWindow) window.removeEventListener('keydown', onKey, true) + stopScene() + stopScope() + stopEditor() + } +} + +export function closeWallSplit() { + const stop = teardown + teardown = null + stop?.() + useWallSplit.getState().setDraft(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'split') +} + +/** Pointer projected onto the wall; Alt (`free`) skips snapping. */ +export function hoverWallSplit(raw: number, free = false) { + const draft = useWallSplit.getState().draft + // Several cuts stay evenly spaced, so the pointer has nothing to move. + if (!draft || draft.cuts > 1 || !ownsSplit()) return + const wall = currentWall(draft) + if (!wall) return closeWallSplit() + const { distance, snap } = free + ? { distance: raw, snap: null } + : snapWallSplitDistance(wall, raw, { + gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : null, + anchors: isMagneticSnapActive() ? wallSplitAnchors(useScene.getState().nodes, wall) : null, + tolerance: ALIGNMENT_TOLERANCE, + }) + if (distance === draft.distance && snap?.kind === draft.snap?.kind) return + useWallSplit.getState().setDraft(withPreview({ ...draft, distance, snap }, wall)) +} + +export function setWallSplitCuts(cuts: number) { + const draft = useWallSplit.getState().draft + if (!draft || !ownsSplit()) return + const next = Math.min(WALL_SPLIT_MAX_CUTS, Math.max(1, Math.round(cuts))) + if (next === draft.cuts) return + const wall = currentWall(draft) + if (!wall) return closeWallSplit() + useWallSplit.getState().setDraft(withPreview({ ...draft, cuts: next }, wall)) +} + +/** Re-plan against the current scene (after an external edit). */ +export function refreshWallSplit() { + const draft = useWallSplit.getState().draft + if (!draft) return + const wall = currentWall(draft) + if (!wall) return closeWallSplit() + useWallSplit.getState().setDraft(withPreview(draft, wall)) +} + +export function commitWallSplit() { + const draft = useWallSplit.getState().draft + const current = useScene.getState() + if (!draft || current.readOnly || !ownsSplit()) return + const wall = currentWall(draft) + if (!wall) return closeWallSplit() + const checked = withPreview(draft, wall) + if (!checked.preview.valid) return useWallSplit.getState().setDraft(checked) + const plan = planWallDivisions(current.nodes, wall.id, checked.preview.distances) + runAsSingleSceneHistoryStep(useScene, () => current.applyNodeChanges(plan.changes)) + closeWallSplit() +} diff --git a/packages/nodes/src/wall/split-store.test.ts b/packages/nodes/src/wall/split-store.test.ts new file mode 100644 index 0000000000..9ff2a3bd50 --- /dev/null +++ b/packages/nodes/src/wall/split-store.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { LevelNode, useScene, WallNode, WindowNode } from '@pascal-app/core' +import { getActiveSnapContext, useInteractionScope } from '@pascal-app/editor' +import { + closeWallSplit, + commitWallSplit, + hoverWallSplit, + openWallSplit, + setWallSplitCuts, +} from './split-session' +import { useWallSplit } from './split-store' + +const level = LevelNode.parse({ children: [] }) +const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) +const window = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [6, 1.5, 0], + width: 1, +}) +level.children = [wall.id] +wall.children = [window.id] +beforeEach(() => { + closeWallSplit() + useScene.setState({ + nodes: { [level.id]: level, [wall.id]: wall, [window.id]: window }, + rootNodeIds: [level.id], + readOnly: false, + }) + useScene.temporal.getState().clear() +}) +afterEach(() => { + closeWallSplit() + useScene.setState({ readOnly: false }) +}) + +test("a split session is the wall's reshaping scope and a snapping context, one centred cut", () => { + openWallSplit(wall) + expect(useInteractionScope.getState().scope).toMatchObject({ + kind: 'reshaping', + nodeId: wall.id, + reshape: 'split', + }) + expect(getActiveSnapContext()).toBe('polygon') + expect(useWallSplit.getState().draft?.preview.distances).toEqual([4]) +}) +test('hover and cut-count changes are ephemeral; cancel does not alter nodes or undo', () => { + const before = useScene.getState().nodes + openWallSplit(wall) + for (let distance = 1; distance < 4; distance += 0.1) hoverWallSplit(distance, true) + setWallSplitCuts(3) + setWallSplitCuts(99) + expect(useWallSplit.getState().draft?.cuts).toBe(32) + expect(useScene.getState().nodes).toBe(before) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + closeWallSplit() + expect(useWallSplit.getState().draft).toBeNull() + expect(useInteractionScope.getState().scope.kind).toBe('idle') +}) +test('commit uses the displayed mark, reparents openings and is one undo', () => { + const before = useScene.getState().nodes + openWallSplit(wall) + hoverWallSplit(3.25, true) + commitWallSplit() + const after = useScene.getState().nodes + expect((after[wall.id] as WallNode).end).toEqual([3.25, 0]) + expect((after[window.id] as WindowNode).position).toEqual([2.75, 1.5, 0]) + expect(useWallSplit.getState().draft).toBeNull() + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual(before) +}) +test('even cuts commit as a single undo step', () => { + openWallSplit(wall) + setWallSplitCuts(2) + commitWallSplit() + const walls = Object.values(useScene.getState().nodes).filter( + (node): node is WallNode => node.type === 'wall', + ) + expect(walls.map((w) => w.end[0] - w.start[0]).map((l) => Number(l.toFixed(6)))).toEqual( + [8 / 3, 8 / 3, 8 / 3].map((l) => Number(l.toFixed(6))), + ) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) +}) +test('invalid opening cuts, replaced scopes and read-only transitions cannot commit', () => { + const before = useScene.getState().nodes + openWallSplit(wall) + hoverWallSplit(6, true) + commitWallSplit() + expect(useWallSplit.getState().draft?.preview.valid).toBe(false) + expect(useScene.getState().nodes).toBe(before) + hoverWallSplit(3, true) + useScene.setState({ readOnly: true }) + commitWallSplit() + expect(useScene.getState().nodes).toBe(before) + useScene.setState({ readOnly: false }) + useInteractionScope.getState().begin({ kind: 'painting' }) + commitWallSplit() + closeWallSplit() + expect(useScene.getState().nodes).toBe(before) + expect(useInteractionScope.getState().scope.kind).toBe('painting') + useInteractionScope.getState().end() +}) diff --git a/packages/nodes/src/wall/split-store.ts b/packages/nodes/src/wall/split-store.ts new file mode 100644 index 0000000000..5f8476f788 --- /dev/null +++ b/packages/nodes/src/wall/split-store.ts @@ -0,0 +1,21 @@ +import type { WallNode } from '@pascal-app/core' +import { create } from 'zustand' +import type { WallSplitPreview, WallSplitSnap } from './split-preview' + +export type WallSplitDraft = { + wallId: WallNode['id'] + cuts: number + /** The single cut's snapped distance from the wall start; more cuts are evenly spaced. */ + distance: number + snap: WallSplitSnap + preview: WallSplitPreview +} + +/** The open split's cut preview. Every transition lives in `split-session.ts`. */ +export const useWallSplit = create<{ + draft: WallSplitDraft | null + setDraft: (draft: WallSplitDraft | null) => void +}>((set) => ({ + draft: null, + setDraft: (draft) => set({ draft }), +})) diff --git a/packages/nodes/src/wall/split-tool.tsx b/packages/nodes/src/wall/split-tool.tsx new file mode 100644 index 0000000000..c67718ec5d --- /dev/null +++ b/packages/nodes/src/wall/split-tool.tsx @@ -0,0 +1,181 @@ +'use client' +import { type AnyNode, getWallThickness, sceneRegistry, useScene } from '@pascal-app/core' +import { + DRAFT_LABEL_Y_OFFSET, + DraftMeasurementLabel, + EDITOR_LAYER, + formatLinearMeasurement, + NO_RAYCAST, +} from '@pascal-app/editor' +import { BATCHED_LAYER, getSceneTheme, SCENE_LAYER, useViewer } from '@pascal-app/viewer' +import { useFrame, useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { type Group, type Mesh, Raycaster, Vector2 } from 'three' +import { bindWallSplitPointer } from './split-pointer' +import { wallSplitDistance, wallSplitMarkerColor, wallSplitSegmentLabels } from './split-preview' +import { useWallSplit } from './split-store' + +/** + * The wall's `split` affordance (3D): cut markers, segment lengths and the + * pointer binding. The session (`split-session.ts`) owns the scope and draft; + * the plan layer renders the same draft. + */ +export default function WallSplitTool(_props: { node: AnyNode }) { + const draft = useWallSplit((s) => s.draft) + const wallId = draft?.wallId + const wall = useScene((s) => (draft ? s.nodes[draft.wallId] : undefined)) + const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) + const isDark = useViewer((s) => getSceneTheme(s.sceneTheme).appearance === 'dark') + const { gl, camera } = useThree() + const root = useRef(null) + const column = useRef(null) + const top = useRef(null) + const base = useRef(null) + useEffect(() => { + if (!wallId) return + const surface = gl.domElement + const raycaster = new Raycaster() + raycaster.layers.set(SCENE_LAYER) + raycaster.layers.enable(BATCHED_LAYER) + const pointer = new Vector2() + return bindWallSplitPointer(surface, (event) => { + if (event.target !== surface) return null + const current = useScene.getState().nodes[wallId] + const object = sceneRegistry.nodes.get(wallId) + if (current?.type !== 'wall' || !object?.parent) return null + const rect = surface.getBoundingClientRect() + if (!rect.width || !rect.height) return null + pointer.set( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + 1 - ((event.clientY - rect.top) / rect.height) * 2, + ) + raycaster.setFromCamera(pointer, camera) + object.updateWorldMatrix(true, true) + const hit = raycaster.intersectObject(object, true)[0] + if (!hit) return null + const local = object.parent.worldToLocal(hit.point.clone()) + return wallSplitDistance(current, [local.x, local.z]) + }) + }, [wallId, gl, camera]) + useFrame(() => { + if (!(root.current && column.current && top.current && base.current)) return + if (wall?.type !== 'wall' || !draft) return + const object = sceneRegistry.nodes.get(wall.id) as Mesh | undefined + if (!object?.parent || !object.geometry) { + root.current.visible = false + return + } + root.current.visible = true + object.updateWorldMatrix(true, false) + root.current.matrix.copy(object.parent.matrixWorld) + if (!object.geometry.boundingBox) object.geometry.computeBoundingBox() + const bounds = object.geometry.boundingBox + const bottom = object.position.y + (bounds?.min.y ?? 0) + const height = bounds ? bounds.max.y - bounds.min.y : (wall.height ?? 3) + column.current.position.y = bottom + height / 2 + column.current.scale.y = Math.max(0.05, height + 0.05) + top.current.position.y = bottom + height + DRAFT_LABEL_Y_OFFSET + base.current.position.y = bottom + 0.01 + }) + if (!draft || wall?.type !== 'wall') return null + const { preview, snap } = draft + const thickness = getWallThickness(wall) + const color = wallSplitMarkerColor(preview.valid) + const cut = preview.frames[0]?.point + const guide = + snap?.kind === 'alignment' && cut + ? { + x: (snap.anchor[0] + cut.x) / 2, + z: (snap.anchor[1] + cut.y) / 2, + length: Math.hypot(cut.x - snap.anchor[0], cut.y - snap.anchor[1]), + angle: -Math.atan2(cut.y - snap.anchor[1], cut.x - snap.anchor[0]), + } + : null + return ( + + + {preview.frames.map(({ point, tangent }, index) => ( + + + + + + + + + + + ))} + + + {/* Why a cut can't commit shows at the mark, where the HUD used to say it. */} + {!preview.valid && cut ? ( + + ) : ( + wallSplitSegmentLabels(wall, preview).map((segment, index) => ( + 1 ? ` × ${segment.count}` : ''}`} + position={[segment.midpoint[0], 0, segment.midpoint[1]]} + shadowColor={isDark ? '#111111' : '#ffffff'} + /> + )) + )} + + + {guide && ( + + + + + )} + + + ) +} diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 745c2a172e..905611ead4 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -23,6 +23,7 @@ import { chainEndJoinsExistingWall, clearPlacementSurface, createWallOnCurrentLevel, + DraftMeasurementLabel, EDITOR_LAYER, formatAngleRadians, formatLinearMeasurement, @@ -60,10 +61,12 @@ import { type DraftAngleLabel, type DraftAxisGuideState, DraftAxisGuides, - DraftMeasurementLabel, getNearestAxisAngleLabel, } from '../shared/draft-axis-guides' +import { useWallDrawingMode, useWallDrawingModeKeys } from './drawing-mode' +import RectangleWallTool from './rectangle-tool' + /** * Phase 5 Stage D — wall placement tool (kind-owned). * @@ -443,7 +446,7 @@ function getBelowLevelWalls(): WallNode[] { return getLevelWalls(belowLevel?.id ?? null, nodes) } -export const WallTool: React.FC = () => { +const LineWallTool: React.FC = () => { const unit = useViewer((state) => state.unit) const metricNotation = useViewer((state) => state.metricNotation) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') @@ -488,10 +491,6 @@ export const WallTool: React.FC = () => { const measurementColor = isDark ? '#ffffff' : '#111111' const measurementShadowColor = isDark ? '#111111' : '#ffffff' - // Clear preset-seeded defaults on deactivation so a later manual wall draw - // isn't built with a stale preset's parameters. Unmount-only. - useEffect(() => () => useEditor.getState().setToolDefaults('wall', null), []) - useEffect(() => { let gridPosition: WallPlanPoint = [0, 0] let previousWallEnd: [number, number] | null = null @@ -948,4 +947,15 @@ export const WallTool: React.FC = () => { ) } +export const WallTool: React.FC = () => { + // Clear preset-seeded defaults on deactivation so a later manual wall draw + // isn't built with a stale preset's parameters. Unmount-only. + useEffect(() => () => useEditor.getState().setToolDefaults('wall', null), []) + + useWallDrawingModeKeys() + + const mode = useWallDrawingMode((s) => s.mode) + return mode === 'rectangle' ? : +} + export default WallTool diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 07f8faed09..2096a3956a 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,4 +1,3 @@ -import { commitOpeningMove } from '../shared/commit-opening-move' import { type AnyNodeId, type DormerEvent, @@ -39,6 +38,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { commitOpeningMove } from '../shared/commit-opening-move' import { type DormerWindowTarget, dormerEventFromHostedWindow, From 3a9006f4a8e8b49141f08b9a2c57d6d8f94dd7d6 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Mon, 21 Sep 2026 17:36:19 +0200 Subject: [PATCH 6/8] fix(editor): group selection acts on the selection and fits it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-selection box measured the meshes in world space and mapped two corners of that box into the level frame; under a rotated building those corners land beside the meshes, so the 2D dashed box sat away from the walls and group move/rotate pivoted on the wrong point. In 2D-only view it was worse still: the 3D scene is paused there, so meshes are unbuilt placeholders or carry stale cached bounds, and a selection holding a room's floor and ceiling boxed 13 m of nothing. It also grew the selection to every wall connected through junctions, so two walls of a room boxed (and moved) the whole connected structure. Group transforms now act on the selection: connected walls outside it stretch at their shared ends to stay joined (the existing neighbour links). groupPlanBounds measures from node data wherever the plan draws from data — walls by their mitered outline, slabs, ceilings and zones by their polygon, fences by their run — and only placed objects by their meshes, with fresh bounds, placeholders skipped and the anchor as fallback. The 2D box, group move and duplicate bounds, keyboard R/T and the 3D rotate gizmo share its centre as the pivot, and the 3D dashed box turns with the building like the 2D one. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017sG15rKXusC8rbBg6gjSRm --- .../editor-2d/floorplan-group-move.tsx | 41 ++--- .../src/components/editor/group-actions.ts | 14 +- .../editor/group-floating-action-menu.tsx | 28 ++-- .../src/components/editor/group-move-3d.ts | 10 +- .../components/editor/group-rotate-handle.tsx | 48 +++--- .../editor/group-selection-box-3d.tsx | 42 +++-- .../editor/group-transform-shared.test.ts | 127 ++++++++++++++- .../editor/group-transform-shared.ts | 152 +++++++++--------- packages/editor/src/hooks/use-keyboard.ts | 26 ++- 9 files changed, 310 insertions(+), 178 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 1c63b3a3cf..f3f36eab40 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -37,8 +37,7 @@ import useInteractionScope, { useMovingNode } from '../../store/use-interaction- import { classifyParticipant, collectParticipants, - computeGroupBox, - expandToComponent, + computeGroupPlanBox, type GroupPlanBounds, groupPlanBounds, levelFrame, @@ -128,9 +127,8 @@ export function startFloorplanGroupMove( const participantIds = selectedIds.filter( (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, ) - // Move the full connected wall/fence component, mirroring the 3D gizmo. - const fullIds = expandToComponent(participantIds, nodes, levelId) - const { starts, links } = collectParticipants(fullIds, nodes, levelId) + // Only the selection moves; connected walls stretch through `links`, as in 3D. + const { starts, links } = collectParticipants(participantIds, nodes, levelId) if (starts.length === 0) return null const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] @@ -144,10 +142,9 @@ export function startFloorplanGroupMove( const candidates = collectAlignmentAnchors(staticNodes, '', levelId) // 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. + // the moving anchors, measured in the level frame like the plan itself. const { inverse: frameInv } = levelFrame(levelId) - const restBounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv) + const restBounds = groupPlanBounds(starts, frameInv) if (!restBounds) return null const restAnchors = bboxCornerAnchors( 'group-move', @@ -441,12 +438,11 @@ export function startFloorplanGroupRotate(event: { (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, ) if (participantIds.length === 0) return false - const fullIds = expandToComponent(participantIds, nodes, levelId) - const { starts, links } = collectParticipants(fullIds, nodes, levelId) + const { starts, links } = collectParticipants(participantIds, nodes, levelId) if (starts.length === 0) return false const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] const { inverse: frameInv } = levelFrame(levelId) - const bounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv) + const bounds = groupPlanBounds(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. @@ -605,8 +601,9 @@ const GROUP_BOX_ROTATE_CURSOR_STYLE = { /** * Dashed bounding box around the current multi-selection's transformable - * participants (expanded to the welded wall/fence component) — shows what a - * group drag will carry along, and IS the group's drag handle: press anywhere + * participants — what a group drag carries (connected walls outside the + * selection stretch at their shared ends to stay joined) — and IS the group's + * drag handle: press anywhere * inside it to slide the group, click to pick it up. Holding a selection * modifier (Cmd/Ctrl/Shift) lets pointer events pass through so members under * the box can still be toggled in and out. Rides the live drag delta so it @@ -653,7 +650,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB }, []) // `meshEpoch` re-runs the measurement once the meshes settle after a scene - // change (undo/redo included) — `computeGroupBox` reads mesh world bounds, + // change (undo/redo included) — `computeGroupPlanBox` reads mesh bounds, // which lag the `nodes` commit by a frame or two. const meshEpoch = useMeshSettleEpoch(nodes) const box = useMemo(() => { @@ -663,17 +660,13 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, ) if (participantIds.length === 0) return null - const fullIds = expandToComponent(participantIds, nodes, levelId) - const world = computeGroupBox(fullIds) - if (!world) return null - const { inverse } = levelFrame(levelId) - const min = world.min.clone().applyMatrix4(inverse) - const max = world.max.clone().applyMatrix4(inverse) + const plan = computeGroupPlanBox(participantIds, levelId) + if (!plan) return null return { - x: Math.min(min.x, max.x), - z: Math.min(min.z, max.z), - width: Math.abs(max.x - min.x), - depth: Math.abs(max.z - min.z), + x: plan.minX, + z: plan.minZ, + width: plan.maxX - plan.minX, + depth: plan.maxZ - plan.minZ, } }, [selectedIds, levelId, nodes, meshEpoch]) diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index e2c0d68765..f41db4cb8c 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -39,7 +39,6 @@ import { classifyParticipant, collectParticipants, computeGroupBox, - expandToComponent, groupPlanBounds, levelFrame, planBoundsCenter, @@ -83,8 +82,8 @@ export function canGroupPickUp(): boolean { * until a click commits, mirroring the single-node `movingNode` flow. Returns * false when the selection holds no transformable participants. * - * `scopeToSelection` limits the moving set to the selected participants — - * no connected-component expansion and no welded-neighbor endpoints. The + * `scopeToSelection` drops the welded-neighbor endpoints, so connected walls + * don't stretch along with the move. The * Duplicate flow needs this: its clones sit EXACTLY on the originals, so * junction coincidence would otherwise weld the originals into the pick-up * and drag them along with the copies. @@ -96,10 +95,7 @@ export function startGroupPickUp( const participantIds = groupParticipantIds() if (participantIds.length === 0) return false const nodes = useScene.getState().nodes - const fullIds = opts.scopeToSelection - ? participantIds - : expandToComponent(participantIds, nodes, levelId) - const collected = collectParticipants(fullIds, nodes, levelId) + const collected = collectParticipants(participantIds, nodes, levelId) // Mutable: mid-carry R/T rotates these snapshots in place. let starts = collected.starts let links = opts.scopeToSelection ? [] : collected.links @@ -107,8 +103,8 @@ export function startGroupPickUp( const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] const { inverse: frameInv } = levelFrame(levelId) - const restBox = computeGroupBox(fullIds) - const startBounds = groupPlanBounds(restBox, starts, frameInv) + const restBox = computeGroupBox(participantIds) + const startBounds = groupPlanBounds(starts, frameInv) if (!startBounds) return false // Mutable: mid-carry R/T re-seeds the footprint around the same pivot. let restBounds = startBounds diff --git a/packages/editor/src/components/editor/group-floating-action-menu.tsx b/packages/editor/src/components/editor/group-floating-action-menu.tsx index 6f755add3c..6f978664e4 100644 --- a/packages/editor/src/components/editor/group-floating-action-menu.tsx +++ b/packages/editor/src/components/editor/group-floating-action-menu.tsx @@ -15,7 +15,12 @@ import useSessionGroups, { ungroupCurrentSelection, } from '../../store/use-session-groups' import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from './group-actions' -import { classifyParticipant, computeGroupBox, expandToComponent } from './group-transform-shared' +import { + classifyParticipant, + computeGroupBox, + computeGroupPlanBox, + levelFrame, +} from './group-transform-shared' import { NodeActionMenu } from './node-action-menu' import { useMeshSettleEpoch } from './use-mesh-settle-epoch' @@ -69,15 +74,20 @@ export function GroupFloatingActionMenu() { const anchor = useMemo(() => { void meshEpoch if (participantIds.length === 0) return null - const fullIds = expandToComponent(participantIds, nodes, levelId) - const box = computeGroupBox(fullIds) + const box = computeGroupBox(participantIds) if (!box) return null - return new THREE.Vector3( - (box.min.x + box.max.x) / 2, - box.max.y + MENU_Y_OFFSET, - (box.min.z + box.max.z) / 2, - ) - }, [participantIds, nodes, levelId, meshEpoch]) + // Over the level-frame box's centre (the dashed boxes' and gizmo's), so the + // pill stays on the box under a rotated building; the mesh box gives the top. + const levelId = useViewer.getState().selection.levelId + const plan = computeGroupPlanBox(participantIds, levelId) + const anchor = plan + ? new THREE.Vector3((plan.minX + plan.maxX) / 2, 0, (plan.minZ + plan.maxZ) / 2).applyMatrix4( + levelFrame(levelId).matrix, + ) + : new THREE.Vector3((box.min.x + box.max.x) / 2, 0, (box.min.z + box.max.z) / 2) + anchor.y = box.max.y + MENU_Y_OFFSET + return anchor + }, [participantIds, meshEpoch]) useFrame((state) => { if (!(menuScaleRef.current && groupRef.current)) return diff --git a/packages/editor/src/components/editor/group-move-3d.ts b/packages/editor/src/components/editor/group-move-3d.ts index 3a26251e5d..53f6bf0e57 100644 --- a/packages/editor/src/components/editor/group-move-3d.ts +++ b/packages/editor/src/components/editor/group-move-3d.ts @@ -31,7 +31,6 @@ import { classifyParticipant, collectParticipants, computeGroupBox, - expandToComponent, type GroupPlanBounds, groupPlanBounds, levelFrame, @@ -107,16 +106,15 @@ export function armGroupMove3d(args: { const participantIds = selectedIds.filter( (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, ) - // Move the full connected wall/fence component, mirroring the 2D session. - const fullIds = expandToComponent(participantIds, nodes, levelId) - const { starts, links } = collectParticipants(fullIds, nodes, levelId) + // Only the selection moves; connected walls stretch through `links`, as in 2D. + const { starts, links } = collectParticipants(participantIds, nodes, levelId) if (starts.length === 0) return null const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] // Horizontal drag plane at the group's base; placements live in the level // frame, so world-space plane hits convert through it (a rotated building // would otherwise drift off-axis from the cursor). - const restBox = computeGroupBox(fullIds) + const restBox = computeGroupBox(participantIds) if (!restBox) return null const plane = new Plane(new Vector3(0, 1, 0), -restBox.min.y) const { inverse: frameInv } = levelFrame(levelId) @@ -136,7 +134,7 @@ export function armGroupMove3d(args: { if (n && !movingIdSet.has(nid)) staticNodes[nid] = n } const candidates = collectAlignmentAnchors(staticNodes, '', levelId) - const restBounds = groupPlanBounds(restBox, starts, frameInv) + const restBounds = groupPlanBounds(starts, frameInv) if (!restBounds) return null const restAnchors = bboxCornerAnchors( 'group-move', diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 0503b4335e..939f17b192 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -28,7 +28,7 @@ import { classifyParticipant, collectParticipants, computeGroupBox, - expandToComponent, + computeGroupPlanBox, levelFrame, rotateGroupPatches, type Vec3, @@ -79,14 +79,6 @@ export function GroupRotateHandle() { [selectedIds, levelId, nodes], ) - // Gate on the explicit selection (so a single connected wall still gets the - // per-node handles), but transform the full connected wall/fence component so - // attached structure rotates rigidly as one piece. - const fullIds = useMemo( - () => expandToComponent(participantIds, nodes, levelId), - [participantIds, levelId, nodes], - ) - const shouldRender = participantIds.length >= 2 && mode !== 'delete' && @@ -98,7 +90,13 @@ export function GroupRotateHandle() { if (!shouldRender) return null // Remount when the moving set changes so the rest pivot re-seeds cleanly. - return + return ( + + ) } function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) { @@ -125,22 +123,28 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: const baseScale = zoom * ARROW_SCALE * 1.05 const scale = (isHovered ? 1.12 : 1) * baseScale - // World-space bounding box of the selected meshes. Levels are axis-aligned in - // XZ, so world XZ coincides with each node's level-local placement — letting - // us rotate `position` / `start` / `end` directly against the pivot without - // per-node frame conversion. - // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin - // - `corner` = front-right bbox corner at mid-height → where the gizmo sits + // Both rest points come from the level-frame box (the dashed boxes' box, also + // keyboard R/T's pivot), carried to world so they stay on it under a rotated + // building; the world mesh box only supplies heights. + // - `pivot` = box center, Y at the group's base → the rotation origin + // - `corner` = far box corner at mid-height → where the gizmo sits const rest = useMemo(() => { void meshEpoch const box = computeGroupBox(ids) if (!box) return null - const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2) - const corner = new Vector3( - box.max.x + CORNER_OFFSET, - (box.min.y + box.max.y) / 2, - box.max.z + CORNER_OFFSET, - ) + const levelId = useViewer.getState().selection.levelId + const { matrix } = levelFrame(levelId) + const plan = computeGroupPlanBox(ids, levelId) + const pivot = plan + ? new Vector3((plan.minX + plan.maxX) / 2, 0, (plan.minZ + plan.maxZ) / 2).applyMatrix4( + matrix, + ) + : new Vector3((box.min.x + box.max.x) / 2, 0, (box.min.z + box.max.z) / 2) + pivot.y = box.min.y + const corner = plan + ? new Vector3(plan.maxX + CORNER_OFFSET, 0, plan.maxZ + CORNER_OFFSET).applyMatrix4(matrix) + : new Vector3(box.max.x + CORNER_OFFSET, 0, box.max.z + CORNER_OFFSET) + corner.y = (box.min.y + box.max.y) / 2 return { pivot, corner } }, [ids, meshEpoch]) diff --git a/packages/editor/src/components/editor/group-selection-box-3d.tsx b/packages/editor/src/components/editor/group-selection-box-3d.tsx index 29d78739a0..6e33fe67ed 100644 --- a/packages/editor/src/components/editor/group-selection-box-3d.tsx +++ b/packages/editor/src/components/editor/group-selection-box-3d.tsx @@ -12,7 +12,7 @@ import { armGroupMove3d } from './group-move-3d' import { classifyParticipant, computeGroupBox, - expandToComponent, + computeGroupPlanBox, levelFrame, } from './group-transform-shared' import { useMeshSettleEpoch } from './use-mesh-settle-epoch' @@ -24,8 +24,8 @@ const BOX_PAD = 0.06 /** * 3D sibling of the 2D dashed group selection box: a dashed wireframe around - * the multi-selection's transformable participants (expanded to the welded - * wall/fence component) that doubles as the group's whole-volume drag + * the multi-selection's transformable participants that doubles as the + * group's whole-volume drag * handle — move cursor across it, press-drag anywhere on it slides the group, * a plain click picks it up. Holding a selection modifier passes the press * through so members inside can still be toggled. Rides the live drag delta @@ -55,22 +55,30 @@ export function GroupSelectionBox3D() { const box = useMemo(() => { void meshEpoch if (participantIds.length === 0) return null - const fullIds = expandToComponent(participantIds, nodes, levelId) - const world = computeGroupBox(fullIds) - if (!world) return null - const size = new THREE.Vector3() - world.getSize(size) - const center = new THREE.Vector3() - world.getCenter(center) + // Aligned with the building like the 2D box: level-frame footprint, world + // height span, turned by the level's rotation. + const world = computeGroupBox(participantIds) + const { matrix } = levelFrame(levelId) + const plan = computeGroupPlanBox(participantIds, levelId) + if (!(world && plan)) return null + const center = new THREE.Vector3( + (plan.minX + plan.maxX) / 2, + 0, + (plan.minZ + plan.maxZ) / 2, + ).applyMatrix4(matrix) + center.y = (world.min.y + world.max.y) / 2 + const quaternion = new THREE.Quaternion() + matrix.decompose(new THREE.Vector3(), quaternion, new THREE.Vector3()) return { - size: [size.x + 2 * BOX_PAD, size.y + 2 * BOX_PAD, size.z + 2 * BOX_PAD] as [ - number, - number, - number, - ], + size: [ + plan.maxX - plan.minX + 2 * BOX_PAD, + world.max.y - world.min.y + 2 * BOX_PAD, + plan.maxZ - plan.minZ + 2 * BOX_PAD, + ] as [number, number, number], center, + quaternion, } - }, [participantIds, nodes, levelId, meshEpoch]) + }, [participantIds, levelId, meshEpoch]) // Dashed wireframe. Built per box size (rare — selection / commit changes) // because LineDashedMaterial measures dashes along the line, so scaling a @@ -156,7 +164,7 @@ export function GroupSelectionBox3D() { ] return ( - + {/* Invisible whole-volume hit target — the box IS the drag handle. */} { ]) }) }) + +describe('group plan bounds', () => { + const withNodes = (nodes: AnyNode[], run: () => void) => { + const saved = useScene.getState().nodes + useScene.setState({ nodes: Object.fromEntries(nodes.map((n) => [n.id, n])) as typeof saved }) + try { + run() + } finally { + useScene.setState({ nodes: saved }) + } + } + + test('a placed object measures its built mesh in the level frame, or falls back to its anchor', () => { + const level = new Group() + level.position.set(10, 0, -4) + level.rotation.y = Math.PI / 5 + const item = new Mesh(new BoxGeometry(1, 1, 1)) + item.position.set(2, 0.5, 0) + level.add(item) + level.updateWorldMatrix(true, true) + const registry = sceneRegistry.nodes as unknown as Map + registry.set('item_plan_bounds_test', item) + const start = { + id: 'item_plan_bounds_test' as AnyNodeId, + kind: 'vec3' as const, + position: [2, 0, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + } + try { + const frameInv = level.matrixWorld.clone().invert() + const built = groupPlanBounds([start], frameInv) + expect(built?.minX).toBeCloseTo(1.5) + expect(built?.maxX).toBeCloseTo(2.5) + expect(built?.minZ).toBeCloseTo(-0.5) + expect(built?.maxZ).toBeCloseTo(0.5) + // Unbuilt while the 3D scene is paused: the placeholder must not count. + item.geometry.userData.placeholder = true + expect(groupPlanBounds([start], frameInv)).toEqual({ minX: 2, minZ: 0, maxX: 2, maxZ: 0 }) + } finally { + registry.delete('item_plan_bounds_test') + } + }) + + test('hugs the selected wall outlines; a connected wall stays outside', () => { + const level = LevelNode.parse({ children: [] }) + const bottom = WallNode.parse({ + parentId: level.id, + start: [0, 0], + end: [2.4, 0], + thickness: 0.2, + }) + const side = WallNode.parse({ + parentId: level.id, + start: [2.4, 0], + end: [2.4, 5.3], + thickness: 0.2, + }) + const neighbour = WallNode.parse({ + parentId: level.id, + start: [2.4, 5.3], + end: [8, 5.3], + thickness: 0.2, + }) + withNodes([level, bottom, side, neighbour], () => { + const plan = computeGroupPlanBox([bottom.id, side.id], level.id) + expect(plan?.minX).toBeCloseTo(0) + expect(plan?.maxX).toBeCloseTo(2.5) + expect(plan?.minZ).toBeCloseTo(-0.1) + expect(plan?.maxZ).toBeLessThan(5.5) + expect(plan?.maxX).toBeLessThan(8) + }) + }) + + test('two walls with the room floor and ceiling: the box is the drawn outlines, never the meshes', () => { + // A user's selection whose box came out 13 m wide in 2D-only view. + const level = LevelNode.parse({ id: 'level_vvkg048zziz9ktiy', children: [] }) + const room: [number, number][] = [ + [-3.6221682640435353, -9.037533979512114], + [-6.21504662384804, -8.29671159099654], + [-5.712152486161543, -6.5365821090938], + [-5.712152486161543, -5], + [-8.223564365687542, -5], + [-8.223564365687542, -6.5365821090938], + [-10.712152486161543, -6.5365821090938], + [-11.712152486161543, -8.5365821090938], + [-4.119274126357038, -10.777404497609373], + ] + const wall15 = WallNode.parse({ + parentId: level.id, + start: [-10.712152486161543, -6.5365821090938], + end: [-11.712152486161543, -8.5365821090938], + }) + const wall12 = WallNode.parse({ + parentId: level.id, + start: [-11.712152486161543, -8.5365821090938], + end: [-6.712152486161543, -10.0365821090938], + }) + const slab = SlabNode.parse({ parentId: level.id, polygon: room, elevation: 0.05 }) + const ceiling = CeilingNode.parse({ parentId: level.id, polygon: room, height: 2.49 }) + withNodes([level, wall15, wall12, slab, ceiling], () => { + const plan = computeGroupPlanBox([wall15.id, ceiling.id, slab.id, wall12.id], level.id)! + expect(plan.minX).toBeLessThan(-11.71) + expect(plan.minX).toBeGreaterThan(-11.95) + expect(plan.maxX).toBeCloseTo(-3.62, 1) + expect(plan.minZ).toBeCloseTo(-10.78, 1) + expect(plan.maxZ).toBeCloseTo(-5, 1) + expect(plan.maxX - plan.minX).toBeLessThan(8.4) + }) + }) +}) diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts index 408eb2cc65..954f3dbaf0 100644 --- a/packages/editor/src/components/editor/group-transform-shared.ts +++ b/packages/editor/src/components/editor/group-transform-shared.ts @@ -1,11 +1,15 @@ import { type AnyNode, type AnyNodeId, + calculateLevelMiters, + getWallPlanFootprint, nodeRegistry, resolveBuildingForLevel, sceneRegistry, + useScene, + type WallNode, } from '@pascal-app/core' -import { Box3, Matrix4 } from 'three' +import { Box3, type BufferGeometry, Matrix4, type Object3D, Vector3 } from 'three' // Shared plumbing for the group transform gizmos (rotate + move). Both operate // on the same multi-selection: classify each participant by how its placement @@ -242,48 +246,6 @@ export function collectParticipants( return { starts, links } } -// Grow a selection to the full connected component of walls/fences: any -// endpoint node transitively reachable through shared junctions from a selected -// endpoint node joins in, so the whole rigid structure transforms as one piece -// (rather than tearing/stretching at the boundary). Non-endpoint selections -// (items, columns) pass through unchanged. -export function expandToComponent( - selectedIds: string[], - sceneNodes: Record, - levelId: string | null, -): string[] { - const endpoints: { id: string; start: Vec2; end: Vec2 }[] = [] - for (const [id, node] of Object.entries(sceneNodes)) { - if (classifyParticipant(node, levelId, sceneNodes) === 'endpoint') { - const n = node as AnyNode & { start: Vec2; end: Vec2 } - endpoints.push({ id, start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]] }) - } - } - const included = new Set(selectedIds) - if (!endpoints.some((e) => included.has(e.id))) return selectedIds - - let changed = true - while (changed) { - changed = false - for (const e of endpoints) { - if (included.has(e.id)) continue - const touches = endpoints.some( - (o) => - included.has(o.id) && - (nearPoint(e.start, o.start) || - nearPoint(e.start, o.end) || - nearPoint(e.end, o.start) || - nearPoint(e.end, o.end)), - ) - if (touches) { - included.add(e.id) - changed = true - } - } - } - return Array.from(included) -} - // Per-node field patch, keyed for `useLiveNodeOverrides.setMany` during a live // preview and for the single batched `updateNodes` on commit. export type GroupPatch = readonly [AnyNodeId, Record] @@ -389,9 +351,24 @@ export function rotateGroupSnapshots( 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. -function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null { +const planCorner = new Vector3() +const planMatrix = new Matrix4() + +/** + * The selection's footprint in the level frame — the plan's own coordinates, + * measured from node data wherever the plan draws from data: walls by their + * mitered outline, polygon hosts (slab, ceiling, zone) by their polygon, fences + * by their run. Only placed objects (items, columns, stairs…) measure their + * meshes: bounds go through `frameInv × matrixWorld` (a world box can't be + * carried into a rotated building's frame), placeholders — unbuilt while the + * 3D scene is paused in 2D-only view — are skipped, and a node with no built + * mesh falls back to its anchor. + */ +export function groupPlanBounds( + starts: ParticipantStart[], + frameInv: Matrix4, +): GroupPlanBounds | null { + const nodes = useScene.getState().nodes let minX = Number.POSITIVE_INFINITY let minZ = Number.POSITIVE_INFINITY let maxX = Number.NEGATIVE_INFINITY @@ -402,43 +379,72 @@ function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null maxX = Math.max(maxX, x) maxZ = Math.max(maxZ, z) } + const miters = new Map>() + const wallOutline = (wall: WallNode) => { + const levelId = wall.parentId ?? null + let levelMiters = miters.get(levelId) + if (!levelMiters) { + levelMiters = calculateLevelMiters( + Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId, + ), + ) + miters.set(levelId, levelMiters) + } + return getWallPlanFootprint(wall, levelMiters) + } + const measureMeshes = (id: string) => { + const obj = sceneRegistry.nodes.get(id as AnyNodeId) + if (!obj) return false + obj.updateWorldMatrix(true, true) + let found = false + obj.traverse((child: Object3D) => { + const geometry = (child as Object3D & { geometry?: BufferGeometry }).geometry + if (!child.visible || !geometry || geometry.userData.placeholder) return + // Always fresh: geometries rebuilt in place keep a stale cached box. + geometry.computeBoundingBox() + const bounds = geometry.boundingBox + if (!bounds || bounds.isEmpty()) return + planMatrix.multiplyMatrices(frameInv, child.matrixWorld) + for (let corner = 0; corner < 8; corner++) { + planCorner + .set( + corner & 1 ? bounds.max.x : bounds.min.x, + corner & 2 ? bounds.max.y : bounds.min.y, + corner & 4 ? bounds.max.z : bounds.min.z, + ) + .applyMatrix4(planMatrix) + reach(planCorner.x, planCorner.z) + } + found = true + }) + return found + } for (const s of starts) { + const node = nodes[s.id] if (s.kind === 'endpoint') { + const outline = node?.type === 'wall' ? wallOutline(node) : [] + if (outline.length > 0) { + for (const point of outline) reach(point.x, point.y) + continue + } reach(s.start[0], s.start[1]) reach(s.end[0], s.end[1]) + const path = (node as { path?: unknown } | undefined)?.path + if (isVec2Array(path)) for (const [x, z] of path) reach(x, z) } else if (s.kind === 'polygon') { - for (const [x, z] of s.polygon) { - reach(x, z) - } - } else { + for (const [x, z] of s.polygon) reach(x, z) + } else if (!measureMeshes(s.id)) { reach(s.position[0], s.position[2]) } } - if (!Number.isFinite(minX)) return null - return { minX, minZ, maxX, maxZ } + return Number.isFinite(minX) ? { minX, minZ, maxX, maxZ } : null } -// 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), - } +/** `groupPlanBounds` for a selection: the dashed boxes and gizmos measure this. */ +export function computeGroupPlanBox(ids: string[], levelId: string | null): GroupPlanBounds | null { + const { starts } = collectParticipants(ids, useScene.getState().nodes, levelId) + return groupPlanBounds(starts, levelFrame(levelId).inverse) } export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [ diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index e9b0c29f15..f3a75bd8d1 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -9,7 +9,6 @@ import { } from '@pascal-app/core' import { cancelPerfAction, markPerfAction, useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' -import { Vector3 } from 'three' import { cutSelectionToEditorClipboard, deleteSelection, @@ -18,9 +17,9 @@ import { import { classifyParticipant, collectParticipants, - computeGroupBox, - expandToComponent, + groupPlanBounds, levelFrame, + planBoundsCenter, rotateGroupPatches, } from '../components/editor/group-transform-shared' import { steppedRotation } from '../components/tools/item/placement-math' @@ -66,27 +65,20 @@ function rotateGroupSelection(direction: 1 | -1): boolean { (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, ) if (participantIds.length === 0) return false - const fullIds = expandToComponent(participantIds, nodes, levelId) - const { starts, links } = collectParticipants(fullIds, nodes, levelId) + const { starts, links } = collectParticipants(participantIds, nodes, levelId) if (starts.length === 0) return false - // Same pivot as the 3D gizmo: the selection's world bbox center, converted - // into the level frame before orbiting placements (a rotated building would - // otherwise displace the centre). - const box = computeGroupBox(fullIds) - if (!box) return false - const worldCenter = new Vector3( - (box.min.x + box.max.x) / 2, - box.min.y, - (box.min.z + box.max.z) / 2, - ) - const localCenter = worldCenter.applyMatrix4(levelFrame(levelId).inverse) + // Same pivot as the dashed boxes and the rotate gizmo: the selection's box + // centre in the level frame. + const bounds = groupPlanBounds(starts, levelFrame(levelId).inverse) + if (!bounds) return false + const [pivotX, pivotZ] = planBoundsCenter(bounds) // R (+45° yaw) orbits by -45° in the atan2 x→z sense: yaw = rotation - delta // (see rotateGroupPatches), so keyboard direction matches the single-node // steppedRotation sense. const delta = -direction * (Math.PI / 4) - const patches = rotateGroupPatches(starts, links, { x: localCenter.x, z: localCenter.z }, delta) + const patches = rotateGroupPatches(starts, links, { x: pivotX, z: pivotZ }, delta) // Space detection stays out: a rigid rotation of existing walls must not // re-create the room's auto floors/ceilings at the new bearing. pauseSpaceDetection() From 13173454e49fb9705819909c666f91b5d170a209 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Mon, 21 Sep 2026 22:37:53 +0200 Subject: [PATCH 7/8] fix(editor): deselecting a room's slab in the plan drops its ceiling too A room's slab and ceiling overlap in the plan and only the slab takes a click, so after a marquee picked both, Shift-clicking the slab off left a ceiling nobody could remove from the plan, and the group box kept covering the room. Removing either surface now removes its counterpart (same level, same outline); adding stays single. 3D keeps single toggles, where each surface is clickable on its own. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017sG15rKXusC8rbBg6gjSRm --- .../renderers/floorplan-registry-layer.tsx | 20 ++++++-- packages/nodes/src/ceiling/definition.ts | 5 ++ .../src/shared/surface-counterparts.test.ts | 35 +++++++++++++ .../nodes/src/shared/surface-counterparts.ts | 49 +++++++++++++++++++ packages/nodes/src/slab/definition.ts | 5 ++ 5 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 packages/nodes/src/shared/surface-counterparts.test.ts create mode 100644 packages/nodes/src/shared/surface-counterparts.ts diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index ba96c12ccb..2c711dd105 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -699,9 +699,23 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const currentSelectedIds = useViewer.getState().selection.selectedIds let nextSelectedIds: string[] if (options.shouldToggle) { - nextSelectedIds = currentSelectedIds.includes(id) - ? currentSelectedIds.filter((selectedId) => selectedId !== id) - : [...currentSelectedIds, id] + if (currentSelectedIds.includes(id)) { + // A room's slab and ceiling overlap in the plan and only the slab + // takes the click, so removing one removes the other too (a marquee + // picks both). 3D keeps single toggles: each is clickable there. + const nodes = useScene.getState().nodes + const node = nodes[id as AnyNodeId] + const counterparts = node + ? (getFloorplanNodeExtension(nodeRegistry.get(node.type))?.selectionCounterparts?.({ + node, + nodes, + }) ?? []) + : [] + const removed = new Set([id, ...counterparts]) + nextSelectedIds = currentSelectedIds.filter((selectedId) => !removed.has(selectedId)) + } else { + nextSelectedIds = [...currentSelectedIds, id] + } } else if (options.isolateMember) { nextSelectedIds = [id] } else { diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index 5ab065c5c7..05ac67ddcb 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -12,10 +12,12 @@ import { clearStructuralElevationGuide, DRAFTING_SURFACE_EXTENSION_KEY, type DraftingSurfaceExtension, + type FloorplanNodeExtension, publishStructuralElevationGuide, resolveStructuralElevationSnap, } from '@pascal-app/editor' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' +import { sameOutlineSurfaceCounterparts } from '../shared/surface-counterparts' import { buildCeilingFloorplan } from './floorplan' import { ceilingAddVertexAffordance, @@ -137,6 +139,9 @@ export const ceilingDefinition: NodeDefinition = { kind: 'ceiling', raycast: 'underside', } satisfies DraftingSurfaceExtension, + 'pascal:editor/floorplan': { + selectionCounterparts: sameOutlineSurfaceCounterparts, + } satisfies FloorplanNodeExtension, }, // Height-less on purpose: a new ceiling follows the level top until the diff --git a/packages/nodes/src/shared/surface-counterparts.test.ts b/packages/nodes/src/shared/surface-counterparts.test.ts new file mode 100644 index 0000000000..142c5b9ac6 --- /dev/null +++ b/packages/nodes/src/shared/surface-counterparts.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'bun:test' +import { type AnyNode, CeilingNode, LevelNode, SlabNode } from '@pascal-app/core' +import { sameOutlineSurfaceCounterparts } from './surface-counterparts' + +const level = LevelNode.parse({ children: [] }) +const otherLevel = LevelNode.parse({ children: [] }) +const room: [number, number][] = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] +const slab = SlabNode.parse({ parentId: level.id, polygon: room, elevation: 0.05 }) +const ceiling = CeilingNode.parse({ parentId: level.id, polygon: room, height: 2.49 }) +const upstairsCeiling = CeilingNode.parse({ parentId: otherLevel.id, polygon: room, height: 2.49 }) +const hallCeiling = CeilingNode.parse({ + parentId: level.id, + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + height: 2.49, +}) +const nodes = Object.fromEntries( + [level, otherLevel, slab, ceiling, upstairsCeiling, hallCeiling].map((n) => [n.id, n]), +) as Record + +test('a slab and a ceiling with the same outline on the same level are counterparts', () => { + expect(sameOutlineSurfaceCounterparts({ node: slab, nodes })).toEqual([ceiling.id]) + expect(sameOutlineSurfaceCounterparts({ node: ceiling, nodes })).toEqual([slab.id]) + expect(sameOutlineSurfaceCounterparts({ node: hallCeiling, nodes })).toEqual([]) + expect(sameOutlineSurfaceCounterparts({ node: level, nodes })).toEqual([]) +}) diff --git a/packages/nodes/src/shared/surface-counterparts.ts b/packages/nodes/src/shared/surface-counterparts.ts new file mode 100644 index 0000000000..dcf01e7742 --- /dev/null +++ b/packages/nodes/src/shared/surface-counterparts.ts @@ -0,0 +1,49 @@ +import type { AnyNode, AnyNodeId } from '@pascal-app/core' + +const POINT_EPSILON = 1e-6 + +type SurfaceNode = AnyNode & { polygon: readonly (readonly [number, number])[] } + +function isSurface(node: AnyNode | undefined): node is SurfaceNode { + return ( + (node?.type === 'slab' || node?.type === 'ceiling') && + Array.isArray((node as { polygon?: unknown }).polygon) + ) +} + +function samePolygon(a: SurfaceNode['polygon'], b: SurfaceNode['polygon']) { + return ( + a.length === b.length && + a.every( + (point, index) => + Math.abs(point[0] - b[index]![0]) <= POINT_EPSILON && + Math.abs(point[1] - b[index]![1]) <= POINT_EPSILON, + ) + ) +} + +/** + * The other surface of the same room: a slab's ceiling, a ceiling's slab — + * on the same level with the same outline. The plan draws them on top of each + * other (only the slab takes a click), so deselecting one deselects both. + * Both kinds declare it as their plan `selectionCounterparts`. + */ +export function sameOutlineSurfaceCounterparts({ + node, + nodes, +}: { + node: AnyNode + nodes: Readonly> +}): AnyNodeId[] { + if (!isSurface(node)) return [] + const other = node.type === 'slab' ? 'ceiling' : 'slab' + return Object.values(nodes) + .filter( + (candidate): candidate is SurfaceNode => + isSurface(candidate) && + candidate.type === other && + (candidate.parentId ?? null) === (node.parentId ?? null) && + samePolygon(candidate.polygon, node.polygon), + ) + .map((candidate) => candidate.id as AnyNodeId) +} diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index e7bc8bbcce..439311dd37 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -14,10 +14,12 @@ import { clearStructuralElevationGuide, DRAFTING_SURFACE_EXTENSION_KEY, type DraftingSurfaceExtension, + type FloorplanNodeExtension, publishStructuralElevationGuide, resolveStructuralElevationSnap, } from '@pascal-app/editor' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' +import { sameOutlineSurfaceCounterparts } from '../shared/surface-counterparts' import { applySlabBaseElevationChange, applySlabThicknessChange, @@ -278,6 +280,9 @@ export const slabDefinition: NodeDefinition = { [DRAFTING_SURFACE_EXTENSION_KEY]: { kind: 'slab', } satisfies DraftingSurfaceExtension, + 'pascal:editor/floorplan': { + selectionCounterparts: sameOutlineSurfaceCounterparts, + } satisfies FloorplanNodeExtension, }, defaults: () => ({ From 2878f1bb877cad5d7c8600fa6dfc0723853ee9bb Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Mon, 21 Sep 2026 23:16:24 +0200 Subject: [PATCH 8/8] docs(wiki): navigation parity, selection-scoped groups, wall lifecycle Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017sG15rKXusC8rbBg6gjSRm --- wiki/architecture/interaction-scope.md | 5 +++++ wiki/architecture/node-definitions.md | 6 +++++- wiki/architecture/plugin-authoring.md | 1 + wiki/architecture/tools.md | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/wiki/architecture/interaction-scope.md b/wiki/architecture/interaction-scope.md index 380e43f1fb..32eeb0080e 100644 --- a/wiki/architecture/interaction-scope.md +++ b/wiki/architecture/interaction-scope.md @@ -135,6 +135,11 @@ There is no per-kind snapping switch. declaring `NodeDefinition.snapProfile` (`'item' | 'structural'`); `snapContextOf(scope × profile)` maps it — `structural` while **setting direction** (drafting / endpoint drag) → `wall` (angle-bearing), `structural` otherwise (translate / curve) → `polygon` (no angle), `item` → `item`. No profile → no chip. + Tools that are not registered kinds map through `TOOL_SNAP_CONTEXTS` in the same file (the host + room-preset stamp `room`); without an entry a tool has no snap context, so Shift cycling and the HUD + chip stay dead while it runs. A kind's own reshape (the wall split, `reshape: 'split'`) runs as a + `reshaping` scope and inherits that scope's mapping (`polygon` unless it sets direction), so it + never needs a tool entry. - **Single read path.** Tools read `isGridSnapActive()` / `isMagneticSnapActive()` / `isAngleSnapActive()` (`store/use-editor`); the grid step is `useEditor.getState().gridSnapStep` gated on `isGridSnapActive()`. These resolve the mode from the scope via `getActiveSnapContext()` → `snappingModeByContext[context]`. diff --git a/wiki/architecture/node-definitions.md b/wiki/architecture/node-definitions.md index d3ddd41af5..bc6e77b222 100644 --- a/wiki/architecture/node-definitions.md +++ b/wiki/architecture/node-definitions.md @@ -295,7 +295,11 @@ during the gesture. A `Shift` hint should describe the bypass in user terms, suc `Free angle`, `Free place`, or `Bypass guided constraints`. `HelperManager` renders `def.toolHints` through `RegisteredToolHelper`, and active Shift -state can update the row to show that guided constraints are currently bypassed. Select +state can update the row to show that guided constraints are currently bypassed. +`affordanceHints?: Record` is the same contract for a kind's own +reshapes, keyed like `affordanceTools`: while a node of the kind is in that `reshaping` +scope the HUD shows those hints instead of the generic reshape rows (the wall split's +cut-count chip lives there). Select mode is not owned by a node definition, so its helper is derived separately from selection state, selected-node move/rotate capabilities, and held modifiers. diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index 109e99209f..abdf638854 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -47,6 +47,7 @@ The core `Plugin` manifest owns semantic node definitions (and registry-backed i - `floorplan` — pure `(node, ctx) => FloorplanGeometry` for the 2D layer. - `floorplanAffordances` / `floorplanMoveTarget` — 2D drag handlers. - `tool` / `affordanceTools` — 3D placement + move tools (lazy components). +- `affordanceHints` — HUD hints for a kind-owned reshape, keyed like `affordanceTools`; the floorplan extension's `reshapeLayers` (its 2D sibling) and `actionMenu.actions` (action-menu buttons) complete the set. - `presentation` — palette / sidebar metadata (`label`, `icon`, `paletteSection`, etc.). - `mcp` — MCP tool descriptions for AI consumers. - `relations` / `computeLevelData` — sibling lookups + level-batch precompute. diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 19ab6d5b55..20ab309a39 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -138,6 +138,12 @@ Concretely, door/window placement/move keeps these in lockstep across `{door,win Tells that you've broken parity: a sound/guide/snap that fires in 3D but is silent in 2D (or vice-versa), or a fix landed in one move file but not its sibling. The two move files are deliberately near-mirrors; diff them when in doubt. +**Navigation is part of parity.** Movement learned in one view works in the other (WASD, Space + drag, middle drag, wheel, orbit), and split view keeps both in sync through `navigationSyncPose`. In 2D-only view the 3D canvas is paused (`renderPaused`), so nothing driven from its frame loop reaches the plan: the plan owns WASD and the orbit buttons there itself (`components/editor/floorplan-panel.tsx`, sharing `lib/keyboard-pan.ts` with `custom-camera-controls.tsx` — physical keys, same guards and speed) and publishes the pose to 3D when the move ends, while the camera stands down. A navigation input that only exists on the camera side is a 2D regression waiting to happen. + +**Group selection acts on the selection.** Group move / rotate / duplicate transform the selected participants only; connected walls outside the selection stretch at their shared ends (`LinkedNeighbor`). Their footprint comes from plan data, never from meshes: `groupPlanBounds` (`components/editor/group-transform-shared.ts`) reads wall outlines, polygon rings and fence runs in the level frame, and measures meshes only for placed objects (fresh bounds, `userData.placeholder` skipped, anchor as fallback). A world-space mesh box mapped into the level frame lands beside the meshes under a rotated building, and in 2D-only view meshes may be unbuilt. The 2D dashed box, the 3D rotate gizmo and keyboard R/T all pivot on that box's centre. + +**A room's slab and ceiling deselect together in the plan.** The floor plan draws the ceiling as an unfilled outline under the walls (`fill="none"` is click-through, see below), so a marquee can select it but a click can't reach it. Removing either surface from the selection removes its same-outline counterpart: slab and ceiling declare `extensions['pascal:editor/floorplan'].selectionCounterparts` (`packages/nodes/src/shared/surface-counterparts.ts`) and `applyEntrySelection` asks the registry, so the plan never names a kind. Adding stays single, and 3D keeps single toggles because each surface is clickable there. + Plan-view surface movement retains only the original host while the footprint centre is supported. Exiting commits a level-frame floor pose with support re-elected and attachment links removed atomically. Plan view never acquires a new host or cycles @@ -305,6 +311,14 @@ useLiveTransforms.getState().set(node.id, { If the tool *also* rotates the node during the drag, it should drive `rotation` from the current tool state — not from 0, not from the stale node value. +## Wall lifecycle: draw, split, merge + +Walls are drawn as a line chain or a rectangle (`R` toggles inside the wall tool; the HUD's Shape chip cycles the same `useWallDrawingMode` store, `packages/nodes/src/wall/drawing-mode.ts`). In 2D the rectangle tool only claims clicks and publishes its first corner to `useFloorplanDraftPreview`; the panel's linear draft layer draws the four mitered walls with the line draft's plates and guides, so cursor, snapping and alignment are the line wall's own (`packages/nodes/src/wall/floorplan-tool.tsx`). + +Split is a HUD-driven loop cut that lives entirely in `packages/nodes/src/wall/`: `split-session.ts` opens the wall's own `reshaping` scope (`reshape: 'split'`, `driver: 'tool'`, which resolves to the `polygon` snap context — see `interaction-scope.md`) and owns every transition of the `split-store.ts` draft; scrolling sets 1–32 cuts, a single cut follows the pointer with the snapping modes, several divide the wall evenly, and a click commits them as one undo step through `planWallDivisions` (core). The editor mounts it through kind-agnostic seams only: `def.affordanceTools.split` (3D markers, `split-tool.tsx`), `extensions['pascal:editor/floorplan'].reshapeLayers.split` (the plan layer, `split-floorplan-layer.tsx`, mounted by `FloorplanRegisteredToolLayer` while the scope runs), `def.affordanceHints.split` (the HUD's cut-count chip and hints, rendered by `HelperManager` like `toolHints`), and `actionMenu.actions` (`actions.tsx`, the Split and Merge buttons `NodeActionMenu` renders for whichever kinds are selected). Any reshape name without a dedicated arm in `ToolManager` resolves the same way, so the next kind-owned reshape needs no editor change. Merge is the inverse (core `planWallMerge`): selected walls that continue each other join into the wall with the most attachments, openings keep their world position, rooms keep one boundary reference; refusals name the difference in the button's tooltip. The join rule (`systems/wall/wall-merge.ts`) is shared with the delete heal. + +Modes and parameters of these tools live on keys, the wheel and the HUD (`ToolHint.chip`), not in sidebar option rows or floating panels. + ## SVG `fill="none"` is click-through When emitting a `FloorplanGeometry` polygon that should remain interactive but visually invisible (e.g. an item with a thumbnail image carrying the visual weight), use `fill="transparent"`, not `fill="none"`. The default `pointer-events: visiblePainted` only hit-tests the interior when there's a paint server — `none` is not paint, `transparent` is. Without this the floor-plan layer's wrapping `` never sees the `onPointerDown` and clicks don't select the node.