fix(editor): improve guided manipulation and snap affordances

This commit is contained in:
Aymeric Rabot
2026-06-11 23:59:15 -04:00
committed by GitHub
parent aab48e053f
commit 5411f5abc8
89 changed files with 2830 additions and 518 deletions
@@ -3,12 +3,14 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
createSceneApi,
type FloorplanAffordancePoint, type FloorplanAffordancePoint,
type FloorplanAffordanceSession, type FloorplanAffordanceSession,
type FloorplanGeometry, type FloorplanGeometry,
type FloorplanPalette, type FloorplanPalette,
type FloorplanPoint, type FloorplanPoint,
type GeometryContext, type GeometryContext,
isRegistryMovable,
kindsWithFloorplanScope, kindsWithFloorplanScope,
nodeRegistry, nodeRegistry,
pauseSceneHistory, pauseSceneHistory,
@@ -29,8 +31,15 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import {
canDirectRotateNode,
resolveDirectRotationDragDelta,
resolveDirectRotationPatch,
} from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
import { useFloorplanRender } from '../floorplan-render-context' 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_GLOW_STROKE_WIDTH_PX = 16
const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7 const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)' 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 * 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 selectedIds = useViewer((s) => s.selection.selectedIds)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId) const hoveredId = useViewer((s) => s.hoveredId)
const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
const setHoveredId = useViewer((s) => s.setHoveredId) const setHoveredId = useViewer((s) => s.setHoveredId)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
@@ -227,11 +240,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const [activeDragId, setActiveDragId] = useState<string | null>(null) const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null) const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
const handleSelect = useCallback( const applyEntrySelection = useCallback(
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => { (id: AnyNodeId, shouldToggle: boolean) => {
if (event.button !== 0) return const currentSelectedIds = useViewer.getState().selection.selectedIds
event.stopPropagation() setSelection({
setSelection({ selectedIds: [id] }) selectedIds: shouldToggle
? currentSelectedIds.includes(id)
? currentSelectedIds.filter((selectedId) => selectedId !== id)
: [...currentSelectedIds, id]
: [id],
})
// Setting selection re-renders the entry — the overlay pass mounts // Setting selection re-renders the entry — the overlay pass mounts
// (endpoint handles, etc.), reshuffling DOM under the cursor between // (endpoint handles, etc.), reshuffling DOM under the cursor between
// pointerdown and click. If the click target ends up on the SVG // 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 // selection we just set. Swallow the next click globally to break
// that race; the listener removes itself after firing (or after a // that race; the listener removes itself after firing (or after a
// safety timeout if no click follows). // safety timeout if no click follows).
const swallowClick = (ev: Event) => { swallowNextClick(200)
ev.stopPropagation()
ev.preventDefault()
window.removeEventListener('click', swallowClick, true)
}
window.addEventListener('click', swallowClick, true)
setTimeout(() => window.removeEventListener('click', swallowClick, true), 200)
}, },
[setSelection], [setSelection],
) )
const handleSelect = useCallback(
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
if (event.button !== 0) return
event.stopPropagation()
applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
},
[applyEntrySelection],
)
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => { const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
event.stopPropagation() event.stopPropagation()
}, []) }, [])
const startDirectMoveDrag = useCallback(
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): 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<SVGGElement>): 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<AnyNode> | null = null
const applyDelta = (pointerEvent: PointerEvent | ReactPointerEvent<SVGGElement>) => {
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<string, unknown>)
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<SVGGElement>) => {
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 // Build the geometry list. `viewState` flows into ctx so kinds can
// theme their output and conditionally emit selection chrome. // theme their output and conditionally emit selection chrome.
// //
@@ -729,7 +926,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
onPointerDown={ onPointerDown={
isOpeningPlacementActive || isMarqueeSelectionActive isOpeningPlacementActive || isMarqueeSelectionActive
? undefined ? undefined
: (e) => handleSelect(id, e) : (e) => handleEntryPointerDown(id, e)
} }
// Mirror the sidebar tree nodes' hover wiring — `useViewer. // Mirror the sidebar tree nodes' hover wiring — `useViewer.
// hoveredId` drives the highlight halo in 3D as well as the // hoveredId` drives the highlight halo in 3D as well as the
@@ -749,6 +946,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
> >
<InteractiveGeometry <InteractiveGeometry
activeDragId={activeDragId} activeDragId={activeDragId}
activeRotateNodeId={activeRotateNodeId}
geometry={geometry} geometry={geometry}
hatchPatternId={renderCtx?.hatchPatternId} hatchPatternId={renderCtx?.hatchPatternId}
hoveredHandleId={hoveredHandleId} hoveredHandleId={hoveredHandleId}
@@ -845,6 +1043,7 @@ function InteractiveGeometry({
hatchPatternId, hatchPatternId,
hoveredHandleId, hoveredHandleId,
activeDragId, activeDragId,
activeRotateNodeId,
isMarqueeSelectionActive, isMarqueeSelectionActive,
nodeId, nodeId,
sceneRotationDeg, sceneRotationDeg,
@@ -858,6 +1057,7 @@ function InteractiveGeometry({
hatchPatternId: string | undefined hatchPatternId: string | undefined
hoveredHandleId: string | null hoveredHandleId: string | null
activeDragId: string | null activeDragId: string | null
activeRotateNodeId: AnyNodeId | null
isMarqueeSelectionActive: boolean isMarqueeSelectionActive: boolean
nodeId: AnyNodeId nodeId: AnyNodeId
sceneRotationDeg: number sceneRotationDeg: number
@@ -1100,7 +1300,7 @@ function InteractiveGeometry({
// each end pointing tangentially in opposite directions — // each end pointing tangentially in opposite directions —
// "rotate either way." // "rotate either way."
const handleId = makeHandleId(nodeId, g.payload) const handleId = makeHandleId(nodeId, g.payload)
const isHovered = hoveredHandleId === handleId const isHovered = hoveredHandleId === handleId || activeRotateNodeId === nodeId
// Arc geometry (all values precomputed for a 72° arc of // Arc geometry (all values precomputed for a 72° arc of
// radius 0.13 — comparable footprint to `move-arrow`). // radius 0.13 — comparable footprint to `move-arrow`).
const R = 0.13 const R = 0.13
@@ -1966,3 +2166,15 @@ function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoin
// the Y axis on screen — same convention as `toSvgPlanPoint`). // the Y axis on screen — same convention as `toSvgPlanPoint`).
return [transformed.x, transformed.y] return [transformed.x, transformed.y]
} }
function swallowNextClick(timeoutMs = 0) {
const swallowClick = (event: MouseEvent) => {
event.stopPropagation()
event.preventDefault()
window.removeEventListener('click', swallowClick, true)
}
window.addEventListener('click', swallowClick, true)
setTimeout(() => {
window.removeEventListener('click', swallowClick, true)
}, timeoutMs)
}
@@ -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,
})
})
})
@@ -7,6 +7,7 @@ import type { WallPlanPoint } from '../tools/wall/wall-drafting'
type ModifierKeys = { type ModifierKeys = {
meta: boolean meta: boolean
ctrl: boolean ctrl: boolean
shift: boolean
} }
type ZoneHitEntry = { type ZoneHitEntry = {
@@ -85,7 +86,7 @@ export function resolveFloorplanBackgroundSelection({
handled: true, handled: true,
kind: 'select-elements', kind: 'select-elements',
selectedIds: selectedIds:
modifierKeys.meta || modifierKeys.ctrl modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
? currentSelectedIds.includes(hitId) ? currentSelectedIds.includes(hitId)
? currentSelectedIds.filter((selectedId) => selectedId !== hitId) ? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
: [...currentSelectedIds, hitId] : [...currentSelectedIds, hitId]
@@ -105,7 +106,7 @@ export function resolveFloorplanBackgroundSelection({
return { return {
handled: true, handled: true,
kind: 'clear-elements', kind: 'clear-elements',
preserveSelection: modifierKeys.meta || modifierKeys.ctrl, preserveSelection: modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift,
} }
} }
@@ -868,10 +868,15 @@ function getElevatorResizeSign(handle: ElevatorResizeHandle) {
return handle.endsWith('positive') ? 1 : -1 return handle.endsWith('positive') ? 1 : -1
} }
function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) { function getSelectionModifierKeys(event?: {
metaKey?: boolean
ctrlKey?: boolean
shiftKey?: boolean
}) {
return { return {
meta: Boolean(event?.metaKey), meta: Boolean(event?.metaKey),
ctrl: Boolean(event?.ctrlKey), 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. // that snap wins, so skip Figma alignment and stand the beacon there.
const lockedToWall = wallSnap.snap !== null const lockedToWall = wallSnap.snap !== null
let snappedPoint = wallSnapped let snappedPoint = wallSnapped
if (lockedToWall || wallAngleSnap) { if (lockedToWall) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} else { } else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, { snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap,
bypass: event.altKey || bypassSnap, bypass: event.altKey || bypassSnap,
}) })
} }
@@ -9233,8 +9239,11 @@ export function FloorplanPanel({
) )
const addFloorplanSelection = useCallback( 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 (shouldAppend) {
if (nextSelectedIds.length === 0) { if (nextSelectedIds.length === 0) {
@@ -9252,8 +9261,8 @@ export function FloorplanPanel({
) )
const toggleFloorplanSelection = useCallback( const toggleFloorplanSelection = useCallback(
(nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean }) => { (nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean }) => {
const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl) const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift)
if (shouldToggle) { if (shouldToggle) {
const currentSelectedIds = useViewer.getState().selection.selectedIds const currentSelectedIds = useViewer.getState().selection.selectedIds
@@ -9296,7 +9305,7 @@ export function FloorplanPanel({
const commitFloorplanScreenSelection = useCallback( const commitFloorplanScreenSelection = useCallback(
(nextSelectedIds: string[], event: PointerEvent) => { (nextSelectedIds: string[], event: PointerEvent) => {
const modifierKeys = getSelectionModifierKeys(event) const modifierKeys = getSelectionModifierKeys(event)
const shouldAppend = modifierKeys.meta || modifierKeys.ctrl const shouldAppend = modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
setSelectedReferenceId(null) setSelectedReferenceId(null)
@@ -9934,7 +9943,7 @@ export function FloorplanPanel({
if (hitId) { if (hitId) {
toggleFloorplanSelection(hitId, modifierKeys) toggleFloorplanSelection(hitId, modifierKeys)
} else if (!(modifierKeys.meta || modifierKeys.ctrl)) { } else if (!(modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift)) {
commitFloorplanSelection([]) commitFloorplanSelection([])
} }
} }
@@ -45,6 +45,7 @@ import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import { createEditorApi } from '../../lib/editor-api' import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { formatAngleRadians } from '../tools/shared/segment-angle' import { formatAngleRadians } from '../tools/shared/segment-angle'
@@ -167,6 +168,7 @@ function DimensionLabel({
export function NodeArrowHandles() { export function NodeArrowHandles() {
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
@@ -180,7 +182,7 @@ export function NodeArrowHandles() {
const curvingWall = useEditor((state) => state.curvingWall) const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence) 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) => const rawNode = useScene((state) =>
selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null, selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null,
) )
@@ -1028,6 +1030,8 @@ function ArcArrow({
// corner) render a two-headed curved arrow; everything else (stair // corner) render a two-headed curved arrow; everything else (stair
// sweep, etc.) keeps the chevron. // sweep, etc.) keeps the chevron.
const isRotateShape = descriptor.shape === 'rotate' 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 // '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 // 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 // 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 // arrow is hovered or dragging. Same recipe as the linear / radial
// decoration path. // decoration path.
const decoration = descriptor.decoration const decoration = descriptor.decoration
const showDecoration = Boolean(decoration) && (isHovered || isDragging) const showDecoration = Boolean(decoration) && (isHovered || isDragging || isDirectRotating)
const activate = useHandleDrag({ const activate = useHandleDrag({
kind: 'drag', 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 ( return (
<> <>
{showDecoration && decoration ? ( {showDecoration && decoration ? (
@@ -1172,7 +1169,7 @@ function ArcArrow({
<HandleArrow <HandleArrow
activeCursor={dragCursor} activeCursor={dragCursor}
cursor={hoverCursor} cursor={hoverCursor}
hover={isHovered} hover={isHovered || isDirectRotating}
onHoverChange={setIsHovered} onHoverChange={setIsHovered}
onPointerDown={activate} onPointerDown={activate}
placement={{ placement={{
@@ -4,8 +4,10 @@ import {
type BuildingNode, type BuildingNode,
type CeilingNode, type CeilingNode,
type ColumnNode, type ColumnNode,
createSceneApi,
emitter, emitter,
type FenceNode, type FenceNode,
type GridEvent,
getEffectiveRoofSurfaceMaterial, getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial, getEffectiveSegmentSurfaceMaterial,
getMaterialPresetByRef, getMaterialPresetByRef,
@@ -28,6 +30,7 @@ import {
type StairSegmentEvent, type StairSegmentEvent,
type StairSurfaceMaterialRole, type StairSurfaceMaterialRole,
sceneRegistry, sceneRegistry,
useLiveNodeOverrides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -42,6 +45,13 @@ import {
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three' import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
import {
canDirectMoveNode,
canDirectRotateNode,
resolveDirectRotationDragDelta,
resolveDirectRotationPatch,
} from '../../lib/direct-manipulation'
import { createEditorApi } from '../../lib/editor-api'
import { import {
type ActivePaintMaterial, type ActivePaintMaterial,
buildRoofSegmentSurfaceMaterialPatch, buildRoofSegmentSurfaceMaterialPatch,
@@ -51,13 +61,17 @@ import {
hasActivePaintMaterial, hasActivePaintMaterial,
resolveActivePaintMaterialFromSelection, resolveActivePaintMaterialFromSelection,
} from '../../lib/material-paint' } from '../../lib/material-paint'
import { emitDeleteSFX } from '../../lib/sfx-bus' import {
import useEditor, { resolveNodeSelectionTarget,
type MaterialTargetRole, resolveSelectedIdsForNodeClick,
type Phase, type SelectionModifierKeys,
type StructureLayer, selectionModifiersFromEvent,
} from './../../store/use-editor' } from '../../lib/selection-routing'
import { boxSelectHandled } from '../tools/select/box-select-state' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor, { type MaterialTargetRole } from './../../store/use-editor'
import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { swallowNextClick } from './node-arrow-handles'
const isNodeInCurrentLevel = (node: AnyNode): boolean => { const isNodeInCurrentLevel = (node: AnyNode): boolean => {
// Elevators are building-scoped, so they stay selectable across level filters. // Elevators are building-scoped, so they stay selectable across level filters.
@@ -86,11 +100,6 @@ type SelectableNodeType =
| 'window' | 'window'
| 'door' | 'door'
type ModifierKeys = {
meta: boolean
ctrl: boolean
}
type PaintPreviewCleanup = () => void type PaintPreviewCleanup = () => void
type PaintInteraction = { type PaintInteraction = {
@@ -103,14 +112,33 @@ type PaintInteraction = {
interface SelectionStrategy { interface SelectionStrategy {
types: SelectableNodeType[] types: SelectableNodeType[]
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void handleSelect: (
node: AnyNode,
nativeEvent?: any,
modifierKeys?: SelectionModifierKeys,
baseSelectedIds?: readonly string[],
) => void
handleDeselect: () => void handleDeselect: () => void
isValid: (node: AnyNode) => boolean isValid: (node: AnyNode) => boolean
} }
type SelectionTarget = { const DIRECT_DRAG_THRESHOLD_PX = 4
phase: Phase const DIRECT_ROTATE_EPSILON = 1e-6
structureLayer?: StructureLayer 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<PointerEvent, 'metaKey' | 'ctrlKey'>): 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 = ( export const resolveBuildingId = (
@@ -619,22 +647,17 @@ function disposeHighlightedMaterials(material: Material | Material[]) {
const computeNextIds = ( const computeNextIds = (
node: AnyNode, node: AnyNode,
selectedIds: string[], selectedIds: readonly string[],
event?: any, event?: any,
modifierKeys?: ModifierKeys, modifierKeys?: SelectionModifierKeys,
baseSelectedIds?: readonly string[],
): string[] => { ): string[] => {
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta return resolveSelectedIdsForNodeClick({
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl baseSelectedIds,
currentSelectedIds: selectedIds,
if (isMeta || isCtrl) { modifierKeys: selectionModifiersFromEvent(event, modifierKeys),
if (selectedIds.includes(node.id)) { nodeId: node.id,
return selectedIds.filter((id) => id !== node.id) })
}
return [...selectedIds, node.id]
}
// Not holding modifiers: select only this node
return [node.id]
} }
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = { const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
@@ -667,7 +690,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
'window', 'window',
'door', 'door',
], ],
handleSelect: (node, nativeEvent, modifierKeys) => { handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
const { selection, setSelection } = useViewer.getState() const { selection, setSelection } = useViewer.getState()
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes) const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
@@ -694,7 +717,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId. // Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
setSelection(updates) setSelection(updates)
} else { } else {
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys) updates.selectedIds = computeNextIds(
node,
selection.selectedIds,
nativeEvent,
modifierKeys,
baseSelectedIds,
)
setSelection(updates) setSelection(updates)
} }
}, },
@@ -746,7 +775,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
furnish: { furnish: {
types: ['item'], types: ['item'],
handleSelect: (node, nativeEvent, modifierKeys) => { handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
const { selection, setSelection } = useViewer.getState() const { selection, setSelection } = useViewer.getState()
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const nodeLevelId = resolveLevelId(node, nodes) const nodeLevelId = resolveLevelId(node, nodes)
@@ -760,7 +789,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
updates.buildingId = buildingId updates.buildingId = buildingId
} }
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys) updates.selectedIds = computeNextIds(
node,
selection.selectedIds,
nativeEvent,
modifierKeys,
baseSelectedIds,
)
setSelection(updates) setSelection(updates)
}, },
handleDeselect: () => { handleDeselect: () => {
@@ -776,7 +811,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
// Registry-driven kinds with `category: 'furnish'` (shelf today, // Registry-driven kinds with `category: 'furnish'` (shelf today,
// future furniture kinds): selectable in furnish phase if their // future furniture kinds): selectable in furnish phase if their
// definition declares the `selectable` capability. Without this // 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. // would be rejected here — single-click selection broken.
const def = nodeRegistry.get(node.type) const def = nodeRegistry.get(node.type)
if (def && def.category === 'furnish' && def.capabilities.selectable) return true if (def && def.category === 'furnish' && def.capabilities.selectable) return true
@@ -785,76 +820,14 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
}, },
} }
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 = () => { export const SelectionManager = () => {
const phase = useEditor((s) => s.phase) const phase = useEditor((s) => s.phase)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode) const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
const modifierKeysRef = useRef<ModifierKeys>({ const modifierKeysRef = useRef<SelectionModifierKeys>({
meta: false, meta: false,
ctrl: false, ctrl: false,
shift: false,
}) })
const clickHandledRef = useRef(false) const clickHandledRef = useRef(false)
@@ -1230,16 +1203,19 @@ export const SelectionManager = () => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Meta') modifierKeysRef.current.meta = true if (event.key === 'Meta') modifierKeysRef.current.meta = true
if (event.key === 'Control') modifierKeysRef.current.ctrl = true if (event.key === 'Control') modifierKeysRef.current.ctrl = true
if (event.key === 'Shift') modifierKeysRef.current.shift = true
} }
const onKeyUp = (event: KeyboardEvent) => { const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Meta') modifierKeysRef.current.meta = false if (event.key === 'Meta') modifierKeysRef.current.meta = false
if (event.key === 'Control') modifierKeysRef.current.ctrl = false if (event.key === 'Control') modifierKeysRef.current.ctrl = false
if (event.key === 'Shift') modifierKeysRef.current.shift = false
} }
const clearModifiers = () => { const clearModifiers = () => {
modifierKeysRef.current.meta = false modifierKeysRef.current.meta = false
modifierKeysRef.current.ctrl = false modifierKeysRef.current.ctrl = false
modifierKeysRef.current.shift = false
} }
window.addEventListener('keydown', onKeyDown) 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<AnyNode> | 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<string, unknown>)
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(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return if (movingNode || curvingWall || curvingFence) return
@@ -1274,12 +1466,13 @@ export const SelectionManager = () => {
let currentPhase = useEditor.getState().phase let currentPhase = useEditor.getState().phase
let currentStructureLayer = useEditor.getState().structureLayer 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. // 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). // Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan).
if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') { if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') {
if (isNodeInCurrentLevel(node)) { if (isNodeInCurrentLevel(node)) {
const target = getSelectionTarget(node) const target = resolveNodeSelectionTarget(node)
if (target) { if (target) {
if (target.phase !== currentPhase) { if (target.phase !== currentPhase) {
useEditor.getState().setPhase(target.phase) useEditor.getState().setPhase(target.phase)
@@ -1330,7 +1523,12 @@ export const SelectionManager = () => {
useEditor.getState().setEditingHole(null) useEditor.getState().setEditingHole(null)
} }
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current) activeStrategy.handleSelect(
nodeToSelect,
event.nativeEvent,
modifierKeysRef.current,
selectedIdsBeforeRouting,
)
let nextMaterialTargetHandled = false let nextMaterialTargetHandled = false
@@ -1435,9 +1633,11 @@ export const SelectionManager = () => {
emitter.on(`${type}:click` as any, onClick as any) emitter.on(`${type}:click` as any, onClick as any)
}) })
const onGridClick = () => { const onGridClick = (event: GridEvent) => {
if (clickHandledRef.current) return if (clickHandledRef.current) return
if (boxSelectHandled) return if (boxSelectHandled) return
const nativeEvent = event.nativeEvent
if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return
const { phase, structureLayer } = useEditor.getState() const { phase, structureLayer } = useEditor.getState()
const activeStrategy = SELECTION_STRATEGIES[phase] const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect() if (activeStrategy) activeStrategy.handleDeselect()
@@ -1506,7 +1706,10 @@ export const SelectionManager = () => {
const currentPhase = useEditor.getState().phase 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 let forceSelect = false
if (node.type === 'building' || node.type === 'site') { if (node.type === 'building' || node.type === 'site') {
@@ -1515,36 +1718,15 @@ export const SelectionManager = () => {
} }
if (node.type === 'building') { if (node.type === 'building') {
targetPhase = 'structure' targetPhase = 'structure'
targetStructureLayer = 'elements'
} }
} else if ( } else {
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'
if (node.type === 'roof-segment' && currentPhase === 'structure') { if (node.type === 'roof-segment' && currentPhase === 'structure') {
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
} }
if (node.type === 'stair-segment' && currentPhase === 'structure') { if (node.type === 'stair-segment' && currentPhase === 'structure') {
forceSelect = true // allow double click to dive into stair-segment even if already in structure phase 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') { if (node.type === 'zone') {
@@ -1558,13 +1740,22 @@ export const SelectionManager = () => {
useEditor.getState().setPhase(targetPhase) useEditor.getState().setPhase(targetPhase)
} }
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') { if (
useEditor.getState().setStructureLayer('elements') targetPhase === 'structure' &&
targetStructureLayer &&
targetStructureLayer !== useEditor.getState().structureLayer
) {
useEditor.getState().setStructureLayer(targetStructureLayer)
} }
const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase] const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
if (strategy) { if (strategy) {
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current) strategy.handleSelect(
node,
event.nativeEvent,
modifierKeysRef.current,
selectedIdsBeforeRouting,
)
} }
} }
} }
@@ -5,6 +5,7 @@ import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useSegmentDraftChain from '../../store/use-segment-draft-chain' import useSegmentDraftChain from '../../store/use-segment-draft-chain'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-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 wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep)
const wallLocked = const wallLocked =
!bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]) !bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1])
const snappedPoint = let snappedPoint = wallSnapped
wallLocked || wallAngleSnap if (wallLocked) {
? wallSnapped useAlignmentGuides.getState().clear()
: alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey || bypassSnap }) } else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap,
bypass: event.altKey || bypassSnap,
})
}
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
@@ -15,6 +15,7 @@ import {
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { resolveElevatorSupportY } from '../../../lib/elevator-support' import { resolveElevatorSupportY } from '../../../lib/elevator-support'
import { consumePlacementDragRelease } from '../../../lib/placement-drag-release'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
@@ -160,6 +161,7 @@ export function MoveElevatorTool({
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current] const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
wasCommitted = true wasCommitted = true
@@ -189,6 +191,11 @@ export function MoveElevatorTool({
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => { const onCancel = () => {
wasCancelled = true wasCancelled = true
clearPreview() clearPreview()
@@ -231,6 +238,7 @@ export function MoveElevatorTool({
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
clearPreview() clearPreview()
@@ -247,6 +255,7 @@ export function MoveElevatorTool({
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
} }
}, [movingNode, exitMoveMode]) }, [movingNode, exitMoveMode])
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { stripTransient } from './placement-math' import { getDetachedAttachmentPreviewLift, stripTransient } from './placement-math'
describe('stripTransient', () => { describe('stripTransient', () => {
test('removes placement-only metadata flags before commit', () => { 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)
})
})
@@ -55,6 +55,12 @@ export function getGridAlignedDimensions(
return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)] 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. * Calculate cursor rotation in WORLD space from wall normal and orientation.
*/ */
@@ -144,6 +144,7 @@ export const floorStrategy = {
): CommitResult | null { ): CommitResult | null {
if (ctx.state.surface !== 'floor') return null if (ctx.state.surface !== 'floor') return null
if (!(ctx.levelId && ctx.draftItem)) return null if (!(ctx.levelId && ctx.draftItem)) return null
if (ctx.draftItem.asset.attachTo) return null
const pos: [number, number, number] = [ const pos: [number, number, number] = [
ctx.gridPosition.x, ctx.gridPosition.x,
@@ -52,7 +52,12 @@ import {
type PreviewBounds, type PreviewBounds,
updateLineGeometry, updateLineGeometry,
} from '../shared/placement-box-geometry' } from '../shared/placement-box-geometry'
import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math' import {
getDetachedAttachmentPreviewLift,
getGridAlignedDimensions,
snapToGrid,
snapUpToGridStep,
} from './placement-math'
import { import {
ceilingStrategy, ceilingStrategy,
checkCanPlace, checkCanPlace,
@@ -732,6 +737,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
previousGridPos = [...gridPos] previousGridPos = [...gridPos]
gridPosition.current.set(...gridPos) gridPosition.current.set(...gridPos)
const cursorPosition = getFloorVisualPosition(gridPos) const cursorPosition = getFloorVisualPosition(gridPos)
if (!draft && asset.attachTo) {
cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
}
cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2]) cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
// Floor items only rotate on Y; keep the preview box (and the live // Floor items only rotate on Y; keep the preview box (and the live
// transform the 2D floorplan mirrors) aligned with the draft's // transform the 2D floorplan mirrors) aligned with the draft's
@@ -27,6 +27,7 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { swallowNextClick } from '../../editor/node-arrow-handles'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { DragBoundingBox } from '../shared/drag-bounding-box' import { DragBoundingBox } from '../shared/drag-bounding-box'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' 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:move', onGridMove)
emitter.on('grid:click', commitAtCursor) 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 // 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 — // `${kind}:click` as a fixed union so the cast is safe at runtime —
// we're just routing them through the shared commit path. // we're just routing them through the shared commit path.
@@ -539,6 +555,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', commitAtCursor) emitter.off('grid:click', commitAtCursor)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
for (const kind of CLICK_TRIGGER_KINDS) { for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey const key = `${kind}:click` as ClickKey
emitter.off(key, commitAtCursor as never) emitter.off(key, commitAtCursor as never)
@@ -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 (
<div className="flex flex-wrap items-center gap-0.5">
{keys.map((key, index) => (
<div className="flex items-center gap-0.5" key={`${key}-${index}`}>
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">+</span> : null}
<ShortcutToken className="h-5 px-1.5 text-[10px]" value={key} />
</div>
))}
</div>
)
}
export function ContextualHelperPanel({ hints }: { hints: ContextualShortcutHint[] }) {
if (hints.length === 0) return null
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col gap-1.5 rounded-lg border border-border bg-background/95 px-3 py-2.5 shadow-lg backdrop-blur-md">
{hints.map((hint) => (
<div
className={cn(
'grid min-w-0 grid-cols-1 gap-1 rounded-md text-sm',
hint.active && '-mx-1 bg-primary/10 px-1.5 py-1 text-foreground',
)}
key={`${hint.keys.join('+')}:${hint.label}`}
>
<ShortcutSequence keys={hint.keys} />
<span className="min-w-0 text-muted-foreground text-xs leading-snug">{hint.label}</span>
</div>
))}
</div>
)
}
@@ -1,44 +1,118 @@
'use client' '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 { 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 useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper' import { BuildingHelper } from './building-helper'
import { ContextualHelperPanel } from './contextual-helper-panel'
import { ItemHelper } from './item-helper' import { ItemHelper } from './item-helper'
import { RegisteredToolHelper } from './registered-tool-helper' import { RegisteredToolHelper } from './registered-tool-helper'
import { RoofHelper } from './roof-helper' import { RoofHelper } from './roof-helper'
type ActiveModifierKeys = {
command: boolean
shift: boolean
}
function useActiveModifierKeys(): ActiveModifierKeys {
const [modifiers, setModifiers] = useState<ActiveModifierKeys>({
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() { export function HelperManager() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool) const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const selectedIds = useViewer((s) => s.selection.selectedIds)
const isMobile = useIsMobile() 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. // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null if (isMobile) return null
if (movingNode) { if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate /> if (movingNode.type === 'building') return <BuildingHelper showRotate />
return <ItemHelper showEsc /> return <ItemHelper shiftPressed={modifiers.shift} showEsc />
} }
if (mode === 'material-paint') { if (mode === 'material-paint') {
return null return null
} }
if (mode === 'select') {
return <ContextualHelperPanel hints={selectModeHints} />
}
// Registry-first: kinds with `def.toolHints` render through the generic // Registry-first: kinds with `def.toolHints` render through the generic
// `RegisteredToolHelper`. Today that covers ceiling / door / fence / // `RegisteredToolHelper`. Today that covers ceiling / door / fence /
// item / shelf / slab / spawn / wall / window. // item / shelf / slab / spawn / wall / window.
if (tool) { if (tool) {
const def = nodeRegistry.get(tool) const def = nodeRegistry.get(tool)
if (def?.toolHints && def.toolHints.length > 0) { if (def?.toolHints && def.toolHints.length > 0) {
return <RegisteredToolHelper hints={def.toolHints} /> return <RegisteredToolHelper hints={def.toolHints} shiftPressed={modifiers.shift} />
} }
} }
// Legacy fallback — only `roof` remains because it hasn't migrated to // Legacy fallback — only `roof` remains because it hasn't migrated to
// `def.tool` / `def.toolHints` yet (no Stage D port). When roof // `def.tool` / `def.toolHints` yet (no Stage D port). When roof
// migrates, this switch deletes outright. // migrates, this switch deletes outright.
if (tool === 'roof') return <RoofHelper /> if (tool === 'roof') return <RoofHelper shiftPressed={modifiers.shift} />
return null return null
} }
@@ -1,40 +1,24 @@
import { ShortcutToken } from '../primitives/shortcut-token' import { ContextualHelperPanel } from './contextual-helper-panel'
interface ItemHelperProps { interface ItemHelperProps {
showEsc?: boolean showEsc?: boolean
shiftPressed?: boolean
} }
export function ItemHelper({ showEsc }: ItemHelperProps) { export function ItemHelper({ showEsc, shiftPressed = false }: ItemHelperProps) {
return ( return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md"> <ContextualHelperPanel
<div className="flex items-center gap-2 text-sm"> hints={[
<ShortcutToken value="Left click" /> { keys: ['Left click'], label: 'Place item' },
<span className="text-muted-foreground">Place item</span> { keys: ['R'], label: 'Rotate counterclockwise' },
</div> { keys: ['T'], label: 'Rotate clockwise' },
<div className="flex items-center gap-2 text-sm"> {
<ShortcutToken value="R" /> keys: ['Shift'],
<span className="text-muted-foreground">Rotate counterclockwise</span> label: shiftPressed ? 'Guided constraints bypassed' : 'Free place',
</div> active: shiftPressed,
<div className="flex items-center gap-2 text-sm"> },
<ShortcutToken value="T" /> { keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
<span className="text-muted-foreground">Rotate clockwise</span> ]}
</div> />
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Shift" />
<span className="text-muted-foreground">Free place</span>
</div>
{showEsc && (
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
)}
{!showEsc && (
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Right click" />
<span className="text-muted-foreground">Cancel</span>
</div>
)}
</div>
) )
} }
@@ -1,5 +1,5 @@
import type { ToolHint } from '@pascal-app/core' 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 * 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 * Drops the need for per-kind helper files entirely — kinds declare
* their hints as static data in their `NodeDefinition`. * 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 if (hints.length === 0) return null
return ( return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md"> <ContextualHelperPanel
{hints.map((hint) => ( hints={hints.map((hint) => ({
<div className="flex items-center gap-2 text-sm" key={`${hint.key}:${hint.label}`}> keys: [hint.key],
<ShortcutToken value={hint.key} /> label:
<span className="text-muted-foreground">{hint.label}</span> shiftPressed && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label,
</div> active: shiftPressed && hint.key === 'Shift',
))} }))}
</div> />
) )
} }
@@ -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 ( return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md"> <ContextualHelperPanel
<div className="flex items-center gap-2 text-sm"> hints={[
<ShortcutToken value="Left click" /> { keys: ['Left click'], label: 'Set corner' },
<span className="text-muted-foreground">Set corner</span> {
</div> keys: ['Shift'],
<div className="flex items-center gap-2 text-sm"> label: shiftPressed ? 'Guided constraints bypassed' : 'Free corner',
<ShortcutToken value="Esc" /> active: shiftPressed,
<span className="text-muted-foreground">Cancel</span> },
</div> { keys: ['Esc'], label: 'Cancel' },
</div> ]}
/>
) )
} }
@@ -2,16 +2,11 @@
import type { AssetInput } from '@pascal-app/core' import type { AssetInput } from '@pascal-app/core'
import { resolveCdnUrl, useViewer } from '@pascal-app/viewer' import { resolveCdnUrl, useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useEffect } from 'react' import { useEffect } from 'react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from './../../../components/ui/primitives/tooltip'
import { triggerSFX } from './../../../lib/sfx-bus' import { triggerSFX } from './../../../lib/sfx-bus'
import { cn } from './../../../lib/utils' import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor' import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge'
import { CATALOG_ITEMS } from './catalog-items' import { CATALOG_ITEMS } from './catalog-items'
export function ItemCatalog({ export function ItemCatalog({
@@ -68,12 +63,6 @@ export function ItemCatalog({
} }
}, [categoryItems, selectedItem?.src, setSelectedItem]) }, [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) { if (filteredItems.length === 0 && emptyState) {
return <>{emptyState}</> return <>{emptyState}</>
} }
@@ -86,7 +75,7 @@ export function ItemCatalog({
{leadingTile} {leadingTile}
{filteredItems.map((item, index) => { {filteredItems.map((item, index) => {
const isSelected = selectedItem?.src === item?.src const isSelected = selectedItem?.src === item?.src
const attachmentIcon = getAttachmentIcon(item?.attachTo) const snapTarget = resolveAssetSnapTarget(item?.attachTo)
return ( return (
<button <button
className={cn( className={cn(
@@ -114,14 +103,8 @@ export function ItemCatalog({
loading="eager" loading="eager"
src={resolveCdnUrl(item.thumbnail) || ''} src={resolveCdnUrl(item.thumbnail) || ''}
/> />
{attachmentIcon && ( {snapTarget && (
<div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60"> <SnapTargetBadge className="absolute right-1 bottom-1" target={snapTarget} />
<img
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
className="h-4 w-4"
src={attachmentIcon}
/>
</div>
)} )}
</div> </div>
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground"> <span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
@@ -71,7 +71,32 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{ {
keys: ['Cmd/Ctrl', 'Left click'], keys: ['Cmd/Ctrl', 'Left click'],
action: 'Add or remove an object from multi-selection', action: 'Add or remove an object from multi-selection',
note: 'Works while in Select mode.', note: 'Works in Select mode on the 3D canvas, the 2D floor plan, and the scene graph.',
},
{
keys: ['Shift', 'Left click'],
action: 'Add or remove an object from canvas multi-selection',
note: 'In the scene graph, Shift-click selects the visible range like a file browser.',
},
],
},
{
title: 'Direct Manipulation',
shortcuts: [
{
keys: ['Cmd/Ctrl', 'Left click'],
action: 'Move the selected movable object under the cursor',
note: 'Drag in Select mode. Guided snapping and guides are enabled by default.',
},
{
keys: ['Cmd/Ctrl', 'Right click'],
action: 'Rotate the selected object under the cursor',
note: 'Drag left or right in Select mode. Rotation snaps to 15° increments by default.',
},
{
keys: ['Cmd/Ctrl', 'Shift', 'Right click'],
action: 'Rotate freely',
note: 'Hold Shift during the drag to bypass the 15° rotation increment.',
}, },
], ],
}, },
@@ -80,13 +105,13 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
shortcuts: [ shortcuts: [
{ {
keys: ['Shift'], keys: ['Shift'],
action: 'Draw at any angle, bypassing the default 15° angle snap', action: 'Bypass guided snapping and angle constraints',
note: 'Hold while drawing walls, fences, slabs, ceilings, and zones.', note: 'Hold during the active gesture. Passive guide or measurement feedback may stay visible.',
}, },
{ {
keys: ['Shift'], keys: ['Shift'],
action: 'Rotate freely, bypassing the default 15° rotation snap', action: 'Rotate freely, bypassing the default 15° rotation snap',
note: 'Hold while dragging a rotate handle.', note: 'Hold while dragging a rotate handle or direct-rotation gesture.',
}, },
], ],
}, },
@@ -162,7 +187,8 @@ export function KeyboardShortcutsDialog() {
<DialogHeader className="shrink-0 border-b px-6 py-4"> <DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle>Keyboard Shortcuts</DialogTitle> <DialogTitle>Keyboard Shortcuts</DialogTitle>
<DialogDescription> <DialogDescription>
Shortcuts are context-aware and depend on the current phase or tool. Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift
during an active gesture to build freely.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -2,6 +2,7 @@ import { type AnyNodeId, type ChimneyNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -51,6 +52,7 @@ export const ChimneyTreeNode = memo(function ChimneyTreeNode({
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
icon={ icon={
<SnapTargetIcon target="roof">
<Image <Image
alt="" alt=""
className="object-contain opacity-60" className="object-contain opacity-60"
@@ -58,6 +60,7 @@ export const ChimneyTreeNode = memo(function ChimneyTreeNode({
src="/icons/roof.png" src="/icons/roof.png"
width={14} width={14}
/> />
</SnapTargetIcon>
} }
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
@@ -1,9 +1,10 @@
'use client' 'use client'
import { type AnyNodeId, useScene } from '@pascal-app/core' import { type AnyNodeId, type DoorNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -22,6 +23,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({
}: DoorTreeNodeProps) { }: DoorTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false) const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
const node = useScene((s) => s.nodes[nodeId] as DoorNode | undefined)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId) const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
@@ -45,6 +47,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({
const handleStartEditing = useCallback(() => setIsEditing(true), []) const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), []) const handleStopEditing = useCallback(() => setIsEditing(false), [])
const snapTarget = resolveNodeSnapTarget(node) ?? 'wall'
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
@@ -53,7 +56,9 @@ export const DoorTreeNode = memo(function DoorTreeNode({
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
icon={ icon={
<SnapTargetIcon target={snapTarget}>
<Image alt="" className="object-contain" height={14} src="/icons/door.png" width={14} /> <Image alt="" className="object-contain" height={14} src="/icons/door.png" width={14} />
</SnapTargetIcon>
} }
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
@@ -2,6 +2,7 @@ import { type AnyNodeId, type DormerNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -57,6 +58,7 @@ export const DormerTreeNode = memo(function DormerTreeNode({
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
icon={ icon={
<SnapTargetIcon target="roof">
<Image <Image
alt="" alt=""
className="object-contain opacity-60" className="object-contain opacity-60"
@@ -64,6 +66,7 @@ export const DormerTreeNode = memo(function DormerTreeNode({
src="/icons/roof.png" src="/icons/roof.png"
width={14} width={14}
/> />
</SnapTargetIcon>
} }
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
@@ -2,6 +2,7 @@ import { type AnyNodeId, type GutterNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -51,6 +52,7 @@ export const GutterTreeNode = memo(function GutterTreeNode({
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
icon={ icon={
<SnapTargetIcon target="roof">
<Image <Image
alt="" alt=""
className="object-contain opacity-60" className="object-contain opacity-60"
@@ -58,6 +60,7 @@ export const GutterTreeNode = memo(function GutterTreeNode({
src="/icons/roof.png" src="/icons/roof.png"
width={14} width={14}
/> />
</SnapTargetIcon>
} }
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
@@ -3,9 +3,15 @@ import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { memo, useCallback, useEffect, useState } from 'react' import { memo, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor' import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node' import {
focusTreeNode,
handleTreeSelection,
routeTreeSelectionToNode,
TreeNode,
TreeNodeWrapper,
} from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
const CATEGORY_ICONS: Record<string, string> = { const CATEGORY_ICONS: Record<string, string> = {
@@ -35,7 +41,8 @@ export const ItemTreeNode = memo(function ItemTreeNode({
const children = useScene( const children = useScene(
useShallow((s) => (s.nodes[nodeId] as ItemNode | undefined)?.children ?? []), useShallow((s) => (s.nodes[nodeId] as ItemNode | undefined)?.children ?? []),
) )
const asset = useScene((s) => (s.nodes[nodeId] as ItemNode | undefined)?.asset) const node = useScene((s) => s.nodes[nodeId] as ItemNode | undefined)
const asset = node?.asset
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId) const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
@@ -63,17 +70,15 @@ export const ItemTreeNode = memo(function ItemTreeNode({
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const handled = handleTreeSelection( handleTreeSelection(
e, e,
nodeId, nodeId,
useViewer.getState().selection.selectedIds, useViewer.getState().selection.selectedIds,
setSelection, setSelection,
) )
if (!handled && useEditor.getState().phase === 'structure') { routeTreeSelectionToNode(node)
useEditor.getState().setPhase('furnish')
}
}, },
[nodeId, setSelection], [node, nodeId, setSelection],
) )
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId]) const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
@@ -84,6 +89,7 @@ export const ItemTreeNode = memo(function ItemTreeNode({
const handleStopEditing = useCallback(() => setIsEditing(false), []) const handleStopEditing = useCallback(() => setIsEditing(false), [])
const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png' const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png'
const snapTarget = resolveNodeSnapTarget(node)
const defaultName = asset?.name || 'Item' const defaultName = asset?.name || 'Item'
const hasChildren = children.length > 0 const hasChildren = children.length > 0
@@ -93,7 +99,15 @@ export const ItemTreeNode = memo(function ItemTreeNode({
depth={depth} depth={depth}
expanded={expanded} expanded={expanded}
hasChildren={hasChildren} hasChildren={hasChildren}
icon={<Image alt="" className="object-contain" height={14} src={iconSrc} width={14} />} icon={
snapTarget ? (
<SnapTargetIcon target={snapTarget}>
<Image alt="" className="object-contain" height={14} src={iconSrc} width={14} />
</SnapTargetIcon>
) : (
<Image alt="" className="object-contain" height={14} src={iconSrc} width={14} />
)
}
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
isSelected={isSelected} isSelected={isSelected}
@@ -2,9 +2,14 @@ import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor' import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import {
focusTreeNode,
handleTreeSelection,
routeTreeSelectionToNode,
TreeNodeWrapper,
} from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
interface RegistryTreeNodeProps { interface RegistryTreeNodeProps {
@@ -36,22 +41,21 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
const icon = presentation?.icon const icon = presentation?.icon
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.png' const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.png'
const snapTarget = resolveNodeSnapTarget(node)
const defaultName = node?.name || presentation?.label || 'Node' const defaultName = node?.name || presentation?.label || 'Node'
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const handled = handleTreeSelection( handleTreeSelection(
e, e,
nodeId, nodeId,
useViewer.getState().selection.selectedIds, useViewer.getState().selection.selectedIds,
setSelection, setSelection,
) )
if (!handled && useEditor.getState().phase === 'furnish') { routeTreeSelectionToNode(node)
useEditor.getState().setPhase('structure')
}
}, },
[nodeId, setSelection], [node, nodeId, setSelection],
) )
return ( return (
@@ -61,6 +65,8 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
icon={ icon={
snapTarget ? (
<SnapTargetIcon target={snapTarget}>
<Image <Image
alt="" alt=""
className="object-contain opacity-60" className="object-contain opacity-60"
@@ -68,6 +74,16 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
src={iconSrc} src={iconSrc}
width={14} width={14}
/> />
</SnapTargetIcon>
) : (
<Image
alt=""
className="object-contain opacity-60"
height={14}
src={iconSrc}
width={14}
/>
)
} }
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
@@ -5,9 +5,14 @@ import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { memo, useCallback, useEffect, useState } from 'react' import { memo, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node' import {
focusTreeNode,
handleTreeSelection,
routeTreeSelectionToNode,
TreeNode,
TreeNodeWrapper,
} from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
interface ShelfTreeNodeProps { interface ShelfTreeNodeProps {
@@ -34,6 +39,7 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
const children = useScene( const children = useScene(
useShallow((s) => (s.nodes[nodeId] as ShelfNode | undefined)?.children ?? []), useShallow((s) => (s.nodes[nodeId] as ShelfNode | undefined)?.children ?? []),
) )
const node = useScene((s) => s.nodes[nodeId] as ShelfNode | undefined)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId) const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
@@ -63,17 +69,15 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const handled = handleTreeSelection( handleTreeSelection(
e, e,
nodeId, nodeId,
useViewer.getState().selection.selectedIds, useViewer.getState().selection.selectedIds,
setSelection, setSelection,
) )
if (!handled && useEditor.getState().phase === 'furnish') { routeTreeSelectionToNode(node)
useEditor.getState().setPhase('structure')
}
}, },
[nodeId, setSelection], [node, nodeId, setSelection],
) )
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId]) const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
@@ -2,6 +2,7 @@ import { type AnyNodeId, type SolarPanelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -51,6 +52,7 @@ export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
icon={ icon={
<SnapTargetIcon target="roof">
<Image <Image
alt="" alt=""
className="object-contain opacity-60" className="object-contain opacity-60"
@@ -58,6 +60,7 @@ export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({
src="/icons/roof.png" src="/icons/roof.png"
width={14} width={14}
/> />
</SnapTargetIcon>
} }
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
@@ -1,7 +1,10 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { ChevronRight } from 'lucide-react' import { ChevronRight } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { forwardRef, memo, useEffect, useRef } from 'react' import { forwardRef, memo, useEffect, useRef } from 'react'
import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing'
import useEditor from '../../../../../store/use-editor'
export function handleTreeSelection( export function handleTreeSelection(
e: React.MouseEvent, e: React.MouseEvent,
@@ -53,6 +56,31 @@ export function focusTreeNode(nodeId: AnyNodeId) {
emitter.emit('camera-controls:focus', { nodeId }) emitter.emit('camera-controls:focus', { nodeId })
} }
export function routeTreeSelectionToNode(node: AnyNode | null | undefined) {
const target = node ? resolveNodeSelectionTarget(node) : null
if (!target) return
const selectedIdsAfterClick = useViewer.getState().selection.selectedIds
const editor = useEditor.getState()
let didRoute = false
if (target.phase !== editor.phase) {
editor.setPhase(target.phase)
didRoute = true
}
if (
target.phase === 'structure' &&
target.structureLayer &&
target.structureLayer !== useEditor.getState().structureLayer
) {
useEditor.getState().setStructureLayer(target.structureLayer)
didRoute = true
}
if (didRoute) {
useViewer.getState().setSelection({ selectedIds: selectedIdsAfterClick })
}
}
import { cn } from '../../../../../lib/utils' import { cn } from '../../../../../lib/utils'
import { BuildingTreeNode } from './building-tree-node' import { BuildingTreeNode } from './building-tree-node'
import { CeilingTreeNode } from './ceiling-tree-node' import { CeilingTreeNode } from './ceiling-tree-node'
@@ -102,6 +130,7 @@ const treeNodeByType: Record<
ceiling: CeilingTreeNode, ceiling: CeilingTreeNode,
chimney: ChimneyTreeNode, chimney: ChimneyTreeNode,
dormer: DormerTreeNode, dormer: DormerTreeNode,
downspout: RegistryTreeNode,
'solar-panel': SolarPanelTreeNode, 'solar-panel': SolarPanelTreeNode,
column: ColumnTreeNode, column: ColumnTreeNode,
elevator: ElevatorTreeNode, elevator: ElevatorTreeNode,
@@ -266,10 +295,10 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
</motion.div> </motion.div>
) : null} ) : null}
</button> </button>
<div className="flex min-w-0 flex-1 items-center gap-1.5"> <div className="flex min-w-0 flex-1 items-center gap-2">
<span <span
className={cn( className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200', 'flex h-5 w-5 shrink-0 items-center justify-center transition-all duration-200',
!isSelected && 'opacity-60 grayscale', !isSelected && 'opacity-60 grayscale',
)} )}
> >
@@ -1,9 +1,10 @@
'use client' '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 { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -22,6 +23,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
}: WindowTreeNodeProps) { }: WindowTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== 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 isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId) const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
@@ -45,6 +47,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
const handleStartEditing = useCallback(() => setIsEditing(true), []) const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), []) const handleStopEditing = useCallback(() => setIsEditing(false), [])
const snapTarget = resolveNodeSnapTarget(node) ?? 'wall'
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
@@ -53,7 +56,9 @@ export const WindowTreeNode = memo(function WindowTreeNode({
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
icon={ icon={
<SnapTargetIcon target={snapTarget}>
<Image alt="" className="object-contain" height={14} src="/icons/window.png" width={14} /> <Image alt="" className="object-contain" height={14} src="/icons/window.png" width={14} />
</SnapTargetIcon>
} }
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
@@ -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')
})
})
@@ -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<SnapTarget, string> = {
wall: '/icons/wall.png',
ceiling: '/icons/ceiling.png',
roof: '/icons/roof.png',
}
const SNAP_TARGET_LABELS: Record<SnapTarget, string> = {
wall: 'Wall attachment',
ceiling: 'Ceiling attachment',
roof: 'Roof attachment',
}
const SNAP_TARGET_BADGE_SIZE_CLASSES: Record<SnapTargetBadgeSize, string> = {
tile: 'h-6 w-6 rounded-md',
tree: 'h-3.5 w-3.5 rounded-[3px]',
}
const SNAP_TARGET_ICON_SIZE_CLASSES: Record<SnapTargetBadgeSize, string> = {
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 (
<span
className={cn(
'flex items-center justify-center bg-black/65 ring-1 ring-white/20',
SNAP_TARGET_BADGE_SIZE_CLASSES[size],
className,
)}
>
<img
alt={SNAP_TARGET_LABELS[target]}
className={cn('object-contain', SNAP_TARGET_ICON_SIZE_CLASSES[size])}
src={SNAP_TARGET_ICONS[target]}
/>
</span>
)
}
export function SnapTargetIcon({
children,
target,
}: {
children: ReactNode
target: SnapTarget
}) {
return (
<span className="relative inline-flex h-5 w-5 items-center justify-center">
{children}
<SnapTargetBadge
className="-right-1.5 -bottom-1.5 absolute"
size="tree"
target={target}
/>
</span>
)
}
+8
View File
@@ -165,6 +165,13 @@ export {
} from './components/ui/sidebar/panels/settings-panel' } from './components/ui/sidebar/panels/settings-panel'
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel' export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export type { SidebarTab } from './components/ui/sidebar/tab-bar' 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' export type { SaveStatus } from './hooks/use-auto-save'
// useDragAction is the React-side glue for the registry's DragAction // useDragAction is the React-side glue for the registry's DragAction
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports) // primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
@@ -226,6 +233,7 @@ export {
linearUnitToMeters, linearUnitToMeters,
metersToLinearUnit, metersToLinearUnit,
} from './lib/measurements' } from './lib/measurements'
export { consumePlacementDragRelease } from './lib/placement-drag-release'
export { export {
addFreshPlacementMetadata, addFreshPlacementMetadata,
getPlacementMetadataRecord, getPlacementMetadataRecord,
@@ -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,
})
})
})
+101
View File
@@ -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
}
@@ -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<AnyNodeDefinition>) {
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<AnyNodeDefinition>)
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
})
})
@@ -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<AnyNode>[] {
const handles = nodeRegistry.get(node.type)?.handles
if (!handles) return []
return (
typeof handles === 'function' ? handles(node as never) : handles
) as HandleDescriptor<AnyNode>[]
}
export function getDirectRotateHandle(node: AnyNode): ArcResizeHandle<AnyNode> | null {
for (const handle of resolveHandles(node)) {
if (handle.kind === 'arc-resize' && handle.shape === 'rotate') {
return handle as ArcResizeHandle<AnyNode>
}
}
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<AnyNode> | null {
const rotateHandle = getDirectRotateHandle(node)
if (rotateHandle) {
return rotateHandle.apply(node, delta, sceneApi) as Partial<AnyNode>
}
const rotation = (node as { rotation?: unknown }).rotation
if (typeof rotation === 'number') {
return { rotation: rotation - delta } as Partial<AnyNode>
}
if (Array.isArray(rotation)) {
const [rx = 0, ry = 0, rz = 0] = rotation as [number?, number?, number?]
return { rotation: [rx, ry - delta, rz] } as Partial<AnyNode>
}
return null
}
@@ -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)
})
})
@@ -30,9 +30,9 @@ export type FloorplanAlignmentResult = {
* *
* Publishes guides to the `useAlignmentGuides` store as a side effect — set * Publishes guides to the `useAlignmentGuides` store as a side effect — set
* on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer` * on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer`
* renders them. Returns the adjusted point. When `bypass` is true (Alt held) * renders them. Returns the adjusted point. When `bypass` is true (Alt for
* the point is returned unchanged and guides are cleared, matching the * alignment-only bypass, or Shift for the full guided-constraint bypass) the
* "No snap" affordance the placement tools advertise. * point is returned unchanged and guides are cleared.
* *
* `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`); * `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`);
* the scene is stable during a single drag, so re-collecting per pointer-move * 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], point: readonly [number, number],
movingAnchors: AlignmentAnchor[], movingAnchors: AlignmentAnchor[],
candidates: AlignmentAnchor[], candidates: AlignmentAnchor[],
opts?: { bypass?: boolean; threshold?: number }, opts?: { applySnap?: boolean; bypass?: boolean; threshold?: number },
): FloorplanAlignmentResult { ): FloorplanAlignmentResult {
if (opts?.bypass) { if (opts?.bypass) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
@@ -57,7 +57,7 @@ export function applyFloorplanAlignment(
useAlignmentGuides.getState().set(result.guides) 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 { point: [point[0], point[1]], snapped: false, guides: result.guides }
} }
return { 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 * 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 * committed vertex lands exactly where the preview showed it. Caller owns the
* per-kind precedence (existing-wall endpoint/join snap wins; angle-snap * per-kind precedence: existing-wall endpoint/join snap can still win, while
* suppresses alignment) and only calls this when alignment should apply. * 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 * `excludeIds` drops those nodes' anchors from the candidate pool — used when
* dragging a wall / fence endpoint so the moving endpoint doesn't try to * 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( export function alignFloorplanDraftPoint(
point: readonly [number, number], point: readonly [number, number],
opts?: { bypass?: boolean; threshold?: number; excludeIds?: readonly string[] }, opts?: {
applySnap?: boolean
bypass?: boolean
threshold?: number
excludeIds?: readonly string[]
},
): [number, number] { ): [number, number] {
if (opts?.bypass) { if (opts?.bypass) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
@@ -105,7 +111,7 @@ export function alignFloorplanDraftPoint(
point, point,
[{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }], [{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }],
candidates, candidates,
{ threshold: opts?.threshold }, { applySnap: opts?.applySnap, threshold: opts?.threshold },
) )
return snapped return snapped
} }
@@ -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
}
@@ -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',
})
})
})
@@ -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>,
): 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',
}
}
@@ -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<DirectManipulationFeedbackState>((set) => ({
activeRotateNodeId: null,
setActiveRotateNodeId: (activeRotateNodeId) => set({ activeRotateNodeId }),
clearActiveRotateNodeId: (nodeId) =>
set((state) => {
if (nodeId !== undefined && state.activeRotateNodeId !== nodeId) return {}
return { activeRotateNodeId: null }
}),
}))
export default useDirectManipulationFeedback
+33 -2
View File
@@ -9,7 +9,12 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { import {
@@ -59,11 +64,22 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
let lastSnap: [number, number] | null = null let lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original) const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event) const target = roofDrag.resolve(event)
if (!target) return if (!target) {
clearTarget()
return
}
lastTarget = target lastTarget = target
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
@@ -90,8 +106,10 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? roofDrag.resolve(event) const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId const targetSegmentId = target.segment.id as AnyNodeId
const st = useScene.getState() const st = useScene.getState()
@@ -176,16 +194,29 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
exitMoveMode() 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:move', updatePreview)
emitter.on('roof:enter', updatePreview) emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick) emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
emitter.off('roof:move', updatePreview) emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview) emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick) emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
// Safety restore — if the tool is unmounted by something other than // Safety restore — if the tool is unmounted by something other than
// a commit / cancel path (e.g. tool change, selection wipe), leave // a commit / cancel path (e.g. tool change, selection wipe), leave
+13 -2
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
import { boxVentDefinition } from './definition' import { boxVentDefinition } from './definition'
@@ -122,9 +123,17 @@ const BoxVentTool = () => {
} }
}, [activeBuildingId, setSelection]) }, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return ( return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[0.6, 0.4, 0.6]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}> <group position={previewPos}>
<group rotation-y={previewYaw}> <group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}> <group quaternion={previewSurfaceQuat}>
@@ -132,6 +141,8 @@ const BoxVentTool = () => {
</group> </group>
</group> </group>
</group> </group>
)}
</>
) )
} }
+15 -1
View File
@@ -8,7 +8,13 @@ import {
useLiveTransforms, useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } 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 { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
@@ -151,6 +157,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter
wasCommitted = true wasCommitted = true
@@ -169,6 +176,11 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => { const onCancel = () => {
// Revert mesh position and rotation immediately // Revert mesh position and rotation immediately
const mesh = sceneRegistry.nodes.get(nodeId) const mesh = sceneRegistry.nodes.get(nodeId)
@@ -190,6 +202,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
if (!wasCommitted) { if (!wasCommitted) {
@@ -207,6 +220,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
} }
}, [exitMoveMode]) // stable — node values captured via refs at mount }, [exitMoveMode]) // stable — node values captured via refs at mount
+1
View File
@@ -150,6 +150,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Trace ceiling outline' }, { key: 'Left click', label: 'Trace ceiling outline' },
{ key: 'Enter', label: 'Finish ceiling' }, { key: 'Enter', label: 'Finish ceiling' },
{ key: 'Shift', label: 'Free outline' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+10
View File
@@ -14,6 +14,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
consumePlacementDragRelease,
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
@@ -189,6 +190,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
if (isFloorplanSourcedEvent(event)) return if (isFloorplanSourcedEvent(event)) return
if (Date.now() - activatedAtRef.current < 150) { if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
@@ -214,6 +216,12 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
activatedAtRef.current = 0
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => { const onCancel = () => {
clearPreview() clearPreview()
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
@@ -225,6 +233,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
@@ -236,6 +245,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
} }
}, [exitMoveMode, node.id]) }, [exitMoveMode, node.id])
+25 -2
View File
@@ -10,7 +10,7 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
@@ -89,9 +89,19 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
roofSegmentId: node.roofSegmentId, roofSegmentId: node.roofSegmentId,
}) })
const clearTarget = () => {
lastTarget = null
setSegmentXform(null)
setHitLocal(null)
setPreviewSegment(null)
}
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event) const target = roofDrag.resolve(event)
if (!target) return if (!target) {
clearTarget()
return
}
lastTarget = target lastTarget = target
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
@@ -156,14 +166,27 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
event.stopPropagation() 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:move', updatePreview)
emitter.on('roof:enter', updatePreview) emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onClick) emitter.on('roof:click', onClick)
emitter.on('roof:leave', clearTarget)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
emitter.off('roof:move', updatePreview) emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview) emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick) emitter.off('roof:click', onClick)
emitter.off('roof:leave', clearTarget)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
} }
}, [activeBuildingId, node, setMovingNode, setSelection]) }, [activeBuildingId, node, setMovingNode, setSelection])
+15 -3
View File
@@ -14,6 +14,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { chimneyDefinition } from './definition' import { chimneyDefinition } from './definition'
import ChimneyPreview from './preview' import ChimneyPreview from './preview'
@@ -139,19 +140,30 @@ const ChimneyTool = () => {
} }
}, [activeBuildingId, setSelection]) }, [activeBuildingId, setSelection])
if (!activeBuildingId || !segmentXform || !hitLocal || !previewSegment) return null return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
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} // Outer group mirrors the real renderer's `position={segment.position}
// rotation-y={segment.rotation}` chain by composing the segment's // rotation-y={segment.rotation}` chain by composing the segment's
// building-local matrix (which walks roof + level + segment). Inner // building-local matrix (which walks roof + level + segment). Inner
// group offsets by the cursor's segment-local x/z so the chimney // group offsets by the cursor's segment-local x/z so the chimney
// geometry (built with `position[0,2] = 0`) lands under the cursor. // geometry (built with `position[0,2] = 0`) lands under the cursor.
return (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}> <group position={segmentXform.position} quaternion={segmentXform.quaternion}>
<group position={[hitLocal[0], 0, hitLocal[2]]}> <group position={[hitLocal[0], 0, hitLocal[2]]}>
<ChimneyPreview node={previewNode} segment={previewSegment} /> <ChimneyPreview node={previewNode} segment={previewSegment} />
</group> </group>
</group> </group>
)}
</>
) )
} }
+1 -1
View File
@@ -359,7 +359,7 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
tool: () => import('./tool'), tool: () => import('./tool'),
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place column' }, { key: 'Left click', label: 'Place column' },
{ key: 'Alt', label: 'No snap' }, { key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
floorplan: buildColumnFloorplan, floorplan: buildColumnFloorplan,
+9
View File
@@ -16,6 +16,7 @@ import {
import { import {
CursorSphere, CursorSphere,
commitFreshPlacementSubtree, commitFreshPlacementSubtree,
consumePlacementDragRelease,
DragBoundingBox, DragBoundingBox,
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
markToolCancelConsumed, markToolCancelConsumed,
@@ -165,6 +166,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (committed) return
if (!hasMoved) return if (!hasMoved) return
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
// Commit at the last previewed position so the alignment snap (which // Commit at the last previewed position so the alignment snap (which
@@ -225,6 +227,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => { const onCancel = () => {
useLiveTransforms.getState().clear(node.id) useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
@@ -244,12 +251,14 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
} }
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
return () => { return () => {
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
+33 -2
View File
@@ -9,7 +9,12 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { import {
@@ -57,11 +62,22 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
let lastSnap: [number, number] | null = null let lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original) const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event) const target = roofDrag.resolve(event)
if (!target) return if (!target) {
clearTarget()
return
}
lastTarget = target lastTarget = target
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
@@ -88,8 +104,10 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? roofDrag.resolve(event) const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId const targetSegmentId = target.segment.id as AnyNodeId
const st = useScene.getState() const st = useScene.getState()
@@ -168,16 +186,29 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
exitMoveMode() 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:move', updatePreview)
emitter.on('roof:enter', updatePreview) emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick) emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
emitter.off('roof:move', updatePreview) emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview) emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick) emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id) const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true if (obj) obj.visible = true
+13 -2
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { cupolaDefinition } from './definition' import { cupolaDefinition } from './definition'
@@ -114,9 +115,17 @@ const CupolaTool = () => {
} }
}, [activeBuildingId, setSelection]) }, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return ( return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[0.8, 1.2, 0.8]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}> <group position={previewPos}>
<group rotation-y={previewYaw}> <group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}> <group quaternion={previewSurfaceQuat}>
@@ -124,6 +133,8 @@ const CupolaTool = () => {
</group> </group>
</group> </group>
</group> </group>
)}
</>
) )
} }
+1
View File
@@ -219,6 +219,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place door on wall' }, { key: 'Left click', label: 'Place door on wall' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+44 -6
View File
@@ -15,6 +15,7 @@ import {
import { import {
calculateCursorRotation, calculateCursorRotation,
calculateItemRotation, calculateItemRotation,
consumePlacementDragRelease,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
isValidWallSideFace, isValidWallSideFace,
@@ -80,6 +81,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
let currentHostId: string | null = movingDoorNode.parentId let currentHostId: string | null = movingDoorNode.parentId
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
let committed = false
let lastTarget: { let lastTarget: {
wallNode: WallEvent['node'] wallNode: WallEvent['node']
wallId: string wallId: string
@@ -91,6 +93,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
valid: boolean valid: boolean
event: WallEvent event: WallEvent
} | null = null } | null = null
let lastRoofEvent: RoofEvent | null = null
const markHostDirty = (hostId: string | null) => { const markHostDirty = (hostId: string | null) => {
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId) 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 onWallEnter = (event: WallEvent) => {
const target = resolveMoveTarget(event) const target = resolveMoveTarget(event)
if (!target) return if (!target) {
onWallLeave()
return
}
lastTarget = target lastTarget = target
lastRoofEvent = null
applyPreview(target) applyPreview(target)
event.stopPropagation() event.stopPropagation()
} }
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) {
onWallLeave()
return
}
if (isCurvedWall(event.node)) { if (isCurvedWall(event.node)) {
hideCursor() onWallLeave()
return
}
if (event.node.parentId !== getLevelId()) {
onWallLeave()
return return
} }
if (event.node.parentId !== getLevelId()) return
const target = resolveMoveTarget(event) const target = resolveMoveTarget(event)
if (!target) return if (!target) {
onWallLeave()
return
}
lastTarget = target lastTarget = target
lastRoofEvent = null
applyPreview(target) applyPreview(target)
event.stopPropagation() event.stopPropagation()
} }
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
if (committed) return
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
if (!target?.valid) return if (!target?.valid) return
committed = true
let placedId: string let placedId: string
@@ -349,6 +368,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
lastRoofEvent = null
if (isNew) return if (isNew) return
if (currentHostId && currentHostId !== original.parentId) { if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId) markHostDirty(currentHostId)
@@ -387,10 +407,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onRoofHover = (event: RoofEvent) => { const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event) const target = resolveRoofMoveTarget(event)
if (!target) return if (!target) {
onRoofLeave()
return
}
// Wall-frame drag anchor / live transform don't apply on a roof face. // Wall-frame drag anchor / live transform don't apply on a roof face.
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
lastRoofEvent = event
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
if (currentHostId !== target.segment.id) { if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
@@ -416,8 +440,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = resolveRoofMoveTarget(event) const target = resolveRoofMoveTarget(event)
if (!target?.valid) return if (!target?.valid) return
committed = true
const segmentId = target.segment.id const segmentId = target.segment.id
let placedId: string let placedId: string
@@ -487,6 +513,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
lastRoofEvent = null
if (isNew) return if (isNew) return
if (currentHostId && currentHostId !== original.parentId) { if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId) markHostDirty(currentHostId)
@@ -527,6 +554,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
exitMoveMode() 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:enter', onWallEnter)
emitter.on('wall:move', onWallMove) emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick) 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:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave) emitter.on('roof:leave', onRoofLeave)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as 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:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave) emitter.off('roof:leave', onRoofLeave)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
} }
}, [movingDoorNode, exitMoveMode]) }, [movingDoorNode, exitMoveMode])
+59 -14
View File
@@ -3,6 +3,7 @@ import {
collectAlignmentAnchors, collectAlignmentAnchors,
DoorNode, DoorNode,
emitter, emitter,
type GridEvent,
isCurvedWall, isCurvedWall,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
@@ -22,12 +23,13 @@ import {
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' 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 { LineBasicNodeMaterial } from 'three/webgpu'
import { import {
getRoofWallOpeningCursorPose, getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget, type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget, resolveRoofWallOpeningTarget,
worldToSelectedBuildingLocal,
} from '../shared/roof-wall-opening-placement' } from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
@@ -39,6 +41,10 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false, 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 * Door tool — places DoorNodes on walls and on roof-segment wall faces
* (the generated base walls under a roof, including coplanar gable ends). * (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) 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) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) { if (isCurvedWall(event.node)) {
destroyDraft() destroyDraft()
hideCursor() showWallFallbackCursor(event)
return return
} }
const levelId = getLevelId() const levelId = getLevelId()
if (!levelId) return if (!levelId) {
if (event.node.parentId !== levelId) return destroyDraft()
showWallFallbackCursor(event)
return
}
if (event.node.parentId !== levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
destroyDraft() destroyDraft()
@@ -158,13 +195,21 @@ const DoorTool: React.FC = () => {
} }
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) {
if (isCurvedWall(event.node)) {
destroyDraft() destroyDraft()
hideCursor() showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (event.node.parentId !== getLevelId()) {
destroyDraft()
showWallFallbackCursor(event)
return return
} }
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
@@ -373,10 +418,8 @@ const DoorTool: React.FC = () => {
if (!target) { if (!target) {
// On the roof but not over a placeable wall face (slope, soffit, // On the roof but not over a placeable wall face (slope, soffit,
// or a face the door cannot fit on). // or a face the door cannot fit on).
if (draftRef.current?.roofSegmentId) {
destroyDraft() destroyDraft()
hideCursor() showRoofFallbackCursor(event)
}
return return
} }
const { segment, face, position } = target const { segment, face, position } = target
@@ -483,6 +526,7 @@ const DoorTool: React.FC = () => {
emitter.on('roof:move', onRoofHover) emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick) emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave) emitter.on('roof:leave', onRoofLeave)
emitter.on('grid:move', showFallbackCursor)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
return () => { return () => {
@@ -498,12 +542,13 @@ const DoorTool: React.FC = () => {
emitter.off('roof:move', onRoofHover) emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick) emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave) emitter.off('roof:leave', onRoofLeave)
emitter.off('grid:move', showFallbackCursor)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
} }
}, []) }, [])
// Cursor geometry: door outline (default 0.9 × 2.1 × 0.07) // Cursor geometry: door outline.
const boxGeo = new BoxGeometry(0.9, 2.1, 0.07) const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo) const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose() boxGeo.dispose()
+12 -3
View File
@@ -3,6 +3,7 @@
import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core' import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react' import { useMemo } from 'react'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { dormerDefinition } from './definition' import { dormerDefinition } from './definition'
import DormerPreview from './preview' import DormerPreview from './preview'
import { useDormerPlacement } from './use-dormer-placement' import { useDormerPlacement } from './use-dormer-placement'
@@ -47,7 +48,8 @@ const DormerTool = () => {
[], [],
) )
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({ const { activeBuildingId, clearPreview, segmentXform, hitLocal, ghostRotation } =
useDormerPlacement({
onCommit: (hit, rotation) => { onCommit: (hit, rotation) => {
const state = useScene.getState() const state = useScene.getState()
const dormer = DormerNode.parse({ const dormer = DormerNode.parse({
@@ -67,9 +69,14 @@ const DormerTool = () => {
}, },
}) })
if (!activeBuildingId || !segmentXform || !hitLocal) return null
return ( return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={clearPreview}
size={[1.8, 1.8, 1.4]}
/>
{activeBuildingId && segmentXform && hitLocal && (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}> <group position={segmentXform.position} quaternion={segmentXform.quaternion}>
<group position={hitLocal}> <group position={hitLocal}>
<group rotation-y={ghostRotation}> <group rotation-y={ghostRotation}>
@@ -77,6 +84,8 @@ const DormerTool = () => {
</group> </group>
</group> </group>
</group> </group>
)}
</>
) )
} }
@@ -6,7 +6,7 @@ import {
type RoofSegmentNode, type RoofSegmentNode,
sceneRegistry, sceneRegistry,
} from '@pascal-app/core' } from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor' import { consumePlacementDragRelease, triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
@@ -58,6 +58,7 @@ export function useDormerPlacement(opts: {
onCommit: (hit: DormerPlacementHit, rotation: number) => void onCommit: (hit: DormerPlacementHit, rotation: number) => void
}): { }): {
activeBuildingId: string | undefined activeBuildingId: string | undefined
clearPreview: () => void
segmentXform: DormerSegmentTransform | null segmentXform: DormerSegmentTransform | null
hitLocal: [number, number, number] | null hitLocal: [number, number, number] | null
ghostRotation: number ghostRotation: number
@@ -78,6 +79,11 @@ export function useDormerPlacement(opts: {
const onCommitRef = useRef(opts.onCommit) const onCommitRef = useRef(opts.onCommit)
onCommitRef.current = opts.onCommit onCommitRef.current = opts.onCommit
const clearPreview = () => {
setSegmentXform(null)
setHitLocal(null)
}
useEffect(() => { useEffect(() => {
if (!activeBuildingId) return if (!activeBuildingId) return
@@ -99,6 +105,7 @@ export function useDormerPlacement(opts: {
const roofDrag = relativeStartRef.current const roofDrag = relativeStartRef.current
? createRelativeRoofDrag(relativeStartRef.current) ? createRelativeRoofDrag(relativeStartRef.current)
: null : null
let committed = false
let lastRelativeHit: DormerPlacementHit | null = null let lastRelativeHit: DormerPlacementHit | null = null
const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => { const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => {
@@ -139,15 +146,27 @@ export function useDormerPlacement(opts: {
} }
const onClick = (event: RoofEvent) => { const onClick = (event: RoofEvent) => {
if (committed) return
const hit = roofDrag const hit = roofDrag
? (lastRelativeHit ?? resolvePlacementHit(event)) ? (lastRelativeHit ?? resolvePlacementHit(event))
: resolvePlacementHit(event) : resolvePlacementHit(event)
if (!hit) return if (!hit) return
committed = true
onCommitRef.current(hit, ghostRotationRef.current) onCommitRef.current(hit, ghostRotationRef.current)
triggerSFX('sfx:item-place') triggerSFX('sfx:item-place')
event.stopPropagation() 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) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'r' && e.key !== 'R') return if (e.key !== 'r' && e.key !== 'R') return
const target = e.target as HTMLElement | null const target = e.target as HTMLElement | null
@@ -166,17 +185,20 @@ export function useDormerPlacement(opts: {
emitter.on('roof:enter', updatePreview) emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onClick) emitter.on('roof:click', onClick)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
emitter.off('roof:move', updatePreview) emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview) emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick) emitter.off('roof:click', onClick)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
} }
}, [activeBuildingId]) }, [activeBuildingId])
return { return {
activeBuildingId: activeBuildingId ?? undefined, activeBuildingId: activeBuildingId ?? undefined,
clearPreview,
segmentXform, segmentXform,
hitLocal, hitLocal,
ghostRotation, ghostRotation,
+11 -2
View File
@@ -17,6 +17,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Vector3 } from 'three' import { Vector3 } from 'three'
import { computeEaveY } from '../gutter/eave-snap' import { computeEaveY } from '../gutter/eave-snap'
import { resolveGutterOutletById } from '../gutter/outlet-lookup' import { resolveGutterOutletById } from '../gutter/outlet-lookup'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { downspoutDefinition } from './definition' import { downspoutDefinition } from './definition'
import DownspoutPreview from './preview' import DownspoutPreview from './preview'
import { computeDownspoutRouting, type DownspoutRouting } from './routing' import { computeDownspoutRouting, type DownspoutRouting } from './routing'
@@ -162,9 +163,15 @@ const DownspoutTool = () => {
} }
}, [activeBuildingId, setSelection]) }, [activeBuildingId, setSelection])
if (!activeBuildingId || !target) return null
return ( return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => setTarget(null)}
size={[0.2, 2.5, 0.2]}
validTarget="gutter"
/>
{activeBuildingId && target && (
<group position={target.segment.position} rotation-y={target.segment.rotation}> <group position={target.segment.position} rotation-y={target.segment.rotation}>
<group <group
position={[target.gutter.position[0], target.segment.eaveY, target.gutter.position[2]]} position={[target.gutter.position[0], target.segment.eaveY, target.gutter.position[2]]}
@@ -178,6 +185,8 @@ const DownspoutTool = () => {
</group> </group>
</group> </group>
</group> </group>
)}
</>
) )
} }
+33 -2
View File
@@ -9,7 +9,12 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { import {
@@ -58,11 +63,22 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
let lastSnap: [number, number] | null = null let lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original) const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event) const target = roofDrag.resolve(event)
if (!target) return if (!target) {
clearTarget()
return
}
lastTarget = target lastTarget = target
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
@@ -89,8 +105,10 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? roofDrag.resolve(event) const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId const targetSegmentId = target.segment.id as AnyNodeId
const st = useScene.getState() const st = useScene.getState()
@@ -169,16 +187,29 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
exitMoveMode() 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:move', updatePreview)
emitter.on('roof:enter', updatePreview) emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick) emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
emitter.off('roof:move', updatePreview) emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview) emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick) emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id) const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true if (obj) obj.visible = true
+13 -2
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
import { eyebrowVentDefinition } from './definition' import { eyebrowVentDefinition } from './definition'
@@ -117,9 +118,17 @@ const EyebrowVentTool = () => {
} }
}, [activeBuildingId, setSelection]) }, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return ( return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[1.2, 0.4, 0.5]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}> <group position={previewPos}>
<group rotation-y={previewYaw}> <group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}> <group quaternion={previewSurfaceQuat}>
@@ -127,6 +136,8 @@ const EyebrowVentTool = () => {
</group> </group>
</group> </group>
</group> </group>
)}
</>
) )
} }
+10
View File
@@ -14,6 +14,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
consumePlacementDragRelease,
markToolCancelConsumed, markToolCancelConsumed,
snapFenceDraftPoint, snapFenceDraftPoint,
triggerSFX, triggerSFX,
@@ -227,6 +228,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
if (Date.now() - activatedAtRef.current < 150) { if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
return return
@@ -264,6 +266,12 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
activatedAtRef.current = 0
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => { const onCancel = () => {
restoreOriginal() restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [fenceId] }) 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:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
if (!wasCommitted) { if (!wasCommitted) {
@@ -303,6 +312,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
} }
}, [exitMoveMode, node]) }, [exitMoveMode, node])
+2 -1
View File
@@ -466,7 +466,8 @@ export const FenceTool: React.FC = () => {
} }
// Align the drafted point onto another object's nearest real anchor and // 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 => { const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
if (bypass || alignmentCandidates.length === 0) { if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
+32 -2
View File
@@ -10,7 +10,12 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useCallback, useEffect, useState } from 'react'
import { createRelativeRoofDrag } from '../shared/relative-roof-drag' import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
import { type EaveSnap, resolveEaveSnap } from './eave-snap' 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 lastSnap: [number, number] | null = null
let lastTarget: GutterDragTarget | null = null let lastTarget: GutterDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original) const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setTarget(null)
}
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => { const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
const target = roofDrag.resolve(event) const target = roofDrag.resolve(event)
if (!target) return null if (!target) return null
@@ -85,7 +97,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const roof = event.node as RoofNode const roof = event.node as RoofNode
const target = resolveTarget(event) const target = resolveTarget(event)
if (!target) return if (!target) {
clearTarget()
return
}
lastTarget = target lastTarget = target
// Same snap math as the placement tool — picking-up and putting- // 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) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? resolveTarget(event) const target = lastTarget ?? resolveTarget(event)
if (!target) return if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId const targetSegmentId = target.segment.id as AnyNodeId
const { snap } = target const { snap } = target
const st = useScene.getState() const st = useScene.getState()
@@ -201,16 +218,29 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
exitMoveMode() 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:move', updatePreview)
emitter.on('roof:enter', updatePreview) emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick) emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
emitter.off('roof:move', updatePreview) emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview) emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick) emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id) const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true if (obj) obj.visible = true
+10 -2
View File
@@ -11,6 +11,7 @@ import {
import { triggerSFX } from '@pascal-app/editor' import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { gutterDefinition } from './definition' import { gutterDefinition } from './definition'
import { type EaveSnap, resolveEaveSnap } from './eave-snap' import { type EaveSnap, resolveEaveSnap } from './eave-snap'
@@ -142,9 +143,14 @@ const GutterTool = () => {
} }
}, [activeBuildingId, setSelection]) }, [activeBuildingId, setSelection])
if (!activeBuildingId || !target) return null
return ( return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => setTarget(null)}
size={[2, 0.2, 0.25]}
/>
{activeBuildingId && target && (
<group position={target.roof.position} rotation-y={target.roof.rotation}> <group position={target.roof.position} rotation-y={target.roof.rotation}>
<group position={target.segment.position} rotation-y={target.segment.rotation}> <group position={target.segment.position} rotation-y={target.segment.rotation}>
<group <group
@@ -155,6 +161,8 @@ const GutterTool = () => {
</group> </group>
</group> </group>
</group> </group>
)}
</>
) )
} }
+32 -2
View File
@@ -9,7 +9,12 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useCallback, useEffect, useState } from 'react'
import { import {
createRelativeRoofDrag, createRelativeRoofDrag,
@@ -60,8 +65,15 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
let lastSnap: [number, number] | null = null let lastSnap: [number, number] | null = null
let lastTarget: RidgeVentDragTarget | null = null let lastTarget: RidgeVentDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original) const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setPreviewPos(null)
}
const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => { const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => {
const target = roofDrag.resolve(event) const target = roofDrag.resolve(event)
if (!target) return null if (!target) return null
@@ -75,7 +87,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const target = resolveTarget(event) const target = resolveTarget(event)
if (!target) return if (!target) {
clearTarget()
return
}
lastTarget = target lastTarget = target
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
@@ -100,8 +115,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? resolveTarget(event) const target = lastTarget ?? resolveTarget(event)
if (!target) return if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId const targetSegmentId = target.segment.id as AnyNodeId
const st = useScene.getState() const st = useScene.getState()
@@ -180,16 +197,29 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
exitMoveMode() 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:move', updatePreview)
emitter.on('roof:enter', updatePreview) emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick) emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
emitter.off('roof:move', updatePreview) emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview) emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick) emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id) const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true if (obj) obj.visible = true
+19 -2
View File
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { resolveRidgeSnap } from '../shared/ridge-snap' import { resolveRidgeSnap } from '../shared/ridge-snap'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { ridgeVentDefinition } from './definition' import { ridgeVentDefinition } from './definition'
import RidgeVentPreview from './preview' import RidgeVentPreview from './preview'
@@ -135,14 +136,30 @@ const RidgeVentTool = () => {
} }
}, [activeBuildingId, setSelection]) }, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos) return null
return ( return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
isValidRoofTarget={(event) => {
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 && (
<group position={previewPos}> <group position={previewPos}>
<group rotation-y={previewYaw}> <group rotation-y={previewYaw}>
<RidgeVentPreview node={previewNode} /> <RidgeVentPreview node={previewNode} />
</group> </group>
</group> </group>
)}
</>
) )
} }
@@ -20,6 +20,7 @@ import {
import { import {
CursorSphere, CursorSphere,
commitFreshPlacementSubtree, commitFreshPlacementSubtree,
consumePlacementDragRelease,
DragBoundingBox, DragBoundingBox,
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
resolvePlanarCursorPosition, resolvePlanarCursorPosition,
@@ -359,6 +360,7 @@ export const MoveRoofTool: React.FC<{
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
if (!hasMoved) return if (!hasMoved) return
const [localX, , localZ] = lastLocalPosition const [localX, , localZ] = lastLocalPosition
@@ -394,6 +396,11 @@ export const MoveRoofTool: React.FC<{
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => { const onCancel = () => {
wasCancelled = true wasCancelled = true
useLiveTransforms.getState().clear(movingNode.id) useLiveTransforms.getState().clear(movingNode.id)
@@ -454,6 +461,7 @@ export const MoveRoofTool: React.FC<{
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
// Restore segment wrapper visibility (React will re-sync on next render) // 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('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
} }
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement]) }, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
@@ -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<unknown>(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 (
<DragBoundingBox
color={INVALID_PREVIEW_COLOR}
nodeId="roof-attachment-fallback"
position={position}
size={size}
/>
)
}
@@ -88,7 +88,10 @@ const cursorPoint = new Vector3()
export function worldToSelectedBuildingLocal(point: Vector3): [number, number, number] { export function worldToSelectedBuildingLocal(point: Vector3): [number, number, number] {
const buildingId = useViewer.getState().selection.buildingId const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined 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] return [point.x, point.y, point.z]
} }
+1
View File
@@ -258,6 +258,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
tool: () => import('./tool'), tool: () => import('./tool'),
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place shelf' }, { key: 'Left click', label: 'Place shelf' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+28 -2
View File
@@ -10,7 +10,12 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { import {
@@ -65,8 +70,14 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
let lastSnapX = 0 let lastSnapX = 0
let lastSnapZ = 0 let lastSnapZ = 0
let lastTarget: RelativeRoofDragTarget | null = null let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original) const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
setHasHit(false)
}
// Resolve which segment the cursor is over, then derive the same // Resolve which segment the cursor is over, then derive the same
// preview transform stack the placement tool uses (`skylight/tool.tsx`): // preview transform stack the placement tool uses (`skylight/tool.tsx`):
// analytical surface normal in segment-local frame → outer yaw = // 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 roof = event.node as RoofNode
const target = roofDrag.resolve(event) const target = roofDrag.resolve(event)
if (!target) { if (!target) {
setHasHit(false) clearTarget()
return false return false
} }
lastTarget = target lastTarget = target
@@ -113,10 +124,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return
const st = useScene.getState() const st = useScene.getState()
const target = lastTarget ?? roofDrag.resolve(event) const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId const targetSegmentId = target.segment.id as AnyNodeId
const finalRotation = original.rotation const finalRotation = original.rotation
@@ -206,16 +219,29 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
exitMoveMode() 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:move', onRoofMove)
emitter.on('roof:enter', onRoofEnter) emitter.on('roof:enter', onRoofEnter)
emitter.on('roof:click', onRoofClick) emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
emitter.off('roof:move', onRoofMove) emitter.off('roof:move', onRoofMove)
emitter.off('roof:enter', onRoofEnter) emitter.off('roof:enter', onRoofEnter)
emitter.off('roof:click', onRoofClick) emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id) const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true if (obj) obj.visible = true
+13 -2
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { skylightDefinition } from './definition' import { skylightDefinition } from './definition'
@@ -109,9 +110,17 @@ const SkylightTool = () => {
} }
}, [activeBuildingId, setSelection]) }, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return ( return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[1.2, 0.2, 1]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}> <group position={previewPos}>
<group rotation-y={previewYaw}> <group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}> <group quaternion={previewSurfaceQuat}>
@@ -119,6 +128,8 @@ const SkylightTool = () => {
</group> </group>
</group> </group>
</group> </group>
)}
</>
) )
} }
+1
View File
@@ -201,6 +201,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Trace slab outline' }, { key: 'Left click', label: 'Trace slab outline' },
{ key: 'Enter', label: 'Finish slab' }, { key: 'Enter', label: 'Finish slab' },
{ key: 'Shift', label: 'Free outline' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+10
View File
@@ -16,6 +16,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
consumePlacementDragRelease,
getSegmentGridStep, getSegmentGridStep,
markToolCancelConsumed, markToolCancelConsumed,
resolveAlignmentForActiveBuilding, resolveAlignmentForActiveBuilding,
@@ -214,6 +215,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
if (isFloorplanSourcedEvent(event)) return if (isFloorplanSourcedEvent(event)) return
if (Date.now() - activatedAtRef.current < 150) { if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
@@ -245,6 +247,12 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
activatedAtRef.current = 0
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => { const onCancel = () => {
// No scene state to roll back — we never wrote anything. Just // No scene state to roll back — we never wrote anything. Just
// restore the mesh visual. // restore the mesh visual.
@@ -258,6 +266,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
@@ -269,6 +278,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
} }
}, [exitMoveMode, node.id, node.parentId]) }, [exitMoveMode, node.id, node.parentId])
+32 -2
View File
@@ -9,7 +9,13 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { import {
@@ -91,11 +97,20 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
let lastSnapX = 0 let lastSnapX = 0
let lastSnapZ = 0 let lastSnapZ = 0
let lastTarget: RelativeRoofDragTarget | null = null let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original) const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
setHasHit(false)
}
const updateGhost = (event: RoofEvent) => { const updateGhost = (event: RoofEvent) => {
const target = roofDrag.resolve(event) const target = roofDrag.resolve(event)
if (!target) return if (!target) {
clearTarget()
return
}
lastTarget = target lastTarget = target
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
@@ -127,10 +142,12 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return
const st = useScene.getState() const st = useScene.getState()
const target = lastTarget ?? roofDrag.resolve(event) const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId const targetSegmentId = target.segment.id as AnyNodeId
@@ -227,16 +244,29 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
exitMoveMode() 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:move', updateGhost)
emitter.on('roof:enter', updateGhost) emitter.on('roof:enter', updateGhost)
emitter.on('roof:click', onRoofClick) emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
emitter.off('roof:move', updateGhost) emitter.off('roof:move', updateGhost)
emitter.off('roof:enter', updateGhost) emitter.off('roof:enter', updateGhost)
emitter.off('roof:click', onRoofClick) emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id) const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true if (obj) obj.visible = true
+13 -2
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { solarPanelDefinition } from './definition' import { solarPanelDefinition } from './definition'
@@ -130,9 +131,17 @@ const SolarPanelTool = () => {
} }
}, [activeBuildingId, setSelection]) }, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return ( return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[1.8, 0.2, 1.2]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}> <group position={previewPos}>
<group rotation-y={previewYaw}> <group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}> <group quaternion={previewSurfaceQuat}>
@@ -140,6 +149,8 @@ const SolarPanelTool = () => {
</group> </group>
</group> </group>
</group> </group>
)}
</>
) )
} }
+1
View File
@@ -100,6 +100,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
tool: () => import('./tool'), tool: () => import('./tool'),
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place spawn point' }, { key: 'Left click', label: 'Place spawn point' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+33 -2
View File
@@ -9,7 +9,12 @@ import {
type TurbineVentNode, type TurbineVentNode,
useScene, useScene,
} from '@pascal-app/core' } 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 { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { import {
@@ -58,11 +63,22 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
let lastSnap: [number, number] | null = null let lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original) const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event) const target = roofDrag.resolve(event)
if (!target) return if (!target) {
clearTarget()
return
}
lastTarget = target lastTarget = target
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
@@ -89,8 +105,10 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? roofDrag.resolve(event) const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId const targetSegmentId = target.segment.id as AnyNodeId
const st = useScene.getState() const st = useScene.getState()
@@ -169,16 +187,29 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
exitMoveMode() 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:move', updatePreview)
emitter.on('roof:enter', updatePreview) emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick) emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
emitter.off('roof:move', updatePreview) emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview) emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick) emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id) const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true if (obj) obj.visible = true
+13 -2
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
import { turbineVentDefinition } from './definition' import { turbineVentDefinition } from './definition'
@@ -117,9 +118,17 @@ const TurbineVentTool = () => {
} }
}, [activeBuildingId, setSelection]) }, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return ( return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[0.5, 0.8, 0.5]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}> <group position={previewPos}>
<group rotation-y={previewYaw}> <group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}> <group quaternion={previewSurfaceQuat}>
@@ -127,6 +136,8 @@ const TurbineVentTool = () => {
</group> </group>
</group> </group>
</group> </group>
)}
</>
) )
} }
+19 -7
View File
@@ -508,9 +508,13 @@ export const WallTool: React.FC = () => {
} }
// Align the drafted point onto another object's nearest real anchor and // 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
const alignPoint = (point: WallPlanPoint, bypass: boolean): WallPlanPoint => { // snapping. Returns the possibly snapped point.
if (bypass || alignmentCandidates.length === 0) { const alignPoint = (
point: WallPlanPoint,
options: { applySnap?: boolean; bypass?: boolean },
): WallPlanPoint => {
if (options.bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
return point return point
} }
@@ -520,7 +524,9 @@ export const WallTool: React.FC = () => {
threshold: ALIGNMENT_THRESHOLD_M, threshold: ALIGNMENT_THRESHOLD_M,
}) })
useAlignmentGuides.getState().set(ar.guides) 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 = () => { const stopDrafting = () => {
@@ -554,7 +560,10 @@ export const WallTool: React.FC = () => {
bypassSnap, bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap, 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 // Stand the magnetic beacon at the endpoint when it locked onto an
// existing wall corner / wall point; clear it for plain grid/angle moves. // existing wall corner / wall point; clear it for plain grid/angle moves.
useWallSnapIndicator useWallSnapIndicator
@@ -635,7 +644,7 @@ export const WallTool: React.FC = () => {
bypassSnap, bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap, magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}).point, }).point,
bypassAlign, { bypass: bypassAlign },
) )
gridPosition = snappedStart gridPosition = snappedStart
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
@@ -666,7 +675,10 @@ export const WallTool: React.FC = () => {
bypassSnap, bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap, magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}).point, }).point,
bypassAlign || angleLocked, {
applySnap: !angleLocked,
bypass: bypassAlign,
},
) )
const dx = snappedEnd[0] - startingPoint.current.x const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z const dz = snappedEnd[1] - startingPoint.current.z
+1
View File
@@ -197,6 +197,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place window on wall' }, { key: 'Left click', label: 'Place window on wall' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+44 -6
View File
@@ -15,6 +15,7 @@ import {
import { import {
calculateCursorRotation, calculateCursorRotation,
calculateItemRotation, calculateItemRotation,
consumePlacementDragRelease,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
isValidWallSideFace, isValidWallSideFace,
@@ -99,6 +100,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
} }
let currentHostId: string | null = movingWindowNode.parentId let currentHostId: string | null = movingWindowNode.parentId
let committed = false
let dragAnchor: { let dragAnchor: {
wallId: string wallId: string
rawX: number rawX: number
@@ -117,6 +119,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
valid: boolean valid: boolean
event: WallEvent event: WallEvent
} | null = null } | null = null
let lastRoofEvent: RoofEvent | null = null
const markHostDirty = (hostId: string | null) => { const markHostDirty = (hostId: string | null) => {
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId) 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 onWallEnter = (event: WallEvent) => {
const target = resolveMoveTarget(event) const target = resolveMoveTarget(event)
if (!target) return if (!target) {
onWallLeave()
return
}
lastTarget = target lastTarget = target
lastRoofEvent = null
applyPreview(target) applyPreview(target)
event.stopPropagation() event.stopPropagation()
} }
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) {
onWallLeave()
return
}
if (isCurvedWall(event.node)) { if (isCurvedWall(event.node)) {
hideCursor() onWallLeave()
return return
} }
// Only interact with walls on the current level // 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) const target = resolveMoveTarget(event)
if (!target) return if (!target) {
onWallLeave()
return
}
lastTarget = target lastTarget = target
lastRoofEvent = null
applyPreview(target) applyPreview(target)
event.stopPropagation() event.stopPropagation()
} }
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
if (committed) return
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return if (isCurvedWall(event.node)) return
// Only interact with walls on the current level // 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) const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
if (!target?.valid) return if (!target?.valid) return
committed = true
let placedId: string let placedId: string
@@ -387,6 +406,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
lastRoofEvent = null
if (isNew) return // No original to restore for duplicates if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall // Move mode: restore to original position while off-wall
if (currentHostId && currentHostId !== original.parentId) { if (currentHostId && currentHostId !== original.parentId) {
@@ -430,10 +450,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onRoofHover = (event: RoofEvent) => { const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event) const target = resolveRoofMoveTarget(event)
if (!target) return if (!target) {
onRoofLeave()
return
}
// Wall-frame drag anchor / live transform don't apply on a roof face. // Wall-frame drag anchor / live transform don't apply on a roof face.
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
lastRoofEvent = event
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
if (currentHostId !== target.segment.id) { if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
@@ -459,8 +483,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = resolveRoofMoveTarget(event) const target = resolveRoofMoveTarget(event)
if (!target?.valid) return if (!target?.valid) return
committed = true
const segmentId = target.segment.id const segmentId = target.segment.id
let placedId: string let placedId: string
@@ -531,6 +557,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
lastRoofEvent = null
if (isNew) return if (isNew) return
if (currentHostId && currentHostId !== original.parentId) { if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId) markHostDirty(currentHostId)
@@ -571,6 +598,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
exitMoveMode() 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:enter', onWallEnter)
emitter.on('wall:move', onWallMove) emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick) 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:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave) emitter.on('roof:leave', onRoofLeave)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => { return () => {
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move) // 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:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave) emitter.off('roof:leave', onRoofLeave)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
} }
}, [movingWindowNode, exitMoveMode]) }, [movingWindowNode, exitMoveMode])
+59 -13
View File
@@ -2,6 +2,7 @@ import {
type AnyNodeId, type AnyNodeId,
collectAlignmentAnchors, collectAlignmentAnchors,
emitter, emitter,
type GridEvent,
isCurvedWall, isCurvedWall,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
@@ -23,12 +24,13 @@ import {
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' 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 { LineBasicNodeMaterial } from 'three/webgpu'
import { import {
getRoofWallOpeningCursorPose, getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget, type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget, resolveRoofWallOpeningTarget,
worldToSelectedBuildingLocal,
} from '../shared/roof-wall-opening-placement' } from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -41,6 +43,11 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false, 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 * Window tool — places WindowNodes on walls and on roof-segment wall
* faces (the generated base walls under a roof, including coplanar gable * 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) 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) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) { if (isCurvedWall(event.node)) {
destroyDraft() destroyDraft()
hideCursor() showWallFallbackCursor(event)
return return
} }
const levelId = getLevelId() const levelId = getLevelId()
if (!levelId) return if (!levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== levelId) return if (event.node.parentId !== levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
destroyDraft() destroyDraft()
@@ -167,14 +205,22 @@ const WindowTool: React.FC = () => {
} }
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) { if (isCurvedWall(event.node)) {
destroyDraft() destroyDraft()
hideCursor() showWallFallbackCursor(event)
return return
} }
// Only interact with walls on the current level // 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 side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
@@ -395,10 +441,8 @@ const WindowTool: React.FC = () => {
if (!target) { if (!target) {
// On the roof but not over a placeable wall face (slope, soffit, // On the roof but not over a placeable wall face (slope, soffit,
// or a face the window cannot fit on). // or a face the window cannot fit on).
if (draftRef.current?.roofSegmentId) {
destroyDraft() destroyDraft()
hideCursor() showRoofFallbackCursor(event)
}
return return
} }
const { segment, face, position } = target const { segment, face, position } = target
@@ -499,6 +543,7 @@ const WindowTool: React.FC = () => {
emitter.on('roof:move', onRoofHover) emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick) emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave) emitter.on('roof:leave', onRoofLeave)
emitter.on('grid:move', showFallbackCursor)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
return () => { return () => {
@@ -514,12 +559,13 @@ const WindowTool: React.FC = () => {
emitter.off('roof:move', onRoofHover) emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick) emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave) emitter.off('roof:leave', onRoofLeave)
emitter.off('grid:move', showFallbackCursor)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
} }
}, []) }, [])
// Cursor geometry: window outline rectangle (width × height × frameDepth) // Cursor geometry: window outline rectangle.
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07) const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo) const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose() boxGeo.dispose()
+1 -1
View File
@@ -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`) | | [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) | | [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 | | [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 | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner | | [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
+22
View File
@@ -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. - **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 — `<ParametricNodeRenderer>` 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. - **`def.renderer` overrides the generic renderer.** Once you set it, you own the mount — `<ParametricNodeRenderer>` 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 ## Pitfalls
### `<GeometrySystem>` must not mutate `group.position` / `group.rotation` ### `<GeometrySystem>` must not mutate `group.position` / `group.rotation`
+15
View File
@@ -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. 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 ## Rules
+21
View File
@@ -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 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. - 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. - **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. - **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. - **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. - **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.