From 5411f5abc82335337cc7de58a20a4d5ea966bc55 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 11 Jun 2026 23:59:15 -0400 Subject: [PATCH] fix(editor): improve guided manipulation and snap affordances --- .../renderers/floorplan-registry-layer.tsx | 240 +++++++++- .../floorplan-background-selection.test.ts | 58 +++ .../editor/floorplan-background-selection.ts | 5 +- .../src/components/editor/floorplan-panel.tsx | 25 +- .../components/editor/node-arrow-handles.tsx | 17 +- .../components/editor/selection-manager.tsx | 449 +++++++++++++----- .../use-floorplan-background-placement.ts | 14 +- .../tools/elevator/move-elevator-tool.tsx | 9 + .../tools/item/placement-math.test.ts | 14 +- .../components/tools/item/placement-math.ts | 6 + .../tools/item/placement-strategies.ts | 1 + .../tools/item/use-placement-coordinator.tsx | 10 +- .../registry/move-registry-node-tool.tsx | 17 + .../ui/helpers/contextual-helper-panel.tsx | 37 ++ .../components/ui/helpers/helper-manager.tsx | 82 +++- .../src/components/ui/helpers/item-helper.tsx | 48 +- .../ui/helpers/registered-tool-helper.tsx | 26 +- .../src/components/ui/helpers/roof-helper.tsx | 25 +- .../ui/item-catalog/item-catalog.tsx | 25 +- .../keyboard-shortcuts-dialog.tsx | 36 +- .../panels/site-panel/chimney-tree-node.tsx | 17 +- .../panels/site-panel/door-tree-node.tsx | 9 +- .../panels/site-panel/dormer-tree-node.tsx | 17 +- .../panels/site-panel/gutter-tree-node.tsx | 17 +- .../panels/site-panel/item-tree-node.tsx | 32 +- .../panels/site-panel/registry-tree-node.tsx | 44 +- .../panels/site-panel/shelf-tree-node.tsx | 18 +- .../site-panel/solar-panel-tree-node.tsx | 17 +- .../sidebar/panels/site-panel/tree-node.tsx | 35 +- .../panels/site-panel/window-tree-node.tsx | 9 +- .../components/ui/snap-target-badge.test.tsx | 40 ++ .../src/components/ui/snap-target-badge.tsx | 88 ++++ packages/editor/src/index.tsx | 8 + .../editor/src/lib/contextual-help.test.ts | 84 ++++ packages/editor/src/lib/contextual-help.ts | 101 ++++ .../src/lib/direct-manipulation.test.ts | 93 ++++ .../editor/src/lib/direct-manipulation.ts | 72 +++ .../src/lib/floorplan/apply-alignment.test.ts | 25 + .../src/lib/floorplan/apply-alignment.ts | 24 +- .../editor/src/lib/placement-drag-release.ts | 23 + .../editor/src/lib/selection-routing.test.ts | 79 +++ packages/editor/src/lib/selection-routing.ts | 124 +++++ .../store/use-direct-manipulation-feedback.ts | 20 + packages/nodes/src/box-vent/move-tool.tsx | 35 +- packages/nodes/src/box-vent/tool.tsx | 27 +- packages/nodes/src/building/move-tool.tsx | 16 +- packages/nodes/src/ceiling/definition.ts | 1 + packages/nodes/src/ceiling/move-tool.tsx | 10 + packages/nodes/src/chimney/move-tool.tsx | 27 +- packages/nodes/src/chimney/tool.tsx | 36 +- packages/nodes/src/column/definition.ts | 2 +- packages/nodes/src/column/move-tool.tsx | 9 + packages/nodes/src/cupola/move-tool.tsx | 35 +- packages/nodes/src/cupola/tool.tsx | 27 +- packages/nodes/src/door/definition.ts | 1 + packages/nodes/src/door/move-tool.tsx | 50 +- packages/nodes/src/door/tool.tsx | 75 ++- packages/nodes/src/dormer/tool.tsx | 63 +-- .../nodes/src/dormer/use-dormer-placement.ts | 24 +- packages/nodes/src/downspout/tool.tsx | 37 +- packages/nodes/src/eyebrow-vent/move-tool.tsx | 35 +- packages/nodes/src/eyebrow-vent/tool.tsx | 27 +- packages/nodes/src/fence/move-tool.tsx | 10 + packages/nodes/src/fence/tool.tsx | 3 +- packages/nodes/src/gutter/move-tool.tsx | 34 +- packages/nodes/src/gutter/tool.tsx | 30 +- packages/nodes/src/ridge-vent/move-tool.tsx | 34 +- packages/nodes/src/ridge-vent/tool.tsx | 31 +- packages/nodes/src/shared/move-roof-tool.tsx | 9 + .../roof-attachment-fallback-preview.tsx | 111 +++++ .../src/shared/roof-wall-opening-placement.ts | 5 +- packages/nodes/src/shelf/definition.ts | 1 + packages/nodes/src/skylight/move-tool.tsx | 30 +- packages/nodes/src/skylight/tool.tsx | 27 +- packages/nodes/src/slab/definition.ts | 1 + packages/nodes/src/slab/move-tool.tsx | 10 + packages/nodes/src/solar-panel/move-tool.tsx | 34 +- packages/nodes/src/solar-panel/tool.tsx | 27 +- packages/nodes/src/spawn/definition.ts | 1 + packages/nodes/src/turbine-vent/move-tool.tsx | 35 +- packages/nodes/src/turbine-vent/tool.tsx | 27 +- packages/nodes/src/wall/tool.tsx | 26 +- packages/nodes/src/window/definition.ts | 1 + packages/nodes/src/window/move-tool.tsx | 50 +- packages/nodes/src/window/tool.tsx | 74 ++- wiki/architecture/README.md | 2 +- wiki/architecture/node-definitions.md | 22 + wiki/architecture/selection-managers.md | 15 + wiki/architecture/tools.md | 21 + 89 files changed, 2830 insertions(+), 518 deletions(-) create mode 100644 packages/editor/src/components/editor/floorplan-background-selection.test.ts create mode 100644 packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx create mode 100644 packages/editor/src/components/ui/snap-target-badge.test.tsx create mode 100644 packages/editor/src/components/ui/snap-target-badge.tsx create mode 100644 packages/editor/src/lib/contextual-help.test.ts create mode 100644 packages/editor/src/lib/contextual-help.ts create mode 100644 packages/editor/src/lib/direct-manipulation.test.ts create mode 100644 packages/editor/src/lib/direct-manipulation.ts create mode 100644 packages/editor/src/lib/floorplan/apply-alignment.test.ts create mode 100644 packages/editor/src/lib/placement-drag-release.ts create mode 100644 packages/editor/src/lib/selection-routing.test.ts create mode 100644 packages/editor/src/lib/selection-routing.ts create mode 100644 packages/editor/src/store/use-direct-manipulation-feedback.ts create mode 100644 packages/nodes/src/shared/roof-attachment-fallback-preview.tsx 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 4c9ae1a7..120f642c 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 @@ -3,12 +3,14 @@ import { type AnyNode, type AnyNodeId, + createSceneApi, type FloorplanAffordancePoint, type FloorplanAffordanceSession, type FloorplanGeometry, type FloorplanPalette, type FloorplanPoint, type GeometryContext, + isRegistryMovable, kindsWithFloorplanScope, nodeRegistry, pauseSceneHistory, @@ -29,8 +31,15 @@ import { useRef, useState, } from 'react' +import { + canDirectRotateNode, + resolveDirectRotationDragDelta, + resolveDirectRotationPatch, +} from '../../../lib/direct-manipulation' +import { createEditorApi } from '../../../lib/editor-api' import { sfxEmitter } from '../../../lib/sfx-bus' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' +import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' import useEditor from '../../../store/use-editor' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { useFloorplanRender } from '../floorplan-render-context' @@ -71,6 +80,9 @@ const ENDPOINT_HIT_STROKE_WIDTH_PX = 18 const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16 const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7 const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)' +const DIRECT_DRAG_THRESHOLD_PX = 4 +const DIRECT_ROTATE_EPSILON = 1e-6 +const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180 /** * Snapshot of node fields captured at drag-start, used by the single-undo @@ -131,6 +143,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const selectedIds = useViewer((s) => s.selection.selectedIds) const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) + const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId) const setHoveredId = useViewer((s) => s.setHoveredId) const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) @@ -227,11 +240,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const [activeDragId, setActiveDragId] = useState(null) const [rotationOverlay, setRotationOverlay] = useState(null) - const handleSelect = useCallback( - (id: AnyNodeId, event: React.PointerEvent) => { - if (event.button !== 0) return - event.stopPropagation() - setSelection({ selectedIds: [id] }) + const applyEntrySelection = useCallback( + (id: AnyNodeId, shouldToggle: boolean) => { + const currentSelectedIds = useViewer.getState().selection.selectedIds + setSelection({ + selectedIds: shouldToggle + ? currentSelectedIds.includes(id) + ? currentSelectedIds.filter((selectedId) => selectedId !== id) + : [...currentSelectedIds, id] + : [id], + }) // Setting selection re-renders the entry — the overlay pass mounts // (endpoint handles, etc.), reshuffling DOM under the cursor between // pointerdown and click. If the click target ends up on the SVG @@ -240,21 +258,200 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // selection we just set. Swallow the next click globally to break // that race; the listener removes itself after firing (or after a // safety timeout if no click follows). - const swallowClick = (ev: Event) => { - ev.stopPropagation() - ev.preventDefault() - window.removeEventListener('click', swallowClick, true) - } - window.addEventListener('click', swallowClick, true) - setTimeout(() => window.removeEventListener('click', swallowClick, true), 200) + swallowNextClick(200) }, [setSelection], ) + const handleSelect = useCallback( + (id: AnyNodeId, event: React.PointerEvent) => { + if (event.button !== 0) return + event.stopPropagation() + applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey) + }, + [applyEntrySelection], + ) + const handleClickStop = useCallback((event: React.MouseEvent) => { event.stopPropagation() }, []) + const startDirectMoveDrag = useCallback( + (id: AnyNodeId, event: ReactPointerEvent): boolean => { + if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false + + const node = useScene.getState().nodes[id] + if (!node || !isRegistryMovable(node.type)) return false + if (!useViewer.getState().selection.selectedIds.includes(id)) return false + + event.preventDefault() + event.stopPropagation() + + const startX = event.clientX + const startY = event.clientY + const pointerId = event.pointerId + let engaged = false + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onEnd) + window.removeEventListener('pointercancel', onEnd) + if (engaged) { + useViewer.getState().setInputDragging(false) + } + } + + const onMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return + if (engaged) return + const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) + if (distance < DIRECT_DRAG_THRESHOLD_PX) return + + engaged = true + useViewer.getState().setInputDragging(true) + swallowNextClick(300) + createEditorApi().engageMoveDrag(node) + + requestAnimationFrame(() => { + window.dispatchEvent( + new PointerEvent('pointermove', { + altKey: moveEvent.altKey, + bubbles: true, + buttons: moveEvent.buttons, + clientX: moveEvent.clientX, + clientY: moveEvent.clientY, + ctrlKey: moveEvent.ctrlKey, + metaKey: moveEvent.metaKey, + pointerId, + pointerType: moveEvent.pointerType, + shiftKey: moveEvent.shiftKey, + }), + ) + }) + } + + const onEnd = (endEvent: PointerEvent) => { + if (endEvent.pointerId !== pointerId) return + cleanup() + if (!engaged) { + applyEntrySelection(id, true) + } + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onEnd) + window.addEventListener('pointercancel', onEnd) + return true + }, + [applyEntrySelection], + ) + + const startDirectRotateDrag = useCallback( + (id: AnyNodeId, event: ReactPointerEvent): boolean => { + if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false + + const node = useScene.getState().nodes[id] + if (!node || !canDirectRotateNode(node)) return false + const selectedIds = useViewer.getState().selection.selectedIds + if (!selectedIds.includes(id)) return false + event.preventDefault() + event.stopPropagation() + + const nodeId = node.id as AnyNodeId + const pointerId = event.pointerId + const startX = event.clientX + const sceneApi = createSceneApi(useScene) + let lastPatch: Partial | null = null + + const applyDelta = (pointerEvent: PointerEvent | ReactPointerEvent) => { + const delta = resolveDirectRotationDragDelta( + startX, + pointerEvent.clientX, + DIRECT_ROTATE_RADIANS_PER_PIXEL, + pointerEvent.shiftKey, + ) + if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) { + lastPatch = null + useLiveNodeOverrides.getState().clear(nodeId) + useScene.getState().markDirty(nodeId) + return + } + const patch = resolveDirectRotationPatch(node, delta, sceneApi) + if (!patch) return + lastPatch = patch + useLiveNodeOverrides.getState().set(nodeId, patch as Record) + useScene.getState().markDirty(nodeId) + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + window.removeEventListener('pointercancel', onCancel, true) + window.removeEventListener('contextmenu', preventContextMenu, true) + useLiveNodeOverrides.getState().clear(nodeId) + useScene.getState().markDirty(nodeId) + resumeSceneHistory(useScene) + useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId) + useViewer.getState().setInputDragging(false) + if (document.body.style.cursor === 'ew-resize') { + document.body.style.cursor = '' + } + } + + const onMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return + moveEvent.preventDefault() + moveEvent.stopPropagation() + applyDelta(moveEvent) + } + + const onUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId !== pointerId) return + upEvent.preventDefault() + upEvent.stopPropagation() + swallowNextClick(300) + if (lastPatch) { + sceneApi.update(nodeId, lastPatch) + sfxEmitter.emit('sfx:item-place') + } + cleanup() + } + + const onCancel = (cancelEvent: PointerEvent) => { + if (cancelEvent.pointerId !== pointerId) return + cleanup() + } + + const preventContextMenu = (contextEvent: Event) => { + contextEvent.preventDefault() + contextEvent.stopPropagation() + } + + pauseSceneHistory(useScene) + useViewer.getState().setInputDragging(true) + useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId) + document.body.style.cursor = 'ew-resize' + sfxEmitter.emit('sfx:item-pick') + applyDelta(event) + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + window.addEventListener('pointercancel', onCancel, true) + window.addEventListener('contextmenu', preventContextMenu, true) + return true + }, + [], + ) + + const handleEntryPointerDown = useCallback( + (id: AnyNodeId, event: ReactPointerEvent) => { + if (startDirectMoveDrag(id, event)) return + if (startDirectRotateDrag(id, event)) return + handleSelect(id, event) + }, + [handleSelect, startDirectMoveDrag, startDirectRotateDrag], + ) + // Build the geometry list. `viewState` flows into ctx so kinds can // theme their output and conditionally emit selection chrome. // @@ -729,7 +926,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { onPointerDown={ isOpeningPlacementActive || isMarqueeSelectionActive ? undefined - : (e) => handleSelect(id, e) + : (e) => handleEntryPointerDown(id, e) } // Mirror the sidebar tree nodes' hover wiring — `useViewer. // hoveredId` drives the highlight halo in 3D as well as the @@ -749,6 +946,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { > { + event.stopPropagation() + event.preventDefault() + window.removeEventListener('click', swallowClick, true) + } + window.addEventListener('click', swallowClick, true) + setTimeout(() => { + window.removeEventListener('click', swallowClick, true) + }, timeoutMs) +} diff --git a/packages/editor/src/components/editor/floorplan-background-selection.test.ts b/packages/editor/src/components/editor/floorplan-background-selection.test.ts new file mode 100644 index 00000000..d2be41a8 --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-background-selection.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' +import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection' + +const baseArgs = { + canSelectElementFloorplanGeometry: true, + canSelectFloorplanZones: false, + currentSelectedIds: ['wall_1'], + getFloorplanHitIdAtPoint: () => 'door_1', + isWallBuildActive: false, + modifierKeys: { meta: false, ctrl: false, shift: false }, + planPoint: [0, 0] as [number, number], + structureLayer: 'elements', + toPoint2D: ([x, y]: [number, number]) => ({ x, y }), + visibleZonePolygons: [], +} + +describe('resolveFloorplanBackgroundSelection', () => { + test('shift-click on a floorplan node toggles into the current selection', () => { + const result = resolveFloorplanBackgroundSelection({ + ...baseArgs, + modifierKeys: { meta: false, ctrl: false, shift: true }, + }) + + expect(result).toEqual({ + handled: true, + kind: 'select-elements', + selectedIds: ['wall_1', 'door_1'], + }) + }) + + test('shift-click on selected floorplan node toggles it out', () => { + const result = resolveFloorplanBackgroundSelection({ + ...baseArgs, + currentSelectedIds: ['wall_1', 'door_1'], + modifierKeys: { meta: false, ctrl: false, shift: true }, + }) + + expect(result).toEqual({ + handled: true, + kind: 'select-elements', + selectedIds: ['wall_1'], + }) + }) + + test('shift-click on empty floorplan space preserves selection', () => { + const result = resolveFloorplanBackgroundSelection({ + ...baseArgs, + getFloorplanHitIdAtPoint: () => null, + modifierKeys: { meta: false, ctrl: false, shift: true }, + }) + + expect(result).toEqual({ + handled: true, + kind: 'clear-elements', + preserveSelection: true, + }) + }) +}) diff --git a/packages/editor/src/components/editor/floorplan-background-selection.ts b/packages/editor/src/components/editor/floorplan-background-selection.ts index ce190f80..0e4a53dc 100644 --- a/packages/editor/src/components/editor/floorplan-background-selection.ts +++ b/packages/editor/src/components/editor/floorplan-background-selection.ts @@ -7,6 +7,7 @@ import type { WallPlanPoint } from '../tools/wall/wall-drafting' type ModifierKeys = { meta: boolean ctrl: boolean + shift: boolean } type ZoneHitEntry = { @@ -85,7 +86,7 @@ export function resolveFloorplanBackgroundSelection({ handled: true, kind: 'select-elements', selectedIds: - modifierKeys.meta || modifierKeys.ctrl + modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift ? currentSelectedIds.includes(hitId) ? currentSelectedIds.filter((selectedId) => selectedId !== hitId) : [...currentSelectedIds, hitId] @@ -105,7 +106,7 @@ export function resolveFloorplanBackgroundSelection({ return { handled: true, kind: 'clear-elements', - preserveSelection: modifierKeys.meta || modifierKeys.ctrl, + preserveSelection: modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift, } } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index f9bf5cb0..6a423670 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -868,10 +868,15 @@ function getElevatorResizeSign(handle: ElevatorResizeHandle) { return handle.endsWith('positive') ? 1 : -1 } -function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) { +function getSelectionModifierKeys(event?: { + metaKey?: boolean + ctrlKey?: boolean + shiftKey?: boolean +}) { return { meta: Boolean(event?.metaKey), ctrl: Boolean(event?.ctrlKey), + shift: Boolean(event?.shiftKey), } } @@ -8660,10 +8665,11 @@ export function FloorplanPanel({ // that snap wins, so skip Figma alignment and stand the beacon there. const lockedToWall = wallSnap.snap !== null let snappedPoint = wallSnapped - if (lockedToWall || wallAngleSnap) { + if (lockedToWall) { useAlignmentGuides.getState().clear() } else { snappedPoint = alignFloorplanDraftPoint(wallSnapped, { + applySnap: !wallAngleSnap, bypass: event.altKey || bypassSnap, }) } @@ -9233,8 +9239,11 @@ export function FloorplanPanel({ ) const addFloorplanSelection = useCallback( - (nextSelectedIds: string[], modifierKeys?: { meta: boolean; ctrl: boolean }) => { - const shouldAppend = Boolean(modifierKeys?.meta || modifierKeys?.ctrl) + ( + nextSelectedIds: string[], + modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean }, + ) => { + const shouldAppend = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift) if (shouldAppend) { if (nextSelectedIds.length === 0) { @@ -9252,8 +9261,8 @@ export function FloorplanPanel({ ) const toggleFloorplanSelection = useCallback( - (nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean }) => { - const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl) + (nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean }) => { + const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift) if (shouldToggle) { const currentSelectedIds = useViewer.getState().selection.selectedIds @@ -9296,7 +9305,7 @@ export function FloorplanPanel({ const commitFloorplanScreenSelection = useCallback( (nextSelectedIds: string[], event: PointerEvent) => { const modifierKeys = getSelectionModifierKeys(event) - const shouldAppend = modifierKeys.meta || modifierKeys.ctrl + const shouldAppend = modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift setSelectedReferenceId(null) @@ -9934,7 +9943,7 @@ export function FloorplanPanel({ if (hitId) { toggleFloorplanSelection(hitId, modifierKeys) - } else if (!(modifierKeys.meta || modifierKeys.ctrl)) { + } else if (!(modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift)) { commitFloorplanSelection([]) } } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 0ef913c5..07fd9fe1 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -45,6 +45,7 @@ import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' +import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useEditor from '../../store/use-editor' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { formatAngleRadians } from '../tools/shared/segment-angle' @@ -167,6 +168,7 @@ function DimensionLabel({ export function NodeArrowHandles() { const selectedIds = useViewer((state) => state.selection.selectedIds) + const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId) const mode = useEditor((state) => state.mode) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const movingNode = useEditor((state) => state.movingNode) @@ -180,7 +182,7 @@ export function NodeArrowHandles() { const curvingWall = useEditor((state) => state.curvingWall) const curvingFence = useEditor((state) => state.curvingFence) - const selectedId = selectedIds.length === 1 ? selectedIds[0] : null + const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId const rawNode = useScene((state) => selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null, ) @@ -1028,6 +1030,8 @@ function ArcArrow({ // corner) render a two-headed curved arrow; everything else (stair // sweep, etc.) keeps the chevron. const isRotateShape = descriptor.shape === 'rotate' + const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId) + const isDirectRotating = isRotateShape && activeRotateNodeId === liveNode.id // 'node-normal' spins the node about its local +Z (a wall item flat against // its wall) instead of yaw about world-Y. The drag plane and the icon both // tilt into that plane, and the horizontal-only wedge/ring readout is @@ -1067,7 +1071,7 @@ function ArcArrow({ // arrow is hovered or dragging. Same recipe as the linear / radial // decoration path. const decoration = descriptor.decoration - const showDecoration = Boolean(decoration) && (isHovered || isDragging) + const showDecoration = Boolean(decoration) && (isHovered || isDragging || isDirectRotating) const activate = useHandleDrag({ kind: 'drag', @@ -1142,13 +1146,6 @@ function ArcArrow({ }, }) - // Suppress "declared but unused" for `liveNode` — ArcArrow's apply - // operates entirely on `initialNode` (snapshot taken inside activate) - // and `delta` (live cursor angle), so the live store node doesn't - // appear in the rotation pipeline. The prop is still required because - // ArrowHandle passes it uniformly to every variant. - void liveNode - return ( <> {showDecoration && decoration ? ( @@ -1172,7 +1169,7 @@ function ArcArrow({ { // Elevators are building-scoped, so they stay selectable across level filters. @@ -86,11 +100,6 @@ type SelectableNodeType = | 'window' | 'door' -type ModifierKeys = { - meta: boolean - ctrl: boolean -} - type PaintPreviewCleanup = () => void type PaintInteraction = { @@ -103,14 +112,33 @@ type PaintInteraction = { interface SelectionStrategy { types: SelectableNodeType[] - handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void + handleSelect: ( + node: AnyNode, + nativeEvent?: any, + modifierKeys?: SelectionModifierKeys, + baseSelectedIds?: readonly string[], + ) => void handleDeselect: () => void isValid: (node: AnyNode) => boolean } -type SelectionTarget = { - phase: Phase - structureLayer?: StructureLayer +const DIRECT_DRAG_THRESHOLD_PX = 4 +const DIRECT_ROTATE_EPSILON = 1e-6 +const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180 + +function pointerEventFromNodeEvent(event: NodeEvent): PointerEvent { + const threeEvent = event.nativeEvent as unknown as PointerEvent & { + nativeEvent?: PointerEvent + } + return threeEvent.nativeEvent ?? threeEvent +} + +function isCommandModifier(event: Pick): boolean { + return event.metaKey || event.ctrlKey +} + +function pointerDistancePx(event: PointerEvent, startX: number, startY: number): number { + return Math.hypot(event.clientX - startX, event.clientY - startY) } export const resolveBuildingId = ( @@ -619,22 +647,17 @@ function disposeHighlightedMaterials(material: Material | Material[]) { const computeNextIds = ( node: AnyNode, - selectedIds: string[], + selectedIds: readonly string[], event?: any, - modifierKeys?: ModifierKeys, + modifierKeys?: SelectionModifierKeys, + baseSelectedIds?: readonly string[], ): string[] => { - const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta - const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl - - if (isMeta || isCtrl) { - if (selectedIds.includes(node.id)) { - return selectedIds.filter((id) => id !== node.id) - } - return [...selectedIds, node.id] - } - - // Not holding modifiers: select only this node - return [node.id] + return resolveSelectedIdsForNodeClick({ + baseSelectedIds, + currentSelectedIds: selectedIds, + modifierKeys: selectionModifiersFromEvent(event, modifierKeys), + nodeId: node.id, + }) } const SELECTION_STRATEGIES: Record = { @@ -667,7 +690,7 @@ const SELECTION_STRATEGIES: Record = { 'window', 'door', ], - handleSelect: (node, nativeEvent, modifierKeys) => { + handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => { const { selection, setSelection } = useViewer.getState() const nodes = useScene.getState().nodes const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes) @@ -694,7 +717,13 @@ const SELECTION_STRATEGIES: Record = { // Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId. setSelection(updates) } else { - updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys) + updates.selectedIds = computeNextIds( + node, + selection.selectedIds, + nativeEvent, + modifierKeys, + baseSelectedIds, + ) setSelection(updates) } }, @@ -746,7 +775,7 @@ const SELECTION_STRATEGIES: Record = { furnish: { types: ['item'], - handleSelect: (node, nativeEvent, modifierKeys) => { + handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => { const { selection, setSelection } = useViewer.getState() const nodes = useScene.getState().nodes const nodeLevelId = resolveLevelId(node, nodes) @@ -760,7 +789,13 @@ const SELECTION_STRATEGIES: Record = { updates.buildingId = buildingId } - updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys) + updates.selectedIds = computeNextIds( + node, + selection.selectedIds, + nativeEvent, + modifierKeys, + baseSelectedIds, + ) setSelection(updates) }, handleDeselect: () => { @@ -776,7 +811,7 @@ const SELECTION_STRATEGIES: Record = { // Registry-driven kinds with `category: 'furnish'` (shelf today, // future furniture kinds): selectable in furnish phase if their // definition declares the `selectable` capability. Without this - // branch, shelf clicks routed to furnish phase via getSelectionTarget + // branch, shelf clicks routed to furnish phase via resolveNodeSelectionTarget // would be rejected here — single-click selection broken. const def = nodeRegistry.get(node.type) if (def && def.category === 'furnish' && def.capabilities.selectable) return true @@ -785,76 +820,14 @@ const SELECTION_STRATEGIES: Record = { }, } -const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { - // Item is checked FIRST so its asset.category-driven routing (door/ - // window items land in structure phase, everything else in furnish) - // beats the generic registry fallback below. Without this, registering - // `item` (Phase 5) made isRegistrySelectable('item') match the - // structure branch first, breaking single-click selection: first click - // switched the editor to structure phase, second click selected. - if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.category === 'door' || item.asset.category === 'window') { - return { - phase: 'structure', - structureLayer: 'elements', - } - } - return { - phase: 'furnish', - } - } - - if (node.type === 'zone') { - return { - phase: 'structure', - structureLayer: 'zones', - } - } - - if ( - node.type === 'wall' || - node.type === 'fence' || - node.type === 'column' || - node.type === 'elevator' || - node.type === 'slab' || - node.type === 'ceiling' || - node.type === 'roof' || - node.type === 'roof-segment' || - node.type === 'stair' || - node.type === 'stair-segment' || - node.type === 'spawn' || - node.type === 'window' || - node.type === 'door' - ) { - return { - phase: 'structure', - structureLayer: 'elements', - } - } - - // Registry-driven kinds (Phase 5+): route by `def.category`. Built-ins - // above match before this fallback. Furnish-category kinds (shelf, - // item — already handled above) land on the furnish phase; structure- - // category kinds (everything else) on structure/elements. - const def = nodeRegistry.get(node.type) - if (def) { - if (def.category === 'furnish') { - return { phase: 'furnish' } - } - return { phase: 'structure', structureLayer: 'elements' } - } - - return null -} - export const SelectionManager = () => { const phase = useEditor((s) => s.phase) const mode = useEditor((s) => s.mode) const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode) - const modifierKeysRef = useRef({ + const modifierKeysRef = useRef({ meta: false, ctrl: false, + shift: false, }) const clickHandledRef = useRef(false) @@ -1230,16 +1203,19 @@ export const SelectionManager = () => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Meta') modifierKeysRef.current.meta = true if (event.key === 'Control') modifierKeysRef.current.ctrl = true + if (event.key === 'Shift') modifierKeysRef.current.shift = true } const onKeyUp = (event: KeyboardEvent) => { if (event.key === 'Meta') modifierKeysRef.current.meta = false if (event.key === 'Control') modifierKeysRef.current.ctrl = false + if (event.key === 'Shift') modifierKeysRef.current.shift = false } const clearModifiers = () => { modifierKeysRef.current.meta = false modifierKeysRef.current.ctrl = false + modifierKeysRef.current.shift = false } window.addEventListener('keydown', onKeyDown) @@ -1253,6 +1229,222 @@ export const SelectionManager = () => { } }, []) + useEffect(() => { + if (mode !== 'select') return + if (movingNode || curvingWall || curvingFence) return + + const onPointerDown = (event: NodeEvent) => { + const pointer = pointerEventFromNodeEvent(event) + if (pointer.button !== 0 || !isCommandModifier(pointer)) return + + const node = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node + if (!canDirectMoveNode(node)) return + if (!useViewer.getState().selection.selectedIds.includes(node.id)) return + + const startX = pointer.clientX + const startY = pointer.clientY + const pointerId = pointer.pointerId + const pointerTarget = pointer.target instanceof EventTarget ? pointer.target : null + let engaged = false + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onEnd) + window.removeEventListener('pointercancel', onEnd) + if (engaged) { + useViewer.getState().setInputDragging(false) + } + } + + const onMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return + if (engaged) return + if (pointerDistancePx(moveEvent, startX, startY) < DIRECT_DRAG_THRESHOLD_PX) return + + engaged = true + event.stopPropagation() + suppressBoxSelectForPointer(event.nativeEvent) + useViewer.getState().setInputDragging(true) + swallowNextClick() + createEditorApi().engageMoveDrag(node) + requestAnimationFrame(() => { + if (useEditor.getState().movingNode?.id !== node.id) return + pointerTarget?.dispatchEvent( + new PointerEvent('pointermove', { + altKey: moveEvent.altKey, + bubbles: true, + buttons: moveEvent.buttons, + clientX: moveEvent.clientX, + clientY: moveEvent.clientY, + ctrlKey: moveEvent.ctrlKey, + metaKey: moveEvent.metaKey, + pointerId, + pointerType: moveEvent.pointerType, + shiftKey: moveEvent.shiftKey, + }), + ) + }) + } + + const onEnd = (endEvent: PointerEvent) => { + if (endEvent.pointerId !== pointerId) return + cleanup() + if (engaged) { + requestAnimationFrame(() => { + const editor = useEditor.getState() + if (editor.movingNode?.id !== node.id || !editor.placementDragMode) return + editor.setMovingNode(null) + }) + } + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onEnd) + window.addEventListener('pointercancel', onEnd) + } + + const allTypes = [ + 'wall', + 'fence', + 'item', + 'column', + 'slab', + 'ceiling', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', + 'window', + 'door', + 'zone', + 'shelf', + 'spawn', + 'elevator', + 'building', + ] as const + const registryKinds = getSelectableKinds().filter( + (kind) => !(allTypes as readonly string[]).includes(kind), + ) + const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds] + + for (const type of subscribedKinds) { + emitter.on(`${type}:pointerdown` as any, onPointerDown as any) + } + + return () => { + for (const type of subscribedKinds) { + emitter.off(`${type}:pointerdown` as any, onPointerDown as any) + } + } + }, [curvingFence, curvingWall, mode, movingNode]) + + useEffect(() => { + if (mode !== 'select') return + if (movingNode || curvingWall || curvingFence) return + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 2 || !isCommandModifier(event)) return + if (!(event.target instanceof HTMLCanvasElement)) return + + const selectedIds = useViewer.getState().selection.selectedIds + const hoveredId = useViewer.getState().hoveredId as AnyNodeId | null + if (!hoveredId || !selectedIds.includes(hoveredId)) return + + const node = useScene.getState().nodes[hoveredId] + if (!node || !canDirectRotateNode(node)) return + + event.preventDefault() + event.stopPropagation() + + const nodeId = node.id as AnyNodeId + const pointerId = event.pointerId + const startX = event.clientX + const sceneApi = createSceneApi(useScene) + let lastPatch: Partial | null = null + + const applyDelta = (moveEvent: PointerEvent) => { + const delta = resolveDirectRotationDragDelta( + startX, + moveEvent.clientX, + DIRECT_ROTATE_RADIANS_PER_PIXEL, + moveEvent.shiftKey, + ) + if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) { + lastPatch = null + useLiveNodeOverrides.getState().clear(nodeId) + useScene.getState().markDirty(nodeId) + return + } + const patch = resolveDirectRotationPatch(node, delta, sceneApi) + if (!patch) return + lastPatch = patch + useLiveNodeOverrides.getState().set(nodeId, patch as Record) + useScene.getState().markDirty(nodeId) + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + window.removeEventListener('pointercancel', onCancel, true) + window.removeEventListener('contextmenu', preventContextMenu, true) + useLiveNodeOverrides.getState().clear(nodeId) + useScene.getState().markDirty(nodeId) + useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId) + useScene.temporal.getState().resume() + useViewer.getState().setInputDragging(false) + if (document.body.style.cursor === 'ew-resize') { + document.body.style.cursor = '' + } + } + + const onMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return + moveEvent.preventDefault() + moveEvent.stopPropagation() + applyDelta(moveEvent) + } + + const onUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId !== pointerId) return + upEvent.preventDefault() + upEvent.stopPropagation() + swallowNextClick() + if (lastPatch) { + sceneApi.update(nodeId, lastPatch) + sfxEmitter.emit('sfx:item-place') + } + cleanup() + } + + const onCancel = (cancelEvent: PointerEvent) => { + if (cancelEvent.pointerId !== pointerId) return + cleanup() + } + + const preventContextMenu = (contextEvent: Event) => { + contextEvent.preventDefault() + contextEvent.stopPropagation() + } + + useViewer.getState().setInputDragging(true) + useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId) + useScene.temporal.getState().pause() + document.body.style.cursor = 'ew-resize' + sfxEmitter.emit('sfx:item-pick') + applyDelta(event) + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + window.addEventListener('pointercancel', onCancel, true) + window.addEventListener('contextmenu', preventContextMenu, true) + } + + window.addEventListener('pointerdown', onPointerDown, true) + return () => { + window.removeEventListener('pointerdown', onPointerDown, true) + } + }, [curvingFence, curvingWall, mode, movingNode]) + useEffect(() => { if (mode !== 'select') return if (movingNode || curvingWall || curvingFence) return @@ -1274,12 +1466,13 @@ export const SelectionManager = () => { let currentPhase = useEditor.getState().phase let currentStructureLayer = useEditor.getState().structureLayer + const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds // Auto-switch between zones, structure, and furnish when clicking elements on the same level. // Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan). if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') { if (isNodeInCurrentLevel(node)) { - const target = getSelectionTarget(node) + const target = resolveNodeSelectionTarget(node) if (target) { if (target.phase !== currentPhase) { useEditor.getState().setPhase(target.phase) @@ -1330,7 +1523,12 @@ export const SelectionManager = () => { useEditor.getState().setEditingHole(null) } - activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current) + activeStrategy.handleSelect( + nodeToSelect, + event.nativeEvent, + modifierKeysRef.current, + selectedIdsBeforeRouting, + ) let nextMaterialTargetHandled = false @@ -1435,9 +1633,11 @@ export const SelectionManager = () => { emitter.on(`${type}:click` as any, onClick as any) }) - const onGridClick = () => { + const onGridClick = (event: GridEvent) => { if (clickHandledRef.current) return if (boxSelectHandled) return + const nativeEvent = event.nativeEvent + if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return const { phase, structureLayer } = useEditor.getState() const activeStrategy = SELECTION_STRATEGIES[phase] if (activeStrategy) activeStrategy.handleDeselect() @@ -1506,7 +1706,10 @@ export const SelectionManager = () => { const currentPhase = useEditor.getState().phase - let targetPhase: 'site' | 'structure' | 'furnish' | null = null + const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds + const target = resolveNodeSelectionTarget(node) + let targetPhase: 'site' | 'structure' | 'furnish' | null = target?.phase ?? null + let targetStructureLayer = target?.structureLayer let forceSelect = false if (node.type === 'building' || node.type === 'site') { @@ -1515,36 +1718,15 @@ export const SelectionManager = () => { } if (node.type === 'building') { targetPhase = 'structure' + targetStructureLayer = 'elements' } - } else if ( - node.type === 'wall' || - node.type === 'fence' || - node.type === 'column' || - node.type === 'elevator' || - node.type === 'slab' || - node.type === 'ceiling' || - node.type === 'roof' || - node.type === 'roof-segment' || - node.type === 'stair' || - node.type === 'stair-segment' || - node.type === 'spawn' || - node.type === 'window' || - node.type === 'door' - ) { - targetPhase = 'structure' + } else { if (node.type === 'roof-segment' && currentPhase === 'structure') { forceSelect = true // allow double click to dive into roof-segment even if already in structure phase } if (node.type === 'stair-segment' && currentPhase === 'structure') { forceSelect = true // allow double click to dive into stair-segment even if already in structure phase } - } else if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.category === 'door' || item.asset.category === 'window') { - targetPhase = 'structure' - } else { - targetPhase = 'furnish' - } } if (node.type === 'zone') { @@ -1558,13 +1740,22 @@ export const SelectionManager = () => { useEditor.getState().setPhase(targetPhase) } - if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') { - useEditor.getState().setStructureLayer('elements') + if ( + targetPhase === 'structure' && + targetStructureLayer && + targetStructureLayer !== useEditor.getState().structureLayer + ) { + useEditor.getState().setStructureLayer(targetStructureLayer) } const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase] if (strategy) { - strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current) + strategy.handleSelect( + node, + event.nativeEvent, + modifierKeysRef.current, + selectedIdsBeforeRouting, + ) } } } diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 387558c1..f885ff4e 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -5,6 +5,7 @@ import { type MouseEvent as ReactMouseEvent, useCallback } from 'react' import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' +import useAlignmentGuides from '../../store/use-alignment-guides' import useSegmentDraftChain from '../../store/use-segment-draft-chain' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting' @@ -329,10 +330,15 @@ export function useFloorplanBackgroundPlacement({ const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep) const wallLocked = !bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]) - const snappedPoint = - wallLocked || wallAngleSnap - ? wallSnapped - : alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey || bypassSnap }) + let snappedPoint = wallSnapped + if (wallLocked) { + useAlignmentGuides.getState().clear() + } else { + snappedPoint = alignFloorplanDraftPoint(wallSnapped, { + applySnap: !wallAngleSnap, + bypass: event.altKey || bypassSnap, + }) + } emitFloorplanGridEvent('click', snappedPoint, event) diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx index 10a2b201..47226001 100644 --- a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -15,6 +15,7 @@ import { import { useCallback, useEffect, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { resolveElevatorSupportY } from '../../../lib/elevator-support' +import { consumePlacementDragRelease } from '../../../lib/placement-drag-release' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' @@ -160,6 +161,7 @@ export function MoveElevatorTool({ } const onGridClick = (event: GridEvent) => { + if (wasCommitted) return const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current] wasCommitted = true @@ -189,6 +191,11 @@ export function MoveElevatorTool({ event.nativeEvent?.stopPropagation?.() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + onGridClick({ nativeEvent: event } as unknown as GridEvent) + } + const onCancel = () => { wasCancelled = true clearPreview() @@ -231,6 +238,7 @@ export function MoveElevatorTool({ emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { clearPreview() @@ -247,6 +255,7 @@ export function MoveElevatorTool({ emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('pointerup', onPlacementDragPointerUp) } }, [movingNode, exitMoveMode]) diff --git a/packages/editor/src/components/tools/item/placement-math.test.ts b/packages/editor/src/components/tools/item/placement-math.test.ts index 10bbb8f0..c1984e65 100644 --- a/packages/editor/src/components/tools/item/placement-math.test.ts +++ b/packages/editor/src/components/tools/item/placement-math.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { stripTransient } from './placement-math' +import { getDetachedAttachmentPreviewLift, stripTransient } from './placement-math' describe('stripTransient', () => { test('removes placement-only metadata flags before commit', () => { @@ -8,3 +8,15 @@ describe('stripTransient', () => { }) }) }) + +describe('getDetachedAttachmentPreviewLift', () => { + test('raises attach-only item previews while they are detached from their host', () => { + expect(getDetachedAttachmentPreviewLift('wall')).toBeGreaterThan(0) + expect(getDetachedAttachmentPreviewLift('wall-side')).toBeGreaterThan(0) + expect(getDetachedAttachmentPreviewLift('ceiling')).toBeGreaterThan(0) + }) + + test('keeps floor item previews on the floor', () => { + expect(getDetachedAttachmentPreviewLift(undefined)).toBe(0) + }) +}) diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index a09c7b27..63c829ff 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -55,6 +55,12 @@ export function getGridAlignedDimensions( return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)] } +export function getDetachedAttachmentPreviewLift( + attachTo: AssetInput['attachTo'] | null | undefined, +): number { + return attachTo ? 0.45 : 0 +} + /** * Calculate cursor rotation in WORLD space from wall normal and orientation. */ diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 74bbc457..dcb00ed9 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -144,6 +144,7 @@ export const floorStrategy = { ): CommitResult | null { if (ctx.state.surface !== 'floor') return null if (!(ctx.levelId && ctx.draftItem)) return null + if (ctx.draftItem.asset.attachTo) return null const pos: [number, number, number] = [ ctx.gridPosition.x, diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 179f7b5e..e0dac1b9 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -52,7 +52,12 @@ import { type PreviewBounds, updateLineGeometry, } from '../shared/placement-box-geometry' -import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math' +import { + getDetachedAttachmentPreviewLift, + getGridAlignedDimensions, + snapToGrid, + snapUpToGridStep, +} from './placement-math' import { ceilingStrategy, checkCanPlace, @@ -732,6 +737,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea previousGridPos = [...gridPos] gridPosition.current.set(...gridPos) const cursorPosition = getFloorVisualPosition(gridPos) + if (!draft && asset.attachTo) { + cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo) + } cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2]) // Floor items only rotate on Y; keep the preview box (and the live // transform the 2D floorplan mirrors) aligned with the draft's diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 40078ee8..797d29b7 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -27,6 +27,7 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' +import { swallowNextClick } from '../../editor/node-arrow-handles' import { CursorSphere } from '../shared/cursor-sphere' import { DragBoundingBox } from '../shared/drag-bounding-box' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' @@ -505,6 +506,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { emitter.on('grid:move', onGridMove) emitter.on('grid:click', commitAtCursor) + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!useEditor.getState().placementDragMode) return + if (event.button !== 0) return + swallowNextClick() + if (!hasMovedRef.current) { + exitMoveMode() + return + } + commitAtCursor({ + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as ClickTriggerEvent) + } + window.addEventListener('pointerup', onPlacementDragPointerUp) + // Listen on every common kind's click event too. mitt's typing keeps // `${kind}:click` as a fixed union so the cast is safe at runtime — // we're just routing them through the shared commit path. @@ -539,6 +555,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { window.removeEventListener('keyup', onKeyUp) emitter.off('grid:move', onGridMove) emitter.off('grid:click', commitAtCursor) + window.removeEventListener('pointerup', onPlacementDragPointerUp) for (const kind of CLICK_TRIGGER_KINDS) { const key = `${kind}:click` as ClickKey emitter.off(key, commitAtCursor as never) diff --git a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx new file mode 100644 index 00000000..5d630cec --- /dev/null +++ b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx @@ -0,0 +1,37 @@ +import type { ContextualShortcutHint } from '../../../lib/contextual-help' +import { cn } from '../../../lib/utils' +import { ShortcutToken } from '../primitives/shortcut-token' + +function ShortcutSequence({ keys }: { keys: string[] }) { + return ( +
+ {keys.map((key, index) => ( +
+ {index > 0 ? + : null} + +
+ ))} +
+ ) +} + +export function ContextualHelperPanel({ hints }: { hints: ContextualShortcutHint[] }) { + if (hints.length === 0) return null + + return ( +
+ {hints.map((hint) => ( +
+ + {hint.label} +
+ ))} +
+ ) +} diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index d25aeafe..8107d8ce 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -1,44 +1,118 @@ 'use client' -import { nodeRegistry } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + nodeRegistry, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useIsMobile } from '../../../hooks/use-mobile' +import { resolveSelectModeHelpHints } from '../../../lib/contextual-help' +import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation' import useEditor from '../../../store/use-editor' import { BuildingHelper } from './building-helper' +import { ContextualHelperPanel } from './contextual-helper-panel' import { ItemHelper } from './item-helper' import { RegisteredToolHelper } from './registered-tool-helper' import { RoofHelper } from './roof-helper' +type ActiveModifierKeys = { + command: boolean + shift: boolean +} + +function useActiveModifierKeys(): ActiveModifierKeys { + const [modifiers, setModifiers] = useState({ + command: false, + shift: false, + }) + + useEffect(() => { + const updateModifiers = (event: KeyboardEvent) => { + const isKeyDown = event.type === 'keydown' + setModifiers({ + command: + event.metaKey || + event.ctrlKey || + (isKeyDown && (event.key === 'Meta' || event.key === 'Control')), + shift: event.shiftKey || (isKeyDown && event.key === 'Shift'), + }) + } + const clearModifiers = () => { + setModifiers({ command: false, shift: false }) + } + + window.addEventListener('keydown', updateModifiers) + window.addEventListener('keyup', updateModifiers) + window.addEventListener('blur', clearModifiers) + return () => { + window.removeEventListener('keydown', updateModifiers) + window.removeEventListener('keyup', updateModifiers) + window.removeEventListener('blur', clearModifiers) + } + }, []) + + return modifiers +} + export function HelperManager() { const mode = useEditor((s) => s.mode) const tool = useEditor((s) => s.tool) const movingNode = useEditor((state) => state.movingNode) + const selectedIds = useViewer((s) => s.selection.selectedIds) const isMobile = useIsMobile() + const modifiers = useActiveModifierKeys() + const selectedNodes = useScene( + useShallow((s) => + selectedIds + .map((id) => s.nodes[id as AnyNodeId]) + .filter((node): node is AnyNode => node !== undefined), + ), + ) + const selectModeHints = useMemo( + () => + resolveSelectModeHelpHints({ + selectedCount: selectedNodes.length, + hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)), + hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)), + commandPressed: modifiers.command, + shiftPressed: modifiers.shift, + }), + [modifiers.command, modifiers.shift, selectedNodes], + ) // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. if (isMobile) return null if (movingNode) { if (movingNode.type === 'building') return - return + return } if (mode === 'material-paint') { return null } + if (mode === 'select') { + return + } + // Registry-first: kinds with `def.toolHints` render through the generic // `RegisteredToolHelper`. Today that covers ceiling / door / fence / // item / shelf / slab / spawn / wall / window. if (tool) { const def = nodeRegistry.get(tool) if (def?.toolHints && def.toolHints.length > 0) { - return + return } } // Legacy fallback — only `roof` remains because it hasn't migrated to // `def.tool` / `def.toolHints` yet (no Stage D port). When roof // migrates, this switch deletes outright. - if (tool === 'roof') return + if (tool === 'roof') return return null } diff --git a/packages/editor/src/components/ui/helpers/item-helper.tsx b/packages/editor/src/components/ui/helpers/item-helper.tsx index 7bf90098..d73b0b91 100644 --- a/packages/editor/src/components/ui/helpers/item-helper.tsx +++ b/packages/editor/src/components/ui/helpers/item-helper.tsx @@ -1,40 +1,24 @@ -import { ShortcutToken } from '../primitives/shortcut-token' +import { ContextualHelperPanel } from './contextual-helper-panel' interface ItemHelperProps { showEsc?: boolean + shiftPressed?: boolean } -export function ItemHelper({ showEsc }: ItemHelperProps) { +export function ItemHelper({ showEsc, shiftPressed = false }: ItemHelperProps) { return ( -
-
- - Place item -
-
- - Rotate counterclockwise -
-
- - Rotate clockwise -
-
- - Free place -
- {showEsc && ( -
- - Cancel -
- )} - {!showEsc && ( -
- - Cancel -
- )} -
+ ) } diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index cc48a3fb..a31946ae 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -1,5 +1,5 @@ import type { ToolHint } from '@pascal-app/core' -import { ShortcutToken } from '../primitives/shortcut-token' +import { ContextualHelperPanel } from './contextual-helper-panel' /** * Generic helper panel rendered from `def.toolHints` data. Matches the @@ -10,16 +10,22 @@ import { ShortcutToken } from '../primitives/shortcut-token' * Drops the need for per-kind helper files entirely — kinds declare * their hints as static data in their `NodeDefinition`. */ -export function RegisteredToolHelper({ hints }: { hints: ToolHint[] }) { +export function RegisteredToolHelper({ + hints, + shiftPressed = false, +}: { + hints: ToolHint[] + shiftPressed?: boolean +}) { if (hints.length === 0) return null return ( -
- {hints.map((hint) => ( -
- - {hint.label} -
- ))} -
+ ({ + keys: [hint.key], + label: + shiftPressed && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label, + active: shiftPressed && hint.key === 'Shift', + }))} + /> ) } diff --git a/packages/editor/src/components/ui/helpers/roof-helper.tsx b/packages/editor/src/components/ui/helpers/roof-helper.tsx index 6dea4a4f..07111a87 100644 --- a/packages/editor/src/components/ui/helpers/roof-helper.tsx +++ b/packages/editor/src/components/ui/helpers/roof-helper.tsx @@ -1,16 +1,17 @@ -import { ShortcutToken } from '../primitives/shortcut-token' +import { ContextualHelperPanel } from './contextual-helper-panel' -export function RoofHelper() { +export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) { return ( -
-
- - Set corner -
-
- - Cancel -
-
+ ) } diff --git a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx index 77db922c..a2d215d5 100644 --- a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx +++ b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx @@ -2,16 +2,11 @@ import type { AssetInput } from '@pascal-app/core' import { resolveCdnUrl, useViewer } from '@pascal-app/viewer' -import Image from 'next/image' import { useEffect } from 'react' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from './../../../components/ui/primitives/tooltip' import { triggerSFX } from './../../../lib/sfx-bus' import { cn } from './../../../lib/utils' import useEditor, { type CatalogCategory } from './../../../store/use-editor' +import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge' import { CATALOG_ITEMS } from './catalog-items' export function ItemCatalog({ @@ -68,12 +63,6 @@ export function ItemCatalog({ } }, [categoryItems, selectedItem?.src, setSelectedItem]) - const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => { - if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png' - if (attachTo === 'ceiling') return '/icons/ceiling.png' - return null - } - if (filteredItems.length === 0 && emptyState) { return <>{emptyState} } @@ -86,7 +75,7 @@ export function ItemCatalog({ {leadingTile} {filteredItems.map((item, index) => { const isSelected = selectedItem?.src === item?.src - const attachmentIcon = getAttachmentIcon(item?.attachTo) + const snapTarget = resolveAssetSnapTarget(item?.attachTo) return ( -
+
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx index c31bc0d9..1f16a139 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx @@ -1,9 +1,10 @@ 'use client' -import { type AnyNodeId, useScene } from '@pascal-app/core' +import { type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { memo, useCallback, useState } from 'react' +import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge' import useEditor from './../../../../../store/use-editor' import { InlineRenameInput } from './inline-rename-input' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' @@ -22,6 +23,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({ }: WindowTreeNodeProps) { const [isEditing, setIsEditing] = useState(false) const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false) + const node = useScene((s) => s.nodes[nodeId] as WindowNode | undefined) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const isHovered = useViewer((state) => state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) @@ -45,6 +47,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({ const handleStartEditing = useCallback(() => setIsEditing(true), []) const handleStopEditing = useCallback(() => setIsEditing(false), []) + const snapTarget = resolveNodeSnapTarget(node) ?? 'wall' return ( + + + } isHovered={isHovered} isLast={isLast} diff --git a/packages/editor/src/components/ui/snap-target-badge.test.tsx b/packages/editor/src/components/ui/snap-target-badge.test.tsx new file mode 100644 index 00000000..4a3ad55a --- /dev/null +++ b/packages/editor/src/components/ui/snap-target-badge.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '@pascal-app/core' +import { resolveAssetSnapTarget, resolveNodeSnapTarget } from './snap-target-badge' + +describe('resolveAssetSnapTarget', () => { + test('maps wall-hosted catalog assets to a wall badge', () => { + expect(resolveAssetSnapTarget('wall')).toBe('wall') + expect(resolveAssetSnapTarget('wall-side')).toBe('wall') + }) + + test('maps ceiling-hosted catalog assets to a ceiling badge', () => { + expect(resolveAssetSnapTarget('ceiling')).toBe('ceiling') + }) + + test('does not badge floor assets', () => { + expect(resolveAssetSnapTarget(undefined)).toBeNull() + }) +}) + +describe('resolveNodeSnapTarget', () => { + test('prefers roof attachment when a node is hosted by a roof segment', () => { + const node = { + id: 'window_1', + type: 'window', + roofSegmentId: 'roof-segment_1', + } as unknown as AnyNode + + expect(resolveNodeSnapTarget(node)).toBe('roof') + }) + + test('badges gutter-hosted downspouts as roof accessories', () => { + const node = { + id: 'downspout_1', + type: 'downspout', + gutterId: 'gutter_1', + } as unknown as AnyNode + + expect(resolveNodeSnapTarget(node)).toBe('roof') + }) +}) diff --git a/packages/editor/src/components/ui/snap-target-badge.tsx b/packages/editor/src/components/ui/snap-target-badge.tsx new file mode 100644 index 00000000..a0ce4ac9 --- /dev/null +++ b/packages/editor/src/components/ui/snap-target-badge.tsx @@ -0,0 +1,88 @@ +import type { AnyNode, AssetInput } from '@pascal-app/core' +import type { ReactNode } from 'react' +import { cn } from '../../lib/utils' + +export type SnapTarget = 'wall' | 'ceiling' | 'roof' +export type SnapTargetBadgeSize = 'tile' | 'tree' + +const SNAP_TARGET_ICONS: Record = { + wall: '/icons/wall.png', + ceiling: '/icons/ceiling.png', + roof: '/icons/roof.png', +} + +const SNAP_TARGET_LABELS: Record = { + wall: 'Wall attachment', + ceiling: 'Ceiling attachment', + roof: 'Roof attachment', +} + +const SNAP_TARGET_BADGE_SIZE_CLASSES: Record = { + tile: 'h-6 w-6 rounded-md', + tree: 'h-3.5 w-3.5 rounded-[3px]', +} + +const SNAP_TARGET_ICON_SIZE_CLASSES: Record = { + tile: 'h-[18px] w-[18px]', + tree: 'h-2.5 w-2.5', +} + +export function resolveAssetSnapTarget(attachTo: AssetInput['attachTo']): SnapTarget | null { + if (attachTo === 'wall' || attachTo === 'wall-side') return 'wall' + if (attachTo === 'ceiling') return 'ceiling' + return null +} + +export function resolveNodeSnapTarget(node: AnyNode | null | undefined): SnapTarget | null { + if (!node) return null + if ('roofSegmentId' in node && typeof node.roofSegmentId === 'string') return 'roof' + if (node.type === 'downspout') return 'roof' + if (node.type === 'door' || node.type === 'window') return 'wall' + if (node.type === 'item') return resolveAssetSnapTarget(node.asset?.attachTo) + return null +} + +export function SnapTargetBadge({ + className, + size = 'tile', + target, +}: { + className?: string + size?: SnapTargetBadgeSize + target: SnapTarget +}) { + return ( + + {SNAP_TARGET_LABELS[target]} + + ) +} + +export function SnapTargetIcon({ + children, + target, +}: { + children: ReactNode + target: SnapTarget +}) { + return ( + + {children} + + + ) +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 6e0b0dde..3cb0ed02 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -165,6 +165,13 @@ export { } from './components/ui/sidebar/panels/settings-panel' export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel' export type { SidebarTab } from './components/ui/sidebar/tab-bar' +export { + resolveAssetSnapTarget, + resolveNodeSnapTarget, + type SnapTarget, + SnapTargetBadge, + SnapTargetIcon, +} from './components/ui/snap-target-badge' export type { SaveStatus } from './hooks/use-auto-save' // useDragAction is the React-side glue for the registry's DragAction // primitive. Public so registry-driven kinds (Phase 5+ Stage D ports) @@ -226,6 +233,7 @@ export { linearUnitToMeters, metersToLinearUnit, } from './lib/measurements' +export { consumePlacementDragRelease } from './lib/placement-drag-release' export { addFreshPlacementMetadata, getPlacementMetadataRecord, diff --git a/packages/editor/src/lib/contextual-help.test.ts b/packages/editor/src/lib/contextual-help.test.ts new file mode 100644 index 00000000..6bb40875 --- /dev/null +++ b/packages/editor/src/lib/contextual-help.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test' +import { resolveSelectModeHelpHints } from './contextual-help' + +describe('resolveSelectModeHelpHints', () => { + test('stays hidden in idle select mode with no selection', () => { + expect( + resolveSelectModeHelpHints({ + selectedCount: 0, + hasMovableSelection: false, + hasRotatableSelection: false, + commandPressed: false, + shiftPressed: false, + }), + ).toEqual([]) + }) + + test('shows multi-select guidance when a modifier is held without selection', () => { + expect( + resolveSelectModeHelpHints({ + selectedCount: 0, + hasMovableSelection: false, + hasRotatableSelection: false, + commandPressed: true, + shiftPressed: false, + }), + ).toEqual([ + { + keys: ['Cmd/Ctrl', 'Left click'], + label: 'Add or remove objects from the selection', + active: true, + }, + ]) + }) + + test('shows direct manipulation tips for selected movable and rotatable nodes', () => { + const hints = resolveSelectModeHelpHints({ + selectedCount: 1, + hasMovableSelection: true, + hasRotatableSelection: true, + commandPressed: false, + shiftPressed: false, + }) + + expect(hints).toContainEqual({ + keys: ['Cmd/Ctrl', 'Left click'], + label: 'Drag selected movable object', + }) + expect(hints).toContainEqual({ + keys: ['Cmd/Ctrl', 'Right click'], + label: 'Drag left or right to rotate selected object', + }) + expect(hints).toContainEqual({ + keys: ['Shift'], + label: 'Hold to bypass snaps and angle steps', + active: false, + }) + }) + + test('switches direct manipulation labels while constraints are bypassed', () => { + const hints = resolveSelectModeHelpHints({ + selectedCount: 1, + hasMovableSelection: true, + hasRotatableSelection: true, + commandPressed: true, + shiftPressed: true, + }) + + expect(hints).toContainEqual({ + keys: ['Cmd/Ctrl', 'Left click'], + label: 'Drag selected movable object freely', + active: true, + }) + expect(hints).toContainEqual({ + keys: ['Cmd/Ctrl', 'Right click'], + label: 'Drag left or right to rotate freely', + active: true, + }) + expect(hints).toContainEqual({ + keys: ['Shift'], + label: 'Guided constraints bypassed', + active: true, + }) + }) +}) diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts new file mode 100644 index 00000000..3648a8c9 --- /dev/null +++ b/packages/editor/src/lib/contextual-help.ts @@ -0,0 +1,101 @@ +export type ContextualShortcutHint = { + keys: string[] + label: string + active?: boolean +} + +export type SelectModeHelpContext = { + selectedCount: number + hasMovableSelection: boolean + hasRotatableSelection: boolean + commandPressed: boolean + shiftPressed: boolean +} + +const COMMAND_KEY = 'Cmd/Ctrl' +const LEFT_CLICK = 'Left click' +const RIGHT_CLICK = 'Right click' +const SHIFT_KEY = 'Shift' + +export function resolveSelectModeHelpHints({ + selectedCount, + hasMovableSelection, + hasRotatableSelection, + commandPressed, + shiftPressed, +}: SelectModeHelpContext): ContextualShortcutHint[] { + const hints: ContextualShortcutHint[] = [] + + if (selectedCount === 0) { + if (!commandPressed && !shiftPressed) return hints + + hints.push({ + keys: [commandPressed ? COMMAND_KEY : SHIFT_KEY, LEFT_CLICK], + label: 'Add or remove objects from the selection', + active: true, + }) + return hints + } + + if (commandPressed) { + if (hasMovableSelection) { + hints.push({ + keys: [COMMAND_KEY, LEFT_CLICK], + label: shiftPressed + ? 'Drag selected movable object freely' + : 'Drag selected movable object with guides', + active: true, + }) + } + + if (hasRotatableSelection) { + hints.push({ + keys: [COMMAND_KEY, RIGHT_CLICK], + label: shiftPressed + ? 'Drag left or right to rotate freely' + : 'Drag left or right to rotate', + active: true, + }) + } + + hints.push({ + keys: [COMMAND_KEY, LEFT_CLICK], + label: 'Click without dragging to add or remove objects', + active: commandPressed && !shiftPressed, + }) + } else { + if (hasMovableSelection) { + hints.push({ + keys: [COMMAND_KEY, LEFT_CLICK], + label: 'Drag selected movable object', + }) + } + + if (hasRotatableSelection) { + hints.push({ + keys: [COMMAND_KEY, RIGHT_CLICK], + label: 'Drag left or right to rotate selected object', + }) + } + } + + hints.push({ + keys: [SHIFT_KEY], + label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps', + active: shiftPressed, + }) + + if (!commandPressed) { + hints.push({ + keys: [COMMAND_KEY, LEFT_CLICK], + label: 'Add or remove objects from the selection', + }) + hints.push({ + keys: [SHIFT_KEY, LEFT_CLICK], + label: 'Add or remove objects on the canvas', + active: shiftPressed, + }) + } + + return hints +} diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts new file mode 100644 index 00000000..377d49cd --- /dev/null +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + DEFAULT_ANGLE_STEP, + nodeRegistry, + registerNode, +} from '@pascal-app/core' +import { z } from 'zod' +import { + canDirectMoveNode, + resolveDirectRotationDragDelta, + snapDirectRotationDelta, +} from './direct-manipulation' + +function registerTestDefinition(kind: string, overrides: Partial) { + if (nodeRegistry.has(kind)) return + registerNode({ + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'structure', + defaults: () => ({ type: kind }) as never, + capabilities: {}, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + ...overrides, + } as AnyNodeDefinition) +} + +describe('snapDirectRotationDelta', () => { + test('snaps rotation deltas to the default angle increment', () => { + expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.49, false)).toBe(0) + expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.51, false)).toBeCloseTo( + DEFAULT_ANGLE_STEP, + ) + expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * -1.49, false)).toBeCloseTo( + -DEFAULT_ANGLE_STEP, + ) + }) + + test('keeps the raw rotation delta while free-rotating', () => { + const rawDelta = DEFAULT_ANGLE_STEP * 0.42 + expect(snapDirectRotationDelta(rawDelta, true)).toBe(rawDelta) + }) +}) + +describe('resolveDirectRotationDragDelta', () => { + test('maps horizontal pointer motion to the direct rotation delta direction', () => { + const radiansPerPixel = DEFAULT_ANGLE_STEP / 12 + + expect(resolveDirectRotationDragDelta(100, 112, radiansPerPixel, false)).toBeCloseTo( + -DEFAULT_ANGLE_STEP, + ) + expect(resolveDirectRotationDragDelta(100, 88, radiansPerPixel, false)).toBeCloseTo( + DEFAULT_ANGLE_STEP, + ) + }) + + test('keeps unsnapped drag deltas while free-rotating', () => { + expect(resolveDirectRotationDragDelta(100, 103, 0.1, true)).toBeCloseTo(-0.3) + }) +}) + +describe('canDirectMoveNode', () => { + test('excludes floorplan-only move targets from 3D direct move', () => { + const kind = 'direct-move-floorplan-only-test' + registerTestDefinition(kind, { floorplanMoveTarget: {} as never }) + + expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) + }) + + test('excludes bespoke move tools from 3D direct move', () => { + const kind = 'direct-move-bespoke-tool-test' + registerTestDefinition(kind, { + affordanceTools: { + move: async () => ({ default: () => null }), + } as never, + }) + + expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) + }) + + test('accepts nodes with the generic movable capability', () => { + const kind = 'direct-move-movable-test' + registerTestDefinition(kind, { + capabilities: { + movable: { axes: ['x', 'z'], gridSnap: true }, + }, + } as Partial) + + expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true) + }) +}) diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts new file mode 100644 index 00000000..54564e57 --- /dev/null +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -0,0 +1,72 @@ +import { + type AnyNode, + type ArcResizeHandle, + createSceneApi, + DEFAULT_ANGLE_STEP, + type HandleDescriptor, + nodeRegistry, + type SceneApi, + useScene, +} from '@pascal-app/core' + +function resolveHandles(node: AnyNode): HandleDescriptor[] { + const handles = nodeRegistry.get(node.type)?.handles + if (!handles) return [] + return ( + typeof handles === 'function' ? handles(node as never) : handles + ) as HandleDescriptor[] +} + +export function getDirectRotateHandle(node: AnyNode): ArcResizeHandle | null { + for (const handle of resolveHandles(node)) { + if (handle.kind === 'arc-resize' && handle.shape === 'rotate') { + return handle as ArcResizeHandle + } + } + return null +} + +export function canDirectRotateNode(node: AnyNode): boolean { + return ( + getDirectRotateHandle(node) !== null || + nodeRegistry.get(node.type)?.capabilities?.rotatable !== undefined + ) +} + +export function canDirectMoveNode(node: AnyNode): boolean { + return nodeRegistry.get(node.type)?.capabilities?.movable !== undefined +} + +export function snapDirectRotationDelta(delta: number, free: boolean): number { + return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP +} + +export function resolveDirectRotationDragDelta( + startX: number, + clientX: number, + radiansPerPixel: number, + free: boolean, +): number { + return snapDirectRotationDelta((startX - clientX) * radiansPerPixel, free) +} + +export function resolveDirectRotationPatch( + node: AnyNode, + delta: number, + sceneApi: SceneApi = createSceneApi(useScene), +): Partial | null { + const rotateHandle = getDirectRotateHandle(node) + if (rotateHandle) { + return rotateHandle.apply(node, delta, sceneApi) as Partial + } + + const rotation = (node as { rotation?: unknown }).rotation + if (typeof rotation === 'number') { + return { rotation: rotation - delta } as Partial + } + if (Array.isArray(rotation)) { + const [rx = 0, ry = 0, rz = 0] = rotation as [number?, number?, number?] + return { rotation: [rx, ry - delta, rz] } as Partial + } + return null +} diff --git a/packages/editor/src/lib/floorplan/apply-alignment.test.ts b/packages/editor/src/lib/floorplan/apply-alignment.test.ts new file mode 100644 index 00000000..d9355b4b --- /dev/null +++ b/packages/editor/src/lib/floorplan/apply-alignment.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import useAlignmentGuides from '../../store/use-alignment-guides' +import { applyFloorplanAlignment } from './apply-alignment' + +describe('applyFloorplanAlignment', () => { + afterEach(() => { + useAlignmentGuides.getState().clear() + }) + + test('can publish passive guides without applying snap', () => { + useAlignmentGuides.getState().clear() + + const result = applyFloorplanAlignment( + [0.04, 2], + [{ nodeId: 'draft', kind: 'corner', x: 0.04, z: 2 }], + [{ nodeId: 'wall_a', kind: 'corner', x: 0, z: 0 }], + { applySnap: false }, + ) + + expect(result.point).toEqual([0.04, 2]) + expect(result.snapped).toBe(false) + expect(result.guides).toHaveLength(1) + expect(useAlignmentGuides.getState().guides).toHaveLength(1) + }) +}) diff --git a/packages/editor/src/lib/floorplan/apply-alignment.ts b/packages/editor/src/lib/floorplan/apply-alignment.ts index f65754ad..6c8349ab 100644 --- a/packages/editor/src/lib/floorplan/apply-alignment.ts +++ b/packages/editor/src/lib/floorplan/apply-alignment.ts @@ -30,9 +30,9 @@ export type FloorplanAlignmentResult = { * * Publishes guides to the `useAlignmentGuides` store as a side effect — set * on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer` - * renders them. Returns the adjusted point. When `bypass` is true (Alt held) - * the point is returned unchanged and guides are cleared, matching the - * "No snap" affordance the placement tools advertise. + * renders them. Returns the adjusted point. When `bypass` is true (Alt for + * alignment-only bypass, or Shift for the full guided-constraint bypass) the + * point is returned unchanged and guides are cleared. * * `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`); * the scene is stable during a single drag, so re-collecting per pointer-move @@ -42,7 +42,7 @@ export function applyFloorplanAlignment( point: readonly [number, number], movingAnchors: AlignmentAnchor[], candidates: AlignmentAnchor[], - opts?: { bypass?: boolean; threshold?: number }, + opts?: { applySnap?: boolean; bypass?: boolean; threshold?: number }, ): FloorplanAlignmentResult { if (opts?.bypass) { useAlignmentGuides.getState().clear() @@ -57,7 +57,7 @@ export function applyFloorplanAlignment( useAlignmentGuides.getState().set(result.guides) - if (!result.snap) { + if (!result.snap || opts?.applySnap === false) { return { point: [point[0], point[1]], snapped: false, guides: result.guides } } return { @@ -81,8 +81,9 @@ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__' * * Used by BOTH the move-preview branch and the click-commit handler so the * committed vertex lands exactly where the preview showed it. Caller owns the - * per-kind precedence (existing-wall endpoint/join snap wins; angle-snap - * suppresses alignment) and only calls this when alignment should apply. + * per-kind precedence: existing-wall endpoint/join snap can still win, while + * angle-locked segments can pass `applySnap: false` to publish passive guide + * feedback without pulling the endpoint off its constrained ray. * * `excludeIds` drops those nodes' anchors from the candidate pool — used when * dragging a wall / fence endpoint so the moving endpoint doesn't try to @@ -90,7 +91,12 @@ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__' */ export function alignFloorplanDraftPoint( point: readonly [number, number], - opts?: { bypass?: boolean; threshold?: number; excludeIds?: readonly string[] }, + opts?: { + applySnap?: boolean + bypass?: boolean + threshold?: number + excludeIds?: readonly string[] + }, ): [number, number] { if (opts?.bypass) { useAlignmentGuides.getState().clear() @@ -105,7 +111,7 @@ export function alignFloorplanDraftPoint( point, [{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }], candidates, - { threshold: opts?.threshold }, + { applySnap: opts?.applySnap, threshold: opts?.threshold }, ) return snapped } diff --git a/packages/editor/src/lib/placement-drag-release.ts b/packages/editor/src/lib/placement-drag-release.ts new file mode 100644 index 00000000..da793163 --- /dev/null +++ b/packages/editor/src/lib/placement-drag-release.ts @@ -0,0 +1,23 @@ +'use client' + +import useEditor from '../store/use-editor' + +function swallowNextClick(timeoutMs = 300) { + const swallow = (event: Event) => { + event.stopPropagation() + event.preventDefault() + } + + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), timeoutMs) +} + +export function consumePlacementDragRelease(event: PointerEvent): boolean { + if (!useEditor.getState().placementDragMode) return false + if (event.button !== 0) return false + + event.stopPropagation() + event.preventDefault() + swallowNextClick() + return true +} diff --git a/packages/editor/src/lib/selection-routing.test.ts b/packages/editor/src/lib/selection-routing.test.ts new file mode 100644 index 00000000..fb1e4c7d --- /dev/null +++ b/packages/editor/src/lib/selection-routing.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '@pascal-app/core' +import { + resolveNodeSelectionTarget, + resolveSelectedIdsForNodeClick, + selectionModifiersFromEvent, +} from './selection-routing' + +describe('resolveSelectedIdsForNodeClick', () => { + test('preserves the pre-routing selection when a phase switch clears current ids', () => { + expect( + resolveSelectedIdsForNodeClick({ + baseSelectedIds: ['wall_1'], + currentSelectedIds: [], + modifierKeys: { meta: true, ctrl: false, shift: false }, + nodeId: 'item_1', + }), + ).toEqual(['wall_1', 'item_1']) + }) + + test('toggles from the pre-routing selection while a modifier is held', () => { + expect( + resolveSelectedIdsForNodeClick({ + baseSelectedIds: ['wall_1', 'item_1'], + currentSelectedIds: [], + modifierKeys: { meta: false, ctrl: false, shift: true }, + nodeId: 'item_1', + }), + ).toEqual(['wall_1']) + }) +}) + +describe('selectionModifiersFromEvent', () => { + test('falls back to tracked modifier state when the click event omits keys', () => { + expect(selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false })).toEqual({ + meta: false, + ctrl: true, + shift: false, + }) + }) + + test('prefers explicit event key state over stale tracked modifiers', () => { + expect( + selectionModifiersFromEvent( + { metaKey: false, ctrlKey: false, shiftKey: false }, + { meta: true, ctrl: true, shift: true }, + ), + ).toEqual({ + meta: false, + ctrl: false, + shift: false, + }) + }) +}) + +describe('resolveNodeSelectionTarget', () => { + test('routes furniture items to furnish', () => { + const node = { + id: 'item_1', + type: 'item', + asset: { category: 'furniture' }, + } as unknown as AnyNode + + expect(resolveNodeSelectionTarget(node)).toEqual({ phase: 'furnish' }) + }) + + test('routes door and window catalog items to structure', () => { + const node = { + id: 'item_1', + type: 'item', + asset: { category: 'door' }, + } as unknown as AnyNode + + expect(resolveNodeSelectionTarget(node)).toEqual({ + phase: 'structure', + structureLayer: 'elements', + }) + }) +}) diff --git a/packages/editor/src/lib/selection-routing.ts b/packages/editor/src/lib/selection-routing.ts new file mode 100644 index 00000000..9d978da1 --- /dev/null +++ b/packages/editor/src/lib/selection-routing.ts @@ -0,0 +1,124 @@ +import { type AnyNode, type ItemNode, nodeRegistry } from '@pascal-app/core' + +export type SelectionModifierKeys = { + meta: boolean + ctrl: boolean + shift: boolean +} + +export type NodeSelectionTarget = { + phase: 'site' | 'structure' | 'furnish' + structureLayer?: 'zones' | 'elements' +} + +export function isSelectionModifierActive(keys: SelectionModifierKeys): boolean { + return keys.meta || keys.ctrl || keys.shift +} + +export function selectionModifiersFromEvent( + event?: { + metaKey?: boolean + ctrlKey?: boolean + shiftKey?: boolean + nativeEvent?: { + metaKey?: boolean + ctrlKey?: boolean + shiftKey?: boolean + } + } | null, + fallback?: Partial, +): SelectionModifierKeys { + const fromEvent = ( + key: keyof SelectionModifierKeys, + eventKey: 'metaKey' | 'ctrlKey' | 'shiftKey', + ) => { + if (typeof event?.[eventKey] === 'boolean') return event[eventKey] + if (typeof event?.nativeEvent?.[eventKey] === 'boolean') return event.nativeEvent[eventKey] + return Boolean(fallback?.[key]) + } + + return { + meta: fromEvent('meta', 'metaKey'), + ctrl: fromEvent('ctrl', 'ctrlKey'), + shift: fromEvent('shift', 'shiftKey'), + } +} + +export function resolveSelectedIdsForNodeClick({ + baseSelectedIds, + currentSelectedIds, + modifierKeys, + nodeId, +}: { + baseSelectedIds?: readonly string[] + currentSelectedIds: readonly string[] + modifierKeys: SelectionModifierKeys + nodeId: string +}): string[] { + if (isSelectionModifierActive(modifierKeys)) { + const selectedIds = baseSelectedIds ?? currentSelectedIds + if (selectedIds.includes(nodeId)) { + return selectedIds.filter((id) => id !== nodeId) + } + return [...selectedIds, nodeId] + } + + return [nodeId] +} + +export function resolveNodeSelectionTarget(node: AnyNode): NodeSelectionTarget | null { + if (node.type === 'building') { + return { phase: 'site' } + } + + if (node.type === 'zone') { + return { + phase: 'structure', + structureLayer: 'zones', + } + } + + if (node.type === 'item') { + const item = node as ItemNode + if (item.asset.category === 'door' || item.asset.category === 'window') { + return { + phase: 'structure', + structureLayer: 'elements', + } + } + return { phase: 'furnish' } + } + + if ( + node.type === 'wall' || + node.type === 'fence' || + node.type === 'column' || + node.type === 'elevator' || + node.type === 'slab' || + node.type === 'ceiling' || + node.type === 'roof' || + node.type === 'roof-segment' || + node.type === 'stair' || + node.type === 'stair-segment' || + node.type === 'spawn' || + node.type === 'window' || + node.type === 'door' + ) { + return { + phase: 'structure', + structureLayer: 'elements', + } + } + + const def = nodeRegistry.get(node.type) + if (!def) return null + + if (def.category === 'furnish') { + return { phase: 'furnish' } + } + + return { + phase: 'structure', + structureLayer: 'elements', + } +} diff --git a/packages/editor/src/store/use-direct-manipulation-feedback.ts b/packages/editor/src/store/use-direct-manipulation-feedback.ts new file mode 100644 index 00000000..c715654b --- /dev/null +++ b/packages/editor/src/store/use-direct-manipulation-feedback.ts @@ -0,0 +1,20 @@ +import type { AnyNodeId } from '@pascal-app/core' +import { create } from 'zustand' + +type DirectManipulationFeedbackState = { + activeRotateNodeId: AnyNodeId | null + setActiveRotateNodeId(nodeId: AnyNodeId | null): void + clearActiveRotateNodeId(nodeId?: AnyNodeId): void +} + +const useDirectManipulationFeedback = create((set) => ({ + activeRotateNodeId: null, + setActiveRotateNodeId: (activeRotateNodeId) => set({ activeRotateNodeId }), + clearActiveRotateNodeId: (nodeId) => + set((state) => { + if (nodeId !== undefined && state.activeRotateNodeId !== nodeId) return {} + return { activeRotateNodeId: null } + }), +})) + +export default useDirectManipulationFeedback diff --git a/packages/nodes/src/box-vent/move-tool.tsx b/packages/nodes/src/box-vent/move-tool.tsx index 4a8b1da5..6553f7a3 100644 --- a/packages/nodes/src/box-vent/move-tool.tsx +++ b/packages/nodes/src/box-vent/move-tool.tsx @@ -9,7 +9,12 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' import { @@ -59,11 +64,22 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { let lastSnap: [number, number] | null = null let lastTarget: RelativeRoofDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag(original) + const clearTarget = () => { + lastTarget = null + lastSnap = null + setPreviewPos(null) + setPreviewSurfaceQuat(null) + } + const updatePreview = (event: RoofEvent) => { const target = roofDrag.resolve(event) - if (!target) return + if (!target) { + clearTarget() + return + } lastTarget = target const sx = Math.round(target.localX * 20) / 20 @@ -90,8 +106,10 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { } const onRoofClick = (event: RoofEvent) => { + if (committed) return const target = lastTarget ?? roofDrag.resolve(event) if (!target) return + committed = true const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() @@ -176,16 +194,29 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { exitMoveMode() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (!lastTarget) return + onRoofClick({ + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as RoofEvent) + } + emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', clearTarget) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', clearTarget) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) // Safety restore — if the tool is unmounted by something other than // a commit / cancel path (e.g. tool change, selection wipe), leave diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index 4290d436..ee424c8e 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' import { boxVentDefinition } from './definition' @@ -122,16 +123,26 @@ const BoxVentTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null - return ( - - - - + <> + { + setPreviewPos(null) + setPreviewSurfaceQuat(null) + }} + size={[0.6, 0.4, 0.6]} + /> + {activeBuildingId && previewPos && previewSurfaceQuat && ( + + + + + + - - + )} + ) } diff --git a/packages/nodes/src/building/move-tool.tsx b/packages/nodes/src/building/move-tool.tsx index 91333f46..43731a88 100644 --- a/packages/nodes/src/building/move-tool.tsx +++ b/packages/nodes/src/building/move-tool.tsx @@ -8,7 +8,13 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { + CursorSphere, + consumePlacementDragRelease, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' @@ -151,6 +157,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { } const onGridClick = (event: GridEvent) => { + if (wasCommitted) return const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter wasCommitted = true @@ -169,6 +176,11 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { event.nativeEvent?.stopPropagation?.() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + onGridClick({ nativeEvent: event } as unknown as GridEvent) + } + const onCancel = () => { // Revert mesh position and rotation immediately const mesh = sceneRegistry.nodes.get(nodeId) @@ -190,6 +202,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { if (!wasCommitted) { @@ -207,6 +220,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('pointerup', onPlacementDragPointerUp) } }, [exitMoveMode]) // stable — node values captured via refs at mount diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index 93bd5903..a59a7034 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -150,6 +150,7 @@ export const ceilingDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Trace ceiling outline' }, { key: 'Enter', label: 'Finish ceiling' }, + { key: 'Shift', label: 'Free outline' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 3914db44..8ac04216 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -14,6 +14,7 @@ import { } from '@pascal-app/core' import { CursorSphere, + consumePlacementDragRelease, markToolCancelConsumed, triggerSFX, useAlignmentGuides, @@ -189,6 +190,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { } const onGridClick = (event: GridEvent) => { + if (wasCommitted) return if (isFloorplanSourcedEvent(event)) return if (Date.now() - activatedAtRef.current < 150) { event.nativeEvent?.stopPropagation?.() @@ -214,6 +216,12 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { event.nativeEvent?.stopPropagation?.() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + activatedAtRef.current = 0 + onGridClick({ nativeEvent: event } as unknown as GridEvent) + } + const onCancel = () => { clearPreview() useAlignmentGuides.getState().clear() @@ -225,6 +233,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { useAlignmentGuides.getState().clear() @@ -236,6 +245,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) } }, [exitMoveMode, node.id]) diff --git a/packages/nodes/src/chimney/move-tool.tsx b/packages/nodes/src/chimney/move-tool.tsx index c635bc03..5e4690f9 100644 --- a/packages/nodes/src/chimney/move-tool.tsx +++ b/packages/nodes/src/chimney/move-tool.tsx @@ -10,7 +10,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { triggerSFX, useEditor } from '@pascal-app/editor' +import { consumePlacementDragRelease, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' @@ -89,9 +89,19 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { roofSegmentId: node.roofSegmentId, }) + const clearTarget = () => { + lastTarget = null + setSegmentXform(null) + setHitLocal(null) + setPreviewSegment(null) + } + const updatePreview = (event: RoofEvent) => { const target = roofDrag.resolve(event) - if (!target) return + if (!target) { + clearTarget() + return + } lastTarget = target const sx = Math.round(target.localX * 20) / 20 @@ -156,14 +166,27 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { event.stopPropagation() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (!lastTarget) return + onClick({ + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as RoofEvent) + } + emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onClick) + emitter.on('roof:leave', clearTarget) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + emitter.off('roof:leave', clearTarget) + window.removeEventListener('pointerup', onPlacementDragPointerUp) } }, [activeBuildingId, node, setMovingNode, setSelection]) diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index 5b319ca2..f69424ac 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -14,6 +14,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { chimneyDefinition } from './definition' import ChimneyPreview from './preview' @@ -139,19 +140,30 @@ const ChimneyTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !segmentXform || !hitLocal || !previewSegment) return null - - // Outer group mirrors the real renderer's `position={segment.position} - // rotation-y={segment.rotation}` chain by composing the segment's - // building-local matrix (which walks roof + level + segment). Inner - // group offsets by the cursor's segment-local x/z so the chimney - // geometry (built with `position[0,2] = 0`) lands under the cursor. return ( - - - - - + <> + { + setSegmentXform(null) + setHitLocal(null) + setPreviewSegment(null) + }} + size={[1, 2.5, 1]} + /> + {activeBuildingId && segmentXform && hitLocal && previewSegment && ( + // Outer group mirrors the real renderer's `position={segment.position} + // rotation-y={segment.rotation}` chain by composing the segment's + // building-local matrix (which walks roof + level + segment). Inner + // group offsets by the cursor's segment-local x/z so the chimney + // geometry (built with `position[0,2] = 0`) lands under the cursor. + + + + + + )} + ) } diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index 81c366b8..8460ae05 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -359,7 +359,7 @@ export const columnDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place column' }, - { key: 'Alt', label: 'No snap' }, + { key: 'Shift', label: 'Free place' }, { key: 'Esc', label: 'Cancel' }, ], floorplan: buildColumnFloorplan, diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index f2d645f4..4df41bd3 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -16,6 +16,7 @@ import { import { CursorSphere, commitFreshPlacementSubtree, + consumePlacementDragRelease, DragBoundingBox, getFloorStackPreviewPosition, markToolCancelConsumed, @@ -165,6 +166,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { } const onGridClick = (event: GridEvent) => { + if (committed) return if (!hasMoved) return useAlignmentGuides.getState().clear() // Commit at the last previewed position so the alignment snap (which @@ -225,6 +227,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { event.nativeEvent?.stopPropagation?.() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + onGridClick({ nativeEvent: event } as unknown as GridEvent) + } + const onCancel = () => { useLiveTransforms.getState().clear(node.id) useAlignmentGuides.getState().clear() @@ -244,12 +251,14 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { } window.addEventListener('keydown', onKeyDown) + window.addEventListener('pointerup', onPlacementDragPointerUp) emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) return () => { window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('pointerup', onPlacementDragPointerUp) emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) diff --git a/packages/nodes/src/cupola/move-tool.tsx b/packages/nodes/src/cupola/move-tool.tsx index 40e50b30..1d888217 100644 --- a/packages/nodes/src/cupola/move-tool.tsx +++ b/packages/nodes/src/cupola/move-tool.tsx @@ -9,7 +9,12 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' import { @@ -57,11 +62,22 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { let lastSnap: [number, number] | null = null let lastTarget: RelativeRoofDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag(original) + const clearTarget = () => { + lastTarget = null + lastSnap = null + setPreviewPos(null) + setPreviewSurfaceQuat(null) + } + const updatePreview = (event: RoofEvent) => { const target = roofDrag.resolve(event) - if (!target) return + if (!target) { + clearTarget() + return + } lastTarget = target const sx = Math.round(target.localX * 20) / 20 @@ -88,8 +104,10 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { } const onRoofClick = (event: RoofEvent) => { + if (committed) return const target = lastTarget ?? roofDrag.resolve(event) if (!target) return + committed = true const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() @@ -168,16 +186,29 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { exitMoveMode() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (!lastTarget) return + onRoofClick({ + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as RoofEvent) + } + emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', clearTarget) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', clearTarget) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true diff --git a/packages/nodes/src/cupola/tool.tsx b/packages/nodes/src/cupola/tool.tsx index 9b6e1996..8fb8e928 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { cupolaDefinition } from './definition' @@ -114,16 +115,26 @@ const CupolaTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null - return ( - - - - + <> + { + setPreviewPos(null) + setPreviewSurfaceQuat(null) + }} + size={[0.8, 1.2, 0.8]} + /> + {activeBuildingId && previewPos && previewSurfaceQuat && ( + + + + + + - - + )} + ) } diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 2c86b92c..f006e263 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -219,6 +219,7 @@ export const doorDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Place door on wall' }, + { key: 'Shift', label: 'Free place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index ece90ca1..8f18bf88 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -15,6 +15,7 @@ import { import { calculateCursorRotation, calculateItemRotation, + consumePlacementDragRelease, EDITOR_LAYER, getSideFromNormal, isValidWallSideFace, @@ -80,6 +81,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => let currentHostId: string | null = movingDoorNode.parentId let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null + let committed = false let lastTarget: { wallNode: WallEvent['node'] wallId: string @@ -91,6 +93,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => valid: boolean event: WallEvent } | null = null + let lastRoofEvent: RoofEvent | null = null const markHostDirty = (hostId: string | null) => { if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId) @@ -254,34 +257,50 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onWallEnter = (event: WallEvent) => { const target = resolveMoveTarget(event) - if (!target) return + if (!target) { + onWallLeave() + return + } lastTarget = target + lastRoofEvent = null applyPreview(target) event.stopPropagation() } const onWallMove = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) return + if (!isValidWallSideFace(event.normal)) { + onWallLeave() + return + } if (isCurvedWall(event.node)) { - hideCursor() + onWallLeave() + return + } + if (event.node.parentId !== getLevelId()) { + onWallLeave() return } - if (event.node.parentId !== getLevelId()) return const target = resolveMoveTarget(event) - if (!target) return + if (!target) { + onWallLeave() + return + } lastTarget = target + lastRoofEvent = null applyPreview(target) event.stopPropagation() } const onWallClick = (event: WallEvent) => { + if (committed) return if (!isValidWallSideFace(event.normal)) return if (isCurvedWall(event.node)) return if (event.node.parentId !== getLevelId()) return const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) if (!target?.valid) return + committed = true let placedId: string @@ -349,6 +368,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => useLiveTransforms.getState().clear(movingDoorNode.id) dragAnchor = null lastTarget = null + lastRoofEvent = null if (isNew) return if (currentHostId && currentHostId !== original.parentId) { markHostDirty(currentHostId) @@ -387,10 +407,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onRoofHover = (event: RoofEvent) => { const target = resolveRoofMoveTarget(event) - if (!target) return + if (!target) { + onRoofLeave() + return + } // Wall-frame drag anchor / live transform don't apply on a roof face. dragAnchor = null lastTarget = null + lastRoofEvent = event useLiveTransforms.getState().clear(movingDoorNode.id) if (currentHostId !== target.segment.id) { useScene.getState().updateNode(movingDoorNode.id, { @@ -416,8 +440,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const onRoofClick = (event: RoofEvent) => { + if (committed) return const target = resolveRoofMoveTarget(event) if (!target?.valid) return + committed = true const segmentId = target.segment.id let placedId: string @@ -487,6 +513,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => useLiveTransforms.getState().clear(movingDoorNode.id) dragAnchor = null lastTarget = null + lastRoofEvent = null if (isNew) return if (currentHostId && currentHostId !== original.parentId) { markHostDirty(currentHostId) @@ -527,6 +554,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => exitMoveMode() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (lastTarget) { + onWallClick(lastTarget.event) + return + } + if (lastRoofEvent) onRoofClick(lastRoofEvent) + } + emitter.on('wall:enter', onWallEnter) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) @@ -536,6 +572,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as @@ -572,6 +609,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) } }, [movingDoorNode, exitMoveMode]) diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 30913e50..0aa4c8c1 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -3,6 +3,7 @@ import { collectAlignmentAnchors, DoorNode, emitter, + type GridEvent, isCurvedWall, type RoofEvent, type RoofNode, @@ -22,12 +23,13 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' -import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' +import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, resolveRoofWallOpeningTarget, + worldToSelectedBuildingLocal, } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -39,6 +41,10 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) +const FALLBACK_WIDTH = 0.9 +const FALLBACK_HEIGHT = 2.1 +const roofFallbackPoint = new Vector3() + /** * Door tool — places DoorNodes on walls and on roof-segment wall faces * (the generated base walls under a roof, including coplanar gable ends). @@ -99,16 +105,47 @@ const DoorTool: React.FC = () => { edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } + const showFallbackCursor = (event: GridEvent) => { + if (draftRef.current) return + const [x, y, z] = event.localPosition + updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false) + useAlignmentGuides.getState().clear() + } + + const showRoofFallbackCursor = (event: RoofEvent) => { + const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) + updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false) + useAlignmentGuides.getState().clear() + } + + const showWallFallbackCursor = (event: WallEvent) => { + const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) + updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false) + useAlignmentGuides.getState().clear() + } + const onWallEnter = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) return + if (!isValidWallSideFace(event.normal)) { + destroyDraft() + showWallFallbackCursor(event) + return + } if (isCurvedWall(event.node)) { destroyDraft() - hideCursor() + showWallFallbackCursor(event) return } const levelId = getLevelId() - if (!levelId) return - if (event.node.parentId !== levelId) return + if (!levelId) { + destroyDraft() + showWallFallbackCursor(event) + return + } + if (event.node.parentId !== levelId) { + destroyDraft() + showWallFallbackCursor(event) + return + } destroyDraft() @@ -158,13 +195,21 @@ const DoorTool: React.FC = () => { } const onWallMove = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) return - if (isCurvedWall(event.node)) { + if (!isValidWallSideFace(event.normal)) { destroyDraft() - hideCursor() + showWallFallbackCursor(event) + return + } + if (isCurvedWall(event.node)) { + destroyDraft() + showWallFallbackCursor(event) + return + } + if (event.node.parentId !== getLevelId()) { + destroyDraft() + showWallFallbackCursor(event) return } - if (event.node.parentId !== getLevelId()) return const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) @@ -373,10 +418,8 @@ const DoorTool: React.FC = () => { if (!target) { // On the roof but not over a placeable wall face (slope, soffit, // or a face the door cannot fit on). - if (draftRef.current?.roofSegmentId) { - destroyDraft() - hideCursor() - } + destroyDraft() + showRoofFallbackCursor(event) return } const { segment, face, position } = target @@ -483,6 +526,7 @@ const DoorTool: React.FC = () => { emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) + emitter.on('grid:move', showFallbackCursor) emitter.on('tool:cancel', onCancel) return () => { @@ -498,12 +542,13 @@ const DoorTool: React.FC = () => { emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) + emitter.off('grid:move', showFallbackCursor) emitter.off('tool:cancel', onCancel) } }, []) - // Cursor geometry: door outline (default 0.9 × 2.1 × 0.07) - const boxGeo = new BoxGeometry(0.9, 2.1, 0.07) + // Cursor geometry: door outline. + const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) const edgesGeo = new EdgesGeometry(boxGeo) boxGeo.dispose() diff --git a/packages/nodes/src/dormer/tool.tsx b/packages/nodes/src/dormer/tool.tsx index 68a873a4..fdbdd98a 100644 --- a/packages/nodes/src/dormer/tool.tsx +++ b/packages/nodes/src/dormer/tool.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { dormerDefinition } from './definition' import DormerPreview from './preview' import { useDormerPlacement } from './use-dormer-placement' @@ -47,36 +48,44 @@ const DormerTool = () => { [], ) - const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({ - onCommit: (hit, rotation) => { - const state = useScene.getState() - const dormer = DormerNode.parse({ - ...dormerDefinition.defaults(), - name: `Dormer ${nextDormerNumber(state.nodes)}`, - roofSegmentId: hit.segment.id, - parentId: hit.segment.id, - // Anchor at the slope height so the renderer matches the ghost. - // The CSG still carves cleanly because it inverts T(position) - // when bringing the host into dormer-local. - position: [hit.localX, hit.localY, hit.localZ], - rotation, - }) - state.createNode(dormer, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - setSelection({ selectedIds: [dormer.id] }) - }, - }) - - if (!activeBuildingId || !segmentXform || !hitLocal) return null + const { activeBuildingId, clearPreview, segmentXform, hitLocal, ghostRotation } = + useDormerPlacement({ + onCommit: (hit, rotation) => { + const state = useScene.getState() + const dormer = DormerNode.parse({ + ...dormerDefinition.defaults(), + name: `Dormer ${nextDormerNumber(state.nodes)}`, + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + // Anchor at the slope height so the renderer matches the ghost. + // The CSG still carves cleanly because it inverts T(position) + // when bringing the host into dormer-local. + position: [hit.localX, hit.localY, hit.localZ], + rotation, + }) + state.createNode(dormer, hit.segment.id as AnyNodeId) + state.dirtyNodes.add(hit.segment.id as AnyNodeId) + setSelection({ selectedIds: [dormer.id] }) + }, + }) return ( - - - - + <> + + {activeBuildingId && segmentXform && hitLocal && ( + + + + + + - - + )} + ) } diff --git a/packages/nodes/src/dormer/use-dormer-placement.ts b/packages/nodes/src/dormer/use-dormer-placement.ts index 5a628cb5..7488b2dc 100644 --- a/packages/nodes/src/dormer/use-dormer-placement.ts +++ b/packages/nodes/src/dormer/use-dormer-placement.ts @@ -6,7 +6,7 @@ import { type RoofSegmentNode, sceneRegistry, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { consumePlacementDragRelease, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import * as THREE from 'three' @@ -58,6 +58,7 @@ export function useDormerPlacement(opts: { onCommit: (hit: DormerPlacementHit, rotation: number) => void }): { activeBuildingId: string | undefined + clearPreview: () => void segmentXform: DormerSegmentTransform | null hitLocal: [number, number, number] | null ghostRotation: number @@ -78,6 +79,11 @@ export function useDormerPlacement(opts: { const onCommitRef = useRef(opts.onCommit) onCommitRef.current = opts.onCommit + const clearPreview = () => { + setSegmentXform(null) + setHitLocal(null) + } + useEffect(() => { if (!activeBuildingId) return @@ -99,6 +105,7 @@ export function useDormerPlacement(opts: { const roofDrag = relativeStartRef.current ? createRelativeRoofDrag(relativeStartRef.current) : null + let committed = false let lastRelativeHit: DormerPlacementHit | null = null const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => { @@ -139,15 +146,27 @@ export function useDormerPlacement(opts: { } const onClick = (event: RoofEvent) => { + if (committed) return const hit = roofDrag ? (lastRelativeHit ?? resolvePlacementHit(event)) : resolvePlacementHit(event) if (!hit) return + committed = true onCommitRef.current(hit, ghostRotationRef.current) triggerSFX('sfx:item-place') event.stopPropagation() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (committed) return + if (!consumePlacementDragRelease(event)) return + const hit = roofDrag ? lastRelativeHit : null + if (!hit) return + committed = true + onCommitRef.current(hit, ghostRotationRef.current) + triggerSFX('sfx:item-place') + } + const onKeyDown = (e: KeyboardEvent) => { if (e.key !== 'r' && e.key !== 'R') return const target = e.target as HTMLElement | null @@ -166,17 +185,20 @@ export function useDormerPlacement(opts: { emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onClick) window.addEventListener('keydown', onKeyDown) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('pointerup', onPlacementDragPointerUp) } }, [activeBuildingId]) return { activeBuildingId: activeBuildingId ?? undefined, + clearPreview, segmentXform, hitLocal, ghostRotation, diff --git a/packages/nodes/src/downspout/tool.tsx b/packages/nodes/src/downspout/tool.tsx index 1f92affc..7356f1d0 100644 --- a/packages/nodes/src/downspout/tool.tsx +++ b/packages/nodes/src/downspout/tool.tsx @@ -17,6 +17,7 @@ import { useEffect, useMemo, useState } from 'react' import { Vector3 } from 'three' import { computeEaveY } from '../gutter/eave-snap' import { resolveGutterOutletById } from '../gutter/outlet-lookup' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { downspoutDefinition } from './definition' import DownspoutPreview from './preview' import { computeDownspoutRouting, type DownspoutRouting } from './routing' @@ -162,22 +163,30 @@ const DownspoutTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !target) return null - return ( - - - - + <> + setTarget(null)} + size={[0.2, 2.5, 0.2]} + validTarget="gutter" + /> + {activeBuildingId && target && ( + + + + + + - - + )} + ) } diff --git a/packages/nodes/src/eyebrow-vent/move-tool.tsx b/packages/nodes/src/eyebrow-vent/move-tool.tsx index 1d5d1696..ce1156d5 100644 --- a/packages/nodes/src/eyebrow-vent/move-tool.tsx +++ b/packages/nodes/src/eyebrow-vent/move-tool.tsx @@ -9,7 +9,12 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' import { @@ -58,11 +63,22 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) let lastSnap: [number, number] | null = null let lastTarget: RelativeRoofDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag(original) + const clearTarget = () => { + lastTarget = null + lastSnap = null + setPreviewPos(null) + setPreviewSurfaceQuat(null) + } + const updatePreview = (event: RoofEvent) => { const target = roofDrag.resolve(event) - if (!target) return + if (!target) { + clearTarget() + return + } lastTarget = target const sx = Math.round(target.localX * 20) / 20 @@ -89,8 +105,10 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) } const onRoofClick = (event: RoofEvent) => { + if (committed) return const target = lastTarget ?? roofDrag.resolve(event) if (!target) return + committed = true const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() @@ -169,16 +187,29 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) exitMoveMode() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (!lastTarget) return + onRoofClick({ + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as RoofEvent) + } + emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', clearTarget) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', clearTarget) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true diff --git a/packages/nodes/src/eyebrow-vent/tool.tsx b/packages/nodes/src/eyebrow-vent/tool.tsx index 14e4e673..5d5b0d00 100644 --- a/packages/nodes/src/eyebrow-vent/tool.tsx +++ b/packages/nodes/src/eyebrow-vent/tool.tsx @@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' import { eyebrowVentDefinition } from './definition' @@ -117,16 +118,26 @@ const EyebrowVentTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null - return ( - - - - + <> + { + setPreviewPos(null) + setPreviewSurfaceQuat(null) + }} + size={[1.2, 0.4, 0.5]} + /> + {activeBuildingId && previewPos && previewSurfaceQuat && ( + + + + + + - - + )} + ) } diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index 5ca806aa..2d482f5b 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -14,6 +14,7 @@ import { } from '@pascal-app/core' import { CursorSphere, + consumePlacementDragRelease, markToolCancelConsumed, snapFenceDraftPoint, triggerSFX, @@ -227,6 +228,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { } const onGridClick = (event: GridEvent) => { + if (wasCommitted) return if (Date.now() - activatedAtRef.current < 150) { event.nativeEvent?.stopPropagation?.() return @@ -264,6 +266,12 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { event.nativeEvent?.stopPropagation?.() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + activatedAtRef.current = 0 + onGridClick({ nativeEvent: event } as unknown as GridEvent) + } + const onCancel = () => { restoreOriginal() useViewer.getState().setSelection({ selectedIds: [fenceId] }) @@ -279,6 +287,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { if (!wasCommitted) { @@ -303,6 +312,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) } }, [exitMoveMode, node]) diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 17de5cd1..53b1a8b6 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -466,7 +466,8 @@ export const FenceTool: React.FC = () => { } // Align the drafted point onto another object's nearest real anchor and - // publish the guide. Alt bypasses. Returns the (possibly snapped) point. + // publish the guide. Alt bypasses alignment; Shift bypasses all guided + // snapping. Returns the possibly snapped point. const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => { if (bypass || alignmentCandidates.length === 0) { useAlignmentGuides.getState().clear() diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx index 32780993..d47b2702 100644 --- a/packages/nodes/src/gutter/move-tool.tsx +++ b/packages/nodes/src/gutter/move-tool.tsx @@ -10,7 +10,12 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' import { createRelativeRoofDrag } from '../shared/relative-roof-drag' import { type EaveSnap, resolveEaveSnap } from './eave-snap' @@ -71,8 +76,15 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { let lastSnap: [number, number] | null = null let lastTarget: GutterDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag(original) + const clearTarget = () => { + lastTarget = null + lastSnap = null + setTarget(null) + } + const resolveTarget = (event: RoofEvent): GutterDragTarget | null => { const target = roofDrag.resolve(event) if (!target) return null @@ -85,7 +97,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { const updatePreview = (event: RoofEvent) => { const roof = event.node as RoofNode const target = resolveTarget(event) - if (!target) return + if (!target) { + clearTarget() + return + } lastTarget = target // Same snap math as the placement tool — picking-up and putting- @@ -120,8 +135,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { } const onRoofClick = (event: RoofEvent) => { + if (committed) return const target = lastTarget ?? resolveTarget(event) if (!target) return + committed = true const targetSegmentId = target.segment.id as AnyNodeId const { snap } = target const st = useScene.getState() @@ -201,16 +218,29 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { exitMoveMode() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (!lastTarget) return + onRoofClick({ + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as RoofEvent) + } + emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', clearTarget) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', clearTarget) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx index 1258519c..6fd43d2d 100644 --- a/packages/nodes/src/gutter/tool.tsx +++ b/packages/nodes/src/gutter/tool.tsx @@ -11,6 +11,7 @@ import { import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { gutterDefinition } from './definition' import { type EaveSnap, resolveEaveSnap } from './eave-snap' @@ -142,19 +143,26 @@ const GutterTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !target) return null - return ( - - - - + <> + setTarget(null)} + size={[2, 0.2, 0.25]} + /> + {activeBuildingId && target && ( + + + + + + - - + )} + ) } diff --git a/packages/nodes/src/ridge-vent/move-tool.tsx b/packages/nodes/src/ridge-vent/move-tool.tsx index 62752d6f..082ef90a 100644 --- a/packages/nodes/src/ridge-vent/move-tool.tsx +++ b/packages/nodes/src/ridge-vent/move-tool.tsx @@ -9,7 +9,12 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' import { createRelativeRoofDrag, @@ -60,8 +65,15 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { let lastSnap: [number, number] | null = null let lastTarget: RidgeVentDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag(original) + const clearTarget = () => { + lastTarget = null + lastSnap = null + setPreviewPos(null) + } + const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => { const target = roofDrag.resolve(event) if (!target) return null @@ -75,7 +87,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { const updatePreview = (event: RoofEvent) => { const target = resolveTarget(event) - if (!target) return + if (!target) { + clearTarget() + return + } lastTarget = target const sx = Math.round(target.localX * 20) / 20 @@ -100,8 +115,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { } const onRoofClick = (event: RoofEvent) => { + if (committed) return const target = lastTarget ?? resolveTarget(event) if (!target) return + committed = true const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() @@ -180,16 +197,29 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { exitMoveMode() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (!lastTarget) return + onRoofClick({ + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as RoofEvent) + } + emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', clearTarget) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', clearTarget) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true diff --git a/packages/nodes/src/ridge-vent/tool.tsx b/packages/nodes/src/ridge-vent/tool.tsx index 9a1a08eb..f0e82fed 100644 --- a/packages/nodes/src/ridge-vent/tool.tsx +++ b/packages/nodes/src/ridge-vent/tool.tsx @@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { resolveRidgeSnap } from '../shared/ridge-snap' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { ridgeVentDefinition } from './definition' import RidgeVentPreview from './preview' @@ -135,14 +136,30 @@ const RidgeVentTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !previewPos) return null - return ( - - - - - + <> + { + const hit = resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + return !!hit && !!resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) + }} + onInvalidTarget={() => setPreviewPos(null)} + size={[2, 0.15, 0.35]} + /> + {activeBuildingId && previewPos && ( + + + + + + )} + ) } diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index d3c762d6..521c8d9a 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -20,6 +20,7 @@ import { import { CursorSphere, commitFreshPlacementSubtree, + consumePlacementDragRelease, DragBoundingBox, getFloorStackPreviewPosition, resolvePlanarCursorPosition, @@ -359,6 +360,7 @@ export const MoveRoofTool: React.FC<{ } const onGridClick = (event: GridEvent) => { + if (wasCommitted) return if (!hasMoved) return const [localX, , localZ] = lastLocalPosition @@ -394,6 +396,11 @@ export const MoveRoofTool: React.FC<{ event.nativeEvent?.stopPropagation?.() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + onGridClick({ nativeEvent: event } as unknown as GridEvent) + } + const onCancel = () => { wasCancelled = true useLiveTransforms.getState().clear(movingNode.id) @@ -454,6 +461,7 @@ export const MoveRoofTool: React.FC<{ emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { // Restore segment wrapper visibility (React will re-sync on next render) @@ -485,6 +493,7 @@ export const MoveRoofTool: React.FC<{ emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('pointerup', onPlacementDragPointerUp) } }, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement]) diff --git a/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx b/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx new file mode 100644 index 00000000..1c7d9576 --- /dev/null +++ b/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx @@ -0,0 +1,111 @@ +'use client' + +import { + type AnyNodeId, + emitter, + type GridEvent, + type GutterEvent, + type RoofEvent, + sceneRegistry, +} from '@pascal-app/core' +import { DragBoundingBox } from '@pascal-app/editor' +import { useEffect, useRef, useState } from 'react' +import { Vector3 } from 'three' + +const INVALID_PREVIEW_COLOR = 0xef_44_44 +type ValidTarget = 'roof' | 'gutter' + +export function RoofAttachmentFallbackPreview({ + activeBuildingId, + isValidRoofTarget, + lift = 0, + onInvalidTarget, + size, + validTarget = 'roof', +}: { + activeBuildingId: string | null | undefined + isValidRoofTarget?: (event: RoofEvent) => boolean + lift?: number + onInvalidTarget?: () => void + size: [number, number, number] + validTarget?: ValidTarget +}) { + const [position, setPosition] = useState<[number, number, number] | null>(null) + const lastValidTargetEventRef = useRef(null) + const localPointRef = useRef(new Vector3()) + const isValidRoofTargetRef = useRef(isValidRoofTarget) + const onInvalidTargetRef = useRef(onInvalidTarget) + isValidRoofTargetRef.current = isValidRoofTarget + onInvalidTargetRef.current = onInvalidTarget + + useEffect(() => { + if (!activeBuildingId) { + setPosition(null) + lastValidTargetEventRef.current = null + return + } + + const trackValidHit = (nativeEvent: unknown) => { + lastValidTargetEventRef.current = nativeEvent + setPosition(null) + } + const showInvalidAt = (x: number, y: number, z: number) => { + setPosition([x, y + lift, z]) + onInvalidTargetRef.current?.() + } + const showInvalidAtWorld = (event: RoofEvent) => { + const point = localPointRef.current.set(...event.position) + const building = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId) + if (building) { + building.updateWorldMatrix(true, false) + building.worldToLocal(point) + } + showInvalidAt(point.x, 0, point.z) + } + const onRoofHit = (event: RoofEvent) => { + if (isValidRoofTargetRef.current?.(event) === false) { + showInvalidAtWorld(event) + return + } + trackValidHit(event.nativeEvent) + } + const onGutterHit = (event: GutterEvent) => trackValidHit(event.nativeEvent) + + const onGridMove = (event: GridEvent) => { + if (event.nativeEvent === lastValidTargetEventRef.current) return + const [x, y, z] = event.localPosition + showInvalidAt(x, y, z) + } + + if (validTarget === 'roof') { + emitter.on('roof:enter', onRoofHit) + emitter.on('roof:move', onRoofHit) + } else { + emitter.on('gutter:enter', onGutterHit) + emitter.on('gutter:move', onGutterHit) + } + emitter.on('grid:move', onGridMove) + + return () => { + if (validTarget === 'roof') { + emitter.off('roof:enter', onRoofHit) + emitter.off('roof:move', onRoofHit) + } else { + emitter.off('gutter:enter', onGutterHit) + emitter.off('gutter:move', onGutterHit) + } + emitter.off('grid:move', onGridMove) + } + }, [activeBuildingId, lift, validTarget]) + + if (!(activeBuildingId && position)) return null + + return ( + + ) +} diff --git a/packages/nodes/src/shared/roof-wall-opening-placement.ts b/packages/nodes/src/shared/roof-wall-opening-placement.ts index 603909f8..48a9170d 100644 --- a/packages/nodes/src/shared/roof-wall-opening-placement.ts +++ b/packages/nodes/src/shared/roof-wall-opening-placement.ts @@ -88,7 +88,10 @@ const cursorPoint = new Vector3() export function worldToSelectedBuildingLocal(point: Vector3): [number, number, number] { const buildingId = useViewer.getState().selection.buildingId const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined - if (buildingObj) buildingObj.worldToLocal(point) + if (buildingObj) { + buildingObj.updateWorldMatrix(true, false) + buildingObj.worldToLocal(point) + } return [point.x, point.y, point.z] } diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index ff0d0bc3..bcb97f04 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -258,6 +258,7 @@ export const shelfDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place shelf' }, + { key: 'Shift', label: 'Free place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/skylight/move-tool.tsx b/packages/nodes/src/skylight/move-tool.tsx index 2255ec08..d5fc859f 100644 --- a/packages/nodes/src/skylight/move-tool.tsx +++ b/packages/nodes/src/skylight/move-tool.tsx @@ -10,7 +10,12 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' import { @@ -65,8 +70,14 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { let lastSnapX = 0 let lastSnapZ = 0 let lastTarget: RelativeRoofDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag(original) + const clearTarget = () => { + lastTarget = null + setHasHit(false) + } + // Resolve which segment the cursor is over, then derive the same // preview transform stack the placement tool uses (`skylight/tool.tsx`): // analytical surface normal in segment-local frame → outer yaw = @@ -77,7 +88,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { const roof = event.node as RoofNode const target = roofDrag.resolve(event) if (!target) { - setHasHit(false) + clearTarget() return false } lastTarget = target @@ -113,10 +124,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { } const onRoofClick = (event: RoofEvent) => { + if (committed) return const st = useScene.getState() const target = lastTarget ?? roofDrag.resolve(event) if (!target) return + committed = true const targetSegmentId = target.segment.id as AnyNodeId const finalRotation = original.rotation @@ -206,16 +219,29 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { exitMoveMode() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (!lastTarget) return + onRoofClick({ + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as RoofEvent) + } + emitter.on('roof:move', onRoofMove) emitter.on('roof:enter', onRoofEnter) emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', clearTarget) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('roof:move', onRoofMove) emitter.off('roof:enter', onRoofEnter) emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', clearTarget) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true diff --git a/packages/nodes/src/skylight/tool.tsx b/packages/nodes/src/skylight/tool.tsx index ffc442c8..73d3933a 100644 --- a/packages/nodes/src/skylight/tool.tsx +++ b/packages/nodes/src/skylight/tool.tsx @@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { skylightDefinition } from './definition' @@ -109,16 +110,26 @@ const SkylightTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null - return ( - - - - + <> + { + setPreviewPos(null) + setPreviewSurfaceQuat(null) + }} + size={[1.2, 0.2, 1]} + /> + {activeBuildingId && previewPos && previewSurfaceQuat && ( + + + + + + - - + )} + ) } diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index ecb2ff65..4b63d070 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -201,6 +201,7 @@ export const slabDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Trace slab outline' }, { key: 'Enter', label: 'Finish slab' }, + { key: 'Shift', label: 'Free outline' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index df684b54..9b2017e1 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -16,6 +16,7 @@ import { } from '@pascal-app/core' import { CursorSphere, + consumePlacementDragRelease, getSegmentGridStep, markToolCancelConsumed, resolveAlignmentForActiveBuilding, @@ -214,6 +215,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { } const onGridClick = (event: GridEvent) => { + if (wasCommitted) return if (isFloorplanSourcedEvent(event)) return if (Date.now() - activatedAtRef.current < 150) { event.nativeEvent?.stopPropagation?.() @@ -245,6 +247,12 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { event.nativeEvent?.stopPropagation?.() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + activatedAtRef.current = 0 + onGridClick({ nativeEvent: event } as unknown as GridEvent) + } + const onCancel = () => { // No scene state to roll back — we never wrote anything. Just // restore the mesh visual. @@ -258,6 +266,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { useAlignmentGuides.getState().clear() @@ -269,6 +278,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) } }, [exitMoveMode, node.id, node.parentId]) diff --git a/packages/nodes/src/solar-panel/move-tool.tsx b/packages/nodes/src/solar-panel/move-tool.tsx index 402a525e..11cf634b 100644 --- a/packages/nodes/src/solar-panel/move-tool.tsx +++ b/packages/nodes/src/solar-panel/move-tool.tsx @@ -9,7 +9,13 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + EDITOR_LAYER, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { @@ -91,11 +97,20 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { let lastSnapX = 0 let lastSnapZ = 0 let lastTarget: RelativeRoofDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag(original) + const clearTarget = () => { + lastTarget = null + setHasHit(false) + } + const updateGhost = (event: RoofEvent) => { const target = roofDrag.resolve(event) - if (!target) return + if (!target) { + clearTarget() + return + } lastTarget = target const sx = Math.round(target.localX * 20) / 20 @@ -127,10 +142,12 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { } const onRoofClick = (event: RoofEvent) => { + if (committed) return const st = useScene.getState() const target = lastTarget ?? roofDrag.resolve(event) if (!target) return + committed = true const targetSegmentId = target.segment.id as AnyNodeId @@ -227,16 +244,29 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { exitMoveMode() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (!lastTarget) return + onRoofClick({ + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as RoofEvent) + } + emitter.on('roof:move', updateGhost) emitter.on('roof:enter', updateGhost) emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', clearTarget) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('roof:move', updateGhost) emitter.off('roof:enter', updateGhost) emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', clearTarget) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true diff --git a/packages/nodes/src/solar-panel/tool.tsx b/packages/nodes/src/solar-panel/tool.tsx index 1fd468cc..7af8aeab 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { solarPanelDefinition } from './definition' @@ -130,16 +131,26 @@ const SolarPanelTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null - return ( - - - - + <> + { + setPreviewPos(null) + setPreviewSurfaceQuat(null) + }} + size={[1.8, 0.2, 1.2]} + /> + {activeBuildingId && previewPos && previewSurfaceQuat && ( + + + + + + - - + )} + ) } diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index f76ecdc8..fde67442 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -100,6 +100,7 @@ export const spawnDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place spawn point' }, + { key: 'Shift', label: 'Free place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/turbine-vent/move-tool.tsx b/packages/nodes/src/turbine-vent/move-tool.tsx index d0f159d9..c7416876 100644 --- a/packages/nodes/src/turbine-vent/move-tool.tsx +++ b/packages/nodes/src/turbine-vent/move-tool.tsx @@ -9,7 +9,12 @@ import { type TurbineVentNode, useScene, } from '@pascal-app/core' -import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' import { @@ -58,11 +63,22 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) let lastSnap: [number, number] | null = null let lastTarget: RelativeRoofDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag(original) + const clearTarget = () => { + lastTarget = null + lastSnap = null + setPreviewPos(null) + setPreviewSurfaceQuat(null) + } + const updatePreview = (event: RoofEvent) => { const target = roofDrag.resolve(event) - if (!target) return + if (!target) { + clearTarget() + return + } lastTarget = target const sx = Math.round(target.localX * 20) / 20 @@ -89,8 +105,10 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) } const onRoofClick = (event: RoofEvent) => { + if (committed) return const target = lastTarget ?? roofDrag.resolve(event) if (!target) return + committed = true const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() @@ -169,16 +187,29 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) exitMoveMode() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (!lastTarget) return + onRoofClick({ + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as RoofEvent) + } + emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', clearTarget) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', clearTarget) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true diff --git a/packages/nodes/src/turbine-vent/tool.tsx b/packages/nodes/src/turbine-vent/tool.tsx index 4a7c2bd2..8ba0b37d 100644 --- a/packages/nodes/src/turbine-vent/tool.tsx +++ b/packages/nodes/src/turbine-vent/tool.tsx @@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' +import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' import { turbineVentDefinition } from './definition' @@ -117,16 +118,26 @@ const TurbineVentTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null - return ( - - - - + <> + { + setPreviewPos(null) + setPreviewSurfaceQuat(null) + }} + size={[0.5, 0.8, 0.5]} + /> + {activeBuildingId && previewPos && previewSurfaceQuat && ( + + + + + + - - + )} + ) } diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 25b02bda..ffa2b8d2 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -508,9 +508,13 @@ export const WallTool: React.FC = () => { } // Align the drafted point onto another object's nearest real anchor and - // publish the guide. Alt bypasses. Returns the (possibly snapped) point. - const alignPoint = (point: WallPlanPoint, bypass: boolean): WallPlanPoint => { - if (bypass || alignmentCandidates.length === 0) { + // publish the guide. Alt bypasses alignment; Shift bypasses all guided + // snapping. Returns the possibly snapped point. + const alignPoint = ( + point: WallPlanPoint, + options: { applySnap?: boolean; bypass?: boolean }, + ): WallPlanPoint => { + if (options.bypass || alignmentCandidates.length === 0) { useAlignmentGuides.getState().clear() return point } @@ -520,7 +524,9 @@ export const WallTool: React.FC = () => { threshold: ALIGNMENT_THRESHOLD_M, }) useAlignmentGuides.getState().set(ar.guides) - return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point + return ar.snap && options.applySnap !== false + ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] + : point } const stopDrafting = () => { @@ -554,7 +560,10 @@ export const WallTool: React.FC = () => { bypassSnap, magnetic: !bypassSnap && useEditor.getState().magneticSnap, }) - gridPosition = alignPoint(snapResult.point, bypassAlign || angleLocked) + gridPosition = alignPoint(snapResult.point, { + applySnap: !angleLocked, + bypass: bypassAlign, + }) // Stand the magnetic beacon at the endpoint when it locked onto an // existing wall corner / wall point; clear it for plain grid/angle moves. useWallSnapIndicator @@ -635,7 +644,7 @@ export const WallTool: React.FC = () => { bypassSnap, magnetic: !bypassSnap && useEditor.getState().magneticSnap, }).point, - bypassAlign, + { bypass: bypassAlign }, ) gridPosition = snappedStart startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) @@ -666,7 +675,10 @@ export const WallTool: React.FC = () => { bypassSnap, magnetic: !bypassSnap && useEditor.getState().magneticSnap, }).point, - bypassAlign || angleLocked, + { + applySnap: !angleLocked, + bypass: bypassAlign, + }, ) const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 1ef18efb..96f8b11c 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -197,6 +197,7 @@ export const windowDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Place window on wall' }, + { key: 'Shift', label: 'Free place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 352c6e90..eb6076b9 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -15,6 +15,7 @@ import { import { calculateCursorRotation, calculateItemRotation, + consumePlacementDragRelease, EDITOR_LAYER, getSideFromNormal, isValidWallSideFace, @@ -99,6 +100,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } let currentHostId: string | null = movingWindowNode.parentId + let committed = false let dragAnchor: { wallId: string rawX: number @@ -117,6 +119,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode valid: boolean event: WallEvent } | null = null + let lastRoofEvent: RoofEvent | null = null const markHostDirty = (hostId: string | null) => { if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId) @@ -285,29 +288,44 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onWallEnter = (event: WallEvent) => { const target = resolveMoveTarget(event) - if (!target) return + if (!target) { + onWallLeave() + return + } lastTarget = target + lastRoofEvent = null applyPreview(target) event.stopPropagation() } const onWallMove = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) return + if (!isValidWallSideFace(event.normal)) { + onWallLeave() + return + } if (isCurvedWall(event.node)) { - hideCursor() + onWallLeave() return } // Only interact with walls on the current level - if (event.node.parentId !== getLevelId()) return + if (event.node.parentId !== getLevelId()) { + onWallLeave() + return + } const target = resolveMoveTarget(event) - if (!target) return + if (!target) { + onWallLeave() + return + } lastTarget = target + lastRoofEvent = null applyPreview(target) event.stopPropagation() } const onWallClick = (event: WallEvent) => { + if (committed) return if (!isValidWallSideFace(event.normal)) return if (isCurvedWall(event.node)) return // Only interact with walls on the current level @@ -315,6 +333,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) if (!target?.valid) return + committed = true let placedId: string @@ -387,6 +406,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useLiveTransforms.getState().clear(movingWindowNode.id) dragAnchor = null lastTarget = null + lastRoofEvent = null if (isNew) return // No original to restore for duplicates // Move mode: restore to original position while off-wall if (currentHostId && currentHostId !== original.parentId) { @@ -430,10 +450,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onRoofHover = (event: RoofEvent) => { const target = resolveRoofMoveTarget(event) - if (!target) return + if (!target) { + onRoofLeave() + return + } // Wall-frame drag anchor / live transform don't apply on a roof face. dragAnchor = null lastTarget = null + lastRoofEvent = event useLiveTransforms.getState().clear(movingWindowNode.id) if (currentHostId !== target.segment.id) { useScene.getState().updateNode(movingWindowNode.id, { @@ -459,8 +483,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const onRoofClick = (event: RoofEvent) => { + if (committed) return const target = resolveRoofMoveTarget(event) if (!target?.valid) return + committed = true const segmentId = target.segment.id let placedId: string @@ -531,6 +557,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useLiveTransforms.getState().clear(movingWindowNode.id) dragAnchor = null lastTarget = null + lastRoofEvent = null if (isNew) return if (currentHostId && currentHostId !== original.parentId) { markHostDirty(currentHostId) @@ -571,6 +598,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode exitMoveMode() } + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (lastTarget) { + onWallClick(lastTarget.event) + return + } + if (lastRoofEvent) onRoofClick(lastRoofEvent) + } + emitter.on('wall:enter', onWallEnter) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) @@ -580,6 +616,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { // Safety cleanup: if still transient on unmount (e.g. phase switch mid-move) @@ -617,6 +654,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) } }, [movingWindowNode, exitMoveMode]) diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index c627f7a2..26552ebc 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, collectAlignmentAnchors, emitter, + type GridEvent, isCurvedWall, type RoofEvent, type RoofNode, @@ -23,12 +24,13 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' -import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' +import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, resolveRoofWallOpeningTarget, + worldToSelectedBuildingLocal, } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -41,6 +43,11 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) +const FALLBACK_WIDTH = 1.5 +const FALLBACK_HEIGHT = 1.5 +const FALLBACK_SILL_LIFT = 0.45 +const roofFallbackPoint = new Vector3() + /** * Window tool — places WindowNodes on walls and on roof-segment wall * faces (the generated base walls under a roof, including coplanar gable @@ -103,17 +110,48 @@ const WindowTool: React.FC = () => { edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } + const showFallbackCursor = (event: GridEvent) => { + if (draftRef.current) return + const [x, y, z] = event.localPosition + updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) + useAlignmentGuides.getState().clear() + } + + const showRoofFallbackCursor = (event: RoofEvent) => { + const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) + updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) + useAlignmentGuides.getState().clear() + } + + const showWallFallbackCursor = (event: WallEvent) => { + const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) + updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) + useAlignmentGuides.getState().clear() + } + const onWallEnter = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) return + if (!isValidWallSideFace(event.normal)) { + destroyDraft() + showWallFallbackCursor(event) + return + } if (isCurvedWall(event.node)) { destroyDraft() - hideCursor() + showWallFallbackCursor(event) return } const levelId = getLevelId() - if (!levelId) return + if (!levelId) { + destroyDraft() + showWallFallbackCursor(event) + return + } // Only interact with walls on the current level - if (event.node.parentId !== levelId) return + if (event.node.parentId !== levelId) { + destroyDraft() + showWallFallbackCursor(event) + return + } destroyDraft() @@ -167,14 +205,22 @@ const WindowTool: React.FC = () => { } const onWallMove = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) return + if (!isValidWallSideFace(event.normal)) { + destroyDraft() + showWallFallbackCursor(event) + return + } if (isCurvedWall(event.node)) { destroyDraft() - hideCursor() + showWallFallbackCursor(event) return } // Only interact with walls on the current level - if (event.node.parentId !== getLevelId()) return + if (event.node.parentId !== getLevelId()) { + destroyDraft() + showWallFallbackCursor(event) + return + } const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) @@ -395,10 +441,8 @@ const WindowTool: React.FC = () => { if (!target) { // On the roof but not over a placeable wall face (slope, soffit, // or a face the window cannot fit on). - if (draftRef.current?.roofSegmentId) { - destroyDraft() - hideCursor() - } + destroyDraft() + showRoofFallbackCursor(event) return } const { segment, face, position } = target @@ -499,6 +543,7 @@ const WindowTool: React.FC = () => { emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) + emitter.on('grid:move', showFallbackCursor) emitter.on('tool:cancel', onCancel) return () => { @@ -514,12 +559,13 @@ const WindowTool: React.FC = () => { emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) + emitter.off('grid:move', showFallbackCursor) emitter.off('tool:cancel', onCancel) } }, []) - // Cursor geometry: window outline rectangle (width × height × frameDepth) - const boxGeo = new BoxGeometry(1.5, 1.5, 0.07) + // Cursor geometry: window outline rectangle. + const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) const edgesGeo = new EdgesGeometry(boxGeo) boxGeo.dispose() diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index 52a14cee..efc42c72 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -12,7 +12,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) | | [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) | | [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 | -| [tools](tools.md) | Editor tools structure in `apps/editor` | +| [tools](tools.md) | Editor tools structure, manipulation constraints, and Shift bypass defaults | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic | | [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner | | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | diff --git a/wiki/architecture/node-definitions.md b/wiki/architecture/node-definitions.md index bde99b46..723f6e58 100644 --- a/wiki/architecture/node-definitions.md +++ b/wiki/architecture/node-definitions.md @@ -184,6 +184,28 @@ If the system also handles cascades, animations, or material updates, keep `def. - **Dispose on rebuild.** The generic system disposes the previous children's geometry + material before swapping. Custom systems that imperatively add children must dispose what they replace, or accept the GPU-memory cost. - **`def.renderer` overrides the generic renderer.** Once you set it, you own the mount — `` is not invoked. The generic geometry system still runs for the kind if `def.geometry` is set, so a custom renderer can register an empty group and let the system fill it. +## `toolHints` + +`toolHints?: ToolHint[]` is the registry-owned source for the floating helper shown while +a registered placement or draw tool is active. + +```ts +type ToolHint = { + key: string + label: string +} +``` + +Keep labels short and action-oriented. Prefer the default guided-building language: +snapping, angle increments, guides, and validation are active unless the user holds Shift +during the gesture. A `Shift` hint should describe the bypass in user terms, such as +`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 +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. + ## Pitfalls ### `` must not mutate `group.position` / `group.rotation` diff --git a/wiki/architecture/selection-managers.md b/wiki/architecture/selection-managers.md index ea4329a8..ad04a46e 100644 --- a/wiki/architecture/selection-managers.md +++ b/wiki/architecture/selection-managers.md @@ -73,6 +73,21 @@ phase: 'furnish' → selectable: furniture items only Clicking a node of a different phase auto-switches the phase. Double-click drills into a context level. +In Select mode, 3D and 2D canvas selection share the same modifier vocabulary: + +- `Ctrl/Meta + click` toggles the clicked object in `selectedIds`. +- `Shift + click` also toggles the clicked canvas object so users can multi-select from + either viewport. The scene graph keeps file-browser semantics: `Shift + click` selects + the visible range between the last selected row and the clicked row. +- `Ctrl/Meta + left-drag` on a selected movable object starts direct move from the canvas. +- `Ctrl/Meta + right-drag` on a selected rotatable object starts direct rotation from the + canvas. Rotation snaps to the default angle increment unless Shift is held during the + drag. + +The floating helper in `packages/editor/src/components/ui/helpers/helper-manager.tsx` +mirrors these rules from current selection state and held modifiers. Keep that helper and +the shortcut dialog in sync when changing selection gestures. + --- ## Rules diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 0d7a648e..d8c80f8b 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -66,6 +66,27 @@ export function MyTool() { - The offset must be cleared on tool unmount, cancel, *and* commit — both `mesh.position.set(0, 0, 0)` and `useLiveTransforms.clear(id)`. - The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system. - **No business logic in tools** — delegate geometry/constraint rules to core systems. +- **Guided manipulation is the default.** Placement, move, rotate, resize, endpoint drag, + and handle drag should behave as guided building mode: they help the user build quickly + with fewer mistakes through grid/object snapping, canonical angle increments, + alignment guides, and distance feedback. Holding Shift is the standard live bypass for + those constraints: while Shift is held, tools should commit the raw pointer/angle + proposal instead of applying sticky snap or angle corrections. Passive measurement + guides may remain visible only when they do not alter the proposal. If an interaction + cannot use Shift because of an established shortcut or topology rule, document the + opt-out in its manipulation policy and explain the replacement behavior. +- **Constraints and guides can be decoupled.** When a stronger constraint owns the + proposal, such as a wall segment's 15° angle lock, the tool may still publish passive + dashed alignment/proximity guides as long as it does not apply the guide snap delta. + Use this for chained wall segments: users keep the fast constrained draft, but still see + proximity feedback for later points. Shift remains the hard bypass for both correction + and guide feedback. +- **Help must mirror manipulation policy.** The shortcut dialog and floating helper panel + are part of the interaction contract. Static shortcut docs should describe guided + building as the default and Shift as the live bypass. Floating help should be contextual + when enough state exists: Select mode can derive direct move, direct rotate, + multi-select, and Shift-bypass tips from the selected nodes and active modifiers; active + tools can highlight the Shift bypass row while the modifier is held. - **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store. - **Clean up on unmount** — remove any pending/incomplete nodes *and* any live transforms/mesh offsets when the tool unmounts. - **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. `sceneRegistry` is exported from `@pascal-app/core` and is the allowed door into the Three.js graph for the narrow purposes above.