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 {
type AnyNode,
type AnyNodeId,
createSceneApi,
type FloorplanAffordancePoint,
type FloorplanAffordanceSession,
type FloorplanGeometry,
type FloorplanPalette,
type FloorplanPoint,
type GeometryContext,
isRegistryMovable,
kindsWithFloorplanScope,
nodeRegistry,
pauseSceneHistory,
@@ -29,8 +31,15 @@ import {
useRef,
useState,
} from 'react'
import {
canDirectRotateNode,
resolveDirectRotationDragDelta,
resolveDirectRotationPatch,
} from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
import useEditor from '../../../store/use-editor'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
import { useFloorplanRender } from '../floorplan-render-context'
@@ -71,6 +80,9 @@ const ENDPOINT_HIT_STROKE_WIDTH_PX = 18
const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
const DIRECT_DRAG_THRESHOLD_PX = 4
const DIRECT_ROTATE_EPSILON = 1e-6
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
/**
* Snapshot of node fields captured at drag-start, used by the single-undo
@@ -131,6 +143,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
const setHoveredId = useViewer((s) => s.setHoveredId)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
@@ -227,11 +240,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
const handleSelect = useCallback(
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
if (event.button !== 0) return
event.stopPropagation()
setSelection({ selectedIds: [id] })
const applyEntrySelection = useCallback(
(id: AnyNodeId, shouldToggle: boolean) => {
const currentSelectedIds = useViewer.getState().selection.selectedIds
setSelection({
selectedIds: shouldToggle
? currentSelectedIds.includes(id)
? currentSelectedIds.filter((selectedId) => selectedId !== id)
: [...currentSelectedIds, id]
: [id],
})
// Setting selection re-renders the entry — the overlay pass mounts
// (endpoint handles, etc.), reshuffling DOM under the cursor between
// pointerdown and click. If the click target ends up on the SVG
@@ -240,21 +258,200 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// selection we just set. Swallow the next click globally to break
// that race; the listener removes itself after firing (or after a
// safety timeout if no click follows).
const swallowClick = (ev: Event) => {
ev.stopPropagation()
ev.preventDefault()
window.removeEventListener('click', swallowClick, true)
}
window.addEventListener('click', swallowClick, true)
setTimeout(() => window.removeEventListener('click', swallowClick, true), 200)
swallowNextClick(200)
},
[setSelection],
)
const handleSelect = useCallback(
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
if (event.button !== 0) return
event.stopPropagation()
applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
},
[applyEntrySelection],
)
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
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
// theme their output and conditionally emit selection chrome.
//
@@ -729,7 +926,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
onPointerDown={
isOpeningPlacementActive || isMarqueeSelectionActive
? undefined
: (e) => handleSelect(id, e)
: (e) => handleEntryPointerDown(id, e)
}
// Mirror the sidebar tree nodes' hover wiring — `useViewer.
// hoveredId` drives the highlight halo in 3D as well as the
@@ -749,6 +946,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
>
<InteractiveGeometry
activeDragId={activeDragId}
activeRotateNodeId={activeRotateNodeId}
geometry={geometry}
hatchPatternId={renderCtx?.hatchPatternId}
hoveredHandleId={hoveredHandleId}
@@ -845,6 +1043,7 @@ function InteractiveGeometry({
hatchPatternId,
hoveredHandleId,
activeDragId,
activeRotateNodeId,
isMarqueeSelectionActive,
nodeId,
sceneRotationDeg,
@@ -858,6 +1057,7 @@ function InteractiveGeometry({
hatchPatternId: string | undefined
hoveredHandleId: string | null
activeDragId: string | null
activeRotateNodeId: AnyNodeId | null
isMarqueeSelectionActive: boolean
nodeId: AnyNodeId
sceneRotationDeg: number
@@ -1100,7 +1300,7 @@ function InteractiveGeometry({
// each end pointing tangentially in opposite directions —
// "rotate either way."
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
// radius 0.13 — comparable footprint to `move-arrow`).
const R = 0.13
@@ -1966,3 +2166,15 @@ function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoin
// the Y axis on screen — same convention as `toSvgPlanPoint`).
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 = {
meta: boolean
ctrl: boolean
shift: boolean
}
type ZoneHitEntry = {
@@ -85,7 +86,7 @@ export function resolveFloorplanBackgroundSelection({
handled: true,
kind: 'select-elements',
selectedIds:
modifierKeys.meta || modifierKeys.ctrl
modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
? currentSelectedIds.includes(hitId)
? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
: [...currentSelectedIds, hitId]
@@ -105,7 +106,7 @@ export function resolveFloorplanBackgroundSelection({
return {
handled: true,
kind: 'clear-elements',
preserveSelection: modifierKeys.meta || modifierKeys.ctrl,
preserveSelection: modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift,
}
}
@@ -868,10 +868,15 @@ function getElevatorResizeSign(handle: ElevatorResizeHandle) {
return handle.endsWith('positive') ? 1 : -1
}
function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) {
function getSelectionModifierKeys(event?: {
metaKey?: boolean
ctrlKey?: boolean
shiftKey?: boolean
}) {
return {
meta: Boolean(event?.metaKey),
ctrl: Boolean(event?.ctrlKey),
shift: Boolean(event?.shiftKey),
}
}
@@ -8660,10 +8665,11 @@ export function FloorplanPanel({
// that snap wins, so skip Figma alignment and stand the beacon there.
const lockedToWall = wallSnap.snap !== null
let snappedPoint = wallSnapped
if (lockedToWall || wallAngleSnap) {
if (lockedToWall) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap,
bypass: event.altKey || bypassSnap,
})
}
@@ -9233,8 +9239,11 @@ export function FloorplanPanel({
)
const addFloorplanSelection = useCallback(
(nextSelectedIds: string[], modifierKeys?: { meta: boolean; ctrl: boolean }) => {
const shouldAppend = Boolean(modifierKeys?.meta || modifierKeys?.ctrl)
(
nextSelectedIds: string[],
modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean },
) => {
const shouldAppend = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift)
if (shouldAppend) {
if (nextSelectedIds.length === 0) {
@@ -9252,8 +9261,8 @@ export function FloorplanPanel({
)
const toggleFloorplanSelection = useCallback(
(nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean }) => {
const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl)
(nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean }) => {
const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift)
if (shouldToggle) {
const currentSelectedIds = useViewer.getState().selection.selectedIds
@@ -9296,7 +9305,7 @@ export function FloorplanPanel({
const commitFloorplanScreenSelection = useCallback(
(nextSelectedIds: string[], event: PointerEvent) => {
const modifierKeys = getSelectionModifierKeys(event)
const shouldAppend = modifierKeys.meta || modifierKeys.ctrl
const shouldAppend = modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
setSelectedReferenceId(null)
@@ -9934,7 +9943,7 @@ export function FloorplanPanel({
if (hitId) {
toggleFloorplanSelection(hitId, modifierKeys)
} else if (!(modifierKeys.meta || modifierKeys.ctrl)) {
} else if (!(modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift)) {
commitFloorplanSelection([])
}
}
@@ -45,6 +45,7 @@ import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { formatAngleRadians } from '../tools/shared/segment-angle'
@@ -167,6 +168,7 @@ function DimensionLabel({
export function NodeArrowHandles() {
const selectedIds = useViewer((state) => state.selection.selectedIds)
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
@@ -180,7 +182,7 @@ export function NodeArrowHandles() {
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId
const rawNode = useScene((state) =>
selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null,
)
@@ -1028,6 +1030,8 @@ function ArcArrow({
// corner) render a two-headed curved arrow; everything else (stair
// sweep, etc.) keeps the chevron.
const isRotateShape = descriptor.shape === 'rotate'
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
const isDirectRotating = isRotateShape && activeRotateNodeId === liveNode.id
// 'node-normal' spins the node about its local +Z (a wall item flat against
// its wall) instead of yaw about world-Y. The drag plane and the icon both
// tilt into that plane, and the horizontal-only wedge/ring readout is
@@ -1067,7 +1071,7 @@ function ArcArrow({
// arrow is hovered or dragging. Same recipe as the linear / radial
// decoration path.
const decoration = descriptor.decoration
const showDecoration = Boolean(decoration) && (isHovered || isDragging)
const showDecoration = Boolean(decoration) && (isHovered || isDragging || isDirectRotating)
const activate = useHandleDrag({
kind: 'drag',
@@ -1142,13 +1146,6 @@ function ArcArrow({
},
})
// Suppress "declared but unused" for `liveNode` — ArcArrow's apply
// operates entirely on `initialNode` (snapshot taken inside activate)
// and `delta` (live cursor angle), so the live store node doesn't
// appear in the rotation pipeline. The prop is still required because
// ArrowHandle passes it uniformly to every variant.
void liveNode
return (
<>
{showDecoration && decoration ? (
@@ -1172,7 +1169,7 @@ function ArcArrow({
<HandleArrow
activeCursor={dragCursor}
cursor={hoverCursor}
hover={isHovered}
hover={isHovered || isDirectRotating}
onHoverChange={setIsHovered}
onPointerDown={activate}
placement={{
@@ -4,8 +4,10 @@ import {
type BuildingNode,
type CeilingNode,
type ColumnNode,
createSceneApi,
emitter,
type FenceNode,
type GridEvent,
getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial,
getMaterialPresetByRef,
@@ -28,6 +30,7 @@ import {
type StairSegmentEvent,
type StairSurfaceMaterialRole,
sceneRegistry,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
@@ -42,6 +45,13 @@ import {
} from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react'
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 {
type ActivePaintMaterial,
buildRoofSegmentSurfaceMaterialPatch,
@@ -51,13 +61,17 @@ import {
hasActivePaintMaterial,
resolveActivePaintMaterialFromSelection,
} from '../../lib/material-paint'
import { emitDeleteSFX } from '../../lib/sfx-bus'
import useEditor, {
type MaterialTargetRole,
type Phase,
type StructureLayer,
} from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-state'
import {
resolveNodeSelectionTarget,
resolveSelectedIdsForNodeClick,
type SelectionModifierKeys,
selectionModifiersFromEvent,
} from '../../lib/selection-routing'
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 => {
// Elevators are building-scoped, so they stay selectable across level filters.
@@ -86,11 +100,6 @@ type SelectableNodeType =
| 'window'
| 'door'
type ModifierKeys = {
meta: boolean
ctrl: boolean
}
type PaintPreviewCleanup = () => void
type PaintInteraction = {
@@ -103,14 +112,33 @@ type PaintInteraction = {
interface SelectionStrategy {
types: SelectableNodeType[]
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
handleSelect: (
node: AnyNode,
nativeEvent?: any,
modifierKeys?: SelectionModifierKeys,
baseSelectedIds?: readonly string[],
) => void
handleDeselect: () => void
isValid: (node: AnyNode) => boolean
}
type SelectionTarget = {
phase: Phase
structureLayer?: StructureLayer
const DIRECT_DRAG_THRESHOLD_PX = 4
const DIRECT_ROTATE_EPSILON = 1e-6
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
function pointerEventFromNodeEvent(event: NodeEvent): PointerEvent {
const threeEvent = event.nativeEvent as unknown as PointerEvent & {
nativeEvent?: PointerEvent
}
return threeEvent.nativeEvent ?? threeEvent
}
function isCommandModifier(event: Pick<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 = (
@@ -619,22 +647,17 @@ function disposeHighlightedMaterials(material: Material | Material[]) {
const computeNextIds = (
node: AnyNode,
selectedIds: string[],
selectedIds: readonly string[],
event?: any,
modifierKeys?: ModifierKeys,
modifierKeys?: SelectionModifierKeys,
baseSelectedIds?: readonly string[],
): string[] => {
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl
if (isMeta || isCtrl) {
if (selectedIds.includes(node.id)) {
return selectedIds.filter((id) => id !== node.id)
}
return [...selectedIds, node.id]
}
// Not holding modifiers: select only this node
return [node.id]
return resolveSelectedIdsForNodeClick({
baseSelectedIds,
currentSelectedIds: selectedIds,
modifierKeys: selectionModifiersFromEvent(event, modifierKeys),
nodeId: node.id,
})
}
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
@@ -667,7 +690,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
'window',
'door',
],
handleSelect: (node, nativeEvent, modifierKeys) => {
handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
const { selection, setSelection } = useViewer.getState()
const nodes = useScene.getState().nodes
const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
@@ -694,7 +717,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
setSelection(updates)
} else {
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
updates.selectedIds = computeNextIds(
node,
selection.selectedIds,
nativeEvent,
modifierKeys,
baseSelectedIds,
)
setSelection(updates)
}
},
@@ -746,7 +775,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
furnish: {
types: ['item'],
handleSelect: (node, nativeEvent, modifierKeys) => {
handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
const { selection, setSelection } = useViewer.getState()
const nodes = useScene.getState().nodes
const nodeLevelId = resolveLevelId(node, nodes)
@@ -760,7 +789,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
updates.buildingId = buildingId
}
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
updates.selectedIds = computeNextIds(
node,
selection.selectedIds,
nativeEvent,
modifierKeys,
baseSelectedIds,
)
setSelection(updates)
},
handleDeselect: () => {
@@ -776,7 +811,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
// Registry-driven kinds with `category: 'furnish'` (shelf today,
// future furniture kinds): selectable in furnish phase if their
// definition declares the `selectable` capability. Without this
// branch, shelf clicks routed to furnish phase via getSelectionTarget
// branch, shelf clicks routed to furnish phase via resolveNodeSelectionTarget
// would be rejected here — single-click selection broken.
const def = nodeRegistry.get(node.type)
if (def && def.category === 'furnish' && def.capabilities.selectable) return true
@@ -785,76 +820,14 @@ const SELECTION_STRATEGIES: Record<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 = () => {
const phase = useEditor((s) => s.phase)
const mode = useEditor((s) => s.mode)
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
const modifierKeysRef = useRef<ModifierKeys>({
const modifierKeysRef = useRef<SelectionModifierKeys>({
meta: false,
ctrl: false,
shift: false,
})
const clickHandledRef = useRef(false)
@@ -1230,16 +1203,19 @@ export const SelectionManager = () => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Meta') modifierKeysRef.current.meta = true
if (event.key === 'Control') modifierKeysRef.current.ctrl = true
if (event.key === 'Shift') modifierKeysRef.current.shift = true
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Meta') modifierKeysRef.current.meta = false
if (event.key === 'Control') modifierKeysRef.current.ctrl = false
if (event.key === 'Shift') modifierKeysRef.current.shift = false
}
const clearModifiers = () => {
modifierKeysRef.current.meta = false
modifierKeysRef.current.ctrl = false
modifierKeysRef.current.shift = false
}
window.addEventListener('keydown', onKeyDown)
@@ -1253,6 +1229,222 @@ export const SelectionManager = () => {
}
}, [])
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
const onPointerDown = (event: NodeEvent) => {
const pointer = pointerEventFromNodeEvent(event)
if (pointer.button !== 0 || !isCommandModifier(pointer)) return
const node = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node
if (!canDirectMoveNode(node)) return
if (!useViewer.getState().selection.selectedIds.includes(node.id)) return
const startX = pointer.clientX
const startY = pointer.clientY
const pointerId = pointer.pointerId
const pointerTarget = pointer.target instanceof EventTarget ? pointer.target : null
let engaged = false
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onEnd)
window.removeEventListener('pointercancel', onEnd)
if (engaged) {
useViewer.getState().setInputDragging(false)
}
}
const onMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return
if (engaged) return
if (pointerDistancePx(moveEvent, startX, startY) < DIRECT_DRAG_THRESHOLD_PX) return
engaged = true
event.stopPropagation()
suppressBoxSelectForPointer(event.nativeEvent)
useViewer.getState().setInputDragging(true)
swallowNextClick()
createEditorApi().engageMoveDrag(node)
requestAnimationFrame(() => {
if (useEditor.getState().movingNode?.id !== node.id) return
pointerTarget?.dispatchEvent(
new PointerEvent('pointermove', {
altKey: moveEvent.altKey,
bubbles: true,
buttons: moveEvent.buttons,
clientX: moveEvent.clientX,
clientY: moveEvent.clientY,
ctrlKey: moveEvent.ctrlKey,
metaKey: moveEvent.metaKey,
pointerId,
pointerType: moveEvent.pointerType,
shiftKey: moveEvent.shiftKey,
}),
)
})
}
const onEnd = (endEvent: PointerEvent) => {
if (endEvent.pointerId !== pointerId) return
cleanup()
if (engaged) {
requestAnimationFrame(() => {
const editor = useEditor.getState()
if (editor.movingNode?.id !== node.id || !editor.placementDragMode) return
editor.setMovingNode(null)
})
}
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onEnd)
window.addEventListener('pointercancel', onEnd)
}
const allTypes = [
'wall',
'fence',
'item',
'column',
'slab',
'ceiling',
'roof',
'roof-segment',
'stair',
'stair-segment',
'window',
'door',
'zone',
'shelf',
'spawn',
'elevator',
'building',
] as const
const registryKinds = getSelectableKinds().filter(
(kind) => !(allTypes as readonly string[]).includes(kind),
)
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
for (const type of subscribedKinds) {
emitter.on(`${type}:pointerdown` as any, onPointerDown as any)
}
return () => {
for (const type of subscribedKinds) {
emitter.off(`${type}:pointerdown` as any, onPointerDown as any)
}
}
}, [curvingFence, curvingWall, mode, movingNode])
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 2 || !isCommandModifier(event)) return
if (!(event.target instanceof HTMLCanvasElement)) return
const selectedIds = useViewer.getState().selection.selectedIds
const hoveredId = useViewer.getState().hoveredId as AnyNodeId | null
if (!hoveredId || !selectedIds.includes(hoveredId)) return
const node = useScene.getState().nodes[hoveredId]
if (!node || !canDirectRotateNode(node)) return
event.preventDefault()
event.stopPropagation()
const nodeId = node.id as AnyNodeId
const pointerId = event.pointerId
const startX = event.clientX
const sceneApi = createSceneApi(useScene)
let lastPatch: Partial<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(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
@@ -1274,12 +1466,13 @@ export const SelectionManager = () => {
let currentPhase = useEditor.getState().phase
let currentStructureLayer = useEditor.getState().structureLayer
const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
// Auto-switch between zones, structure, and furnish when clicking elements on the same level.
// Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan).
if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') {
if (isNodeInCurrentLevel(node)) {
const target = getSelectionTarget(node)
const target = resolveNodeSelectionTarget(node)
if (target) {
if (target.phase !== currentPhase) {
useEditor.getState().setPhase(target.phase)
@@ -1330,7 +1523,12 @@ export const SelectionManager = () => {
useEditor.getState().setEditingHole(null)
}
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
activeStrategy.handleSelect(
nodeToSelect,
event.nativeEvent,
modifierKeysRef.current,
selectedIdsBeforeRouting,
)
let nextMaterialTargetHandled = false
@@ -1435,9 +1633,11 @@ export const SelectionManager = () => {
emitter.on(`${type}:click` as any, onClick as any)
})
const onGridClick = () => {
const onGridClick = (event: GridEvent) => {
if (clickHandledRef.current) return
if (boxSelectHandled) return
const nativeEvent = event.nativeEvent
if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return
const { phase, structureLayer } = useEditor.getState()
const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect()
@@ -1506,7 +1706,10 @@ export const SelectionManager = () => {
const currentPhase = useEditor.getState().phase
let targetPhase: 'site' | 'structure' | 'furnish' | null = null
const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
const target = resolveNodeSelectionTarget(node)
let targetPhase: 'site' | 'structure' | 'furnish' | null = target?.phase ?? null
let targetStructureLayer = target?.structureLayer
let forceSelect = false
if (node.type === 'building' || node.type === 'site') {
@@ -1515,36 +1718,15 @@ export const SelectionManager = () => {
}
if (node.type === 'building') {
targetPhase = 'structure'
targetStructureLayer = 'elements'
}
} else if (
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'elevator' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment' ||
node.type === 'spawn' ||
node.type === 'window' ||
node.type === 'door'
) {
targetPhase = 'structure'
} else {
if (node.type === 'roof-segment' && currentPhase === 'structure') {
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
}
if (node.type === 'stair-segment' && currentPhase === 'structure') {
forceSelect = true // allow double click to dive into stair-segment even if already in structure phase
}
} else if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') {
targetPhase = 'structure'
} else {
targetPhase = 'furnish'
}
}
if (node.type === 'zone') {
@@ -1558,13 +1740,22 @@ export const SelectionManager = () => {
useEditor.getState().setPhase(targetPhase)
}
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
useEditor.getState().setStructureLayer('elements')
if (
targetPhase === 'structure' &&
targetStructureLayer &&
targetStructureLayer !== useEditor.getState().structureLayer
) {
useEditor.getState().setStructureLayer(targetStructureLayer)
}
const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
if (strategy) {
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
strategy.handleSelect(
node,
event.nativeEvent,
modifierKeysRef.current,
selectedIdsBeforeRouting,
)
}
}
}
@@ -5,6 +5,7 @@ import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
@@ -329,10 +330,15 @@ export function useFloorplanBackgroundPlacement({
const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep)
const wallLocked =
!bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1])
const snappedPoint =
wallLocked || wallAngleSnap
? wallSnapped
: alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey || bypassSnap })
let snappedPoint = wallSnapped
if (wallLocked) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap,
bypass: event.altKey || bypassSnap,
})
}
emitFloorplanGridEvent('click', snappedPoint, event)
@@ -15,6 +15,7 @@ import {
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { resolveElevatorSupportY } from '../../../lib/elevator-support'
import { consumePlacementDragRelease } from '../../../lib/placement-drag-release'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -160,6 +161,7 @@ export function MoveElevatorTool({
}
const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
wasCommitted = true
@@ -189,6 +191,11 @@ export function MoveElevatorTool({
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
wasCancelled = true
clearPreview()
@@ -231,6 +238,7 @@ export function MoveElevatorTool({
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
clearPreview()
@@ -247,6 +255,7 @@ export function MoveElevatorTool({
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [movingNode, exitMoveMode])
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { stripTransient } from './placement-math'
import { getDetachedAttachmentPreviewLift, stripTransient } from './placement-math'
describe('stripTransient', () => {
test('removes placement-only metadata flags before commit', () => {
@@ -8,3 +8,15 @@ describe('stripTransient', () => {
})
})
})
describe('getDetachedAttachmentPreviewLift', () => {
test('raises attach-only item previews while they are detached from their host', () => {
expect(getDetachedAttachmentPreviewLift('wall')).toBeGreaterThan(0)
expect(getDetachedAttachmentPreviewLift('wall-side')).toBeGreaterThan(0)
expect(getDetachedAttachmentPreviewLift('ceiling')).toBeGreaterThan(0)
})
test('keeps floor item previews on the floor', () => {
expect(getDetachedAttachmentPreviewLift(undefined)).toBe(0)
})
})
@@ -55,6 +55,12 @@ export function getGridAlignedDimensions(
return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
}
export function getDetachedAttachmentPreviewLift(
attachTo: AssetInput['attachTo'] | null | undefined,
): number {
return attachTo ? 0.45 : 0
}
/**
* Calculate cursor rotation in WORLD space from wall normal and orientation.
*/
@@ -144,6 +144,7 @@ export const floorStrategy = {
): CommitResult | null {
if (ctx.state.surface !== 'floor') return null
if (!(ctx.levelId && ctx.draftItem)) return null
if (ctx.draftItem.asset.attachTo) return null
const pos: [number, number, number] = [
ctx.gridPosition.x,
@@ -52,7 +52,12 @@ import {
type PreviewBounds,
updateLineGeometry,
} from '../shared/placement-box-geometry'
import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
import {
getDetachedAttachmentPreviewLift,
getGridAlignedDimensions,
snapToGrid,
snapUpToGridStep,
} from './placement-math'
import {
ceilingStrategy,
checkCanPlace,
@@ -732,6 +737,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
previousGridPos = [...gridPos]
gridPosition.current.set(...gridPos)
const cursorPosition = getFloorVisualPosition(gridPos)
if (!draft && asset.attachTo) {
cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
}
cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
// Floor items only rotate on Y; keep the preview box (and the live
// transform the 2D floorplan mirrors) aligned with the draft's
@@ -27,6 +27,7 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { swallowNextClick } from '../../editor/node-arrow-handles'
import { CursorSphere } from '../shared/cursor-sphere'
import { DragBoundingBox } from '../shared/drag-bounding-box'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
@@ -505,6 +506,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', commitAtCursor)
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!useEditor.getState().placementDragMode) return
if (event.button !== 0) return
swallowNextClick()
if (!hasMovedRef.current) {
exitMoveMode()
return
}
commitAtCursor({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as ClickTriggerEvent)
}
window.addEventListener('pointerup', onPlacementDragPointerUp)
// Listen on every common kind's click event too. mitt's typing keeps
// `${kind}:click` as a fixed union so the cast is safe at runtime —
// we're just routing them through the shared commit path.
@@ -539,6 +555,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
window.removeEventListener('keyup', onKeyUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', commitAtCursor)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.off(key, commitAtCursor as never)
@@ -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'
import { nodeRegistry } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
nodeRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useIsMobile } from '../../../hooks/use-mobile'
import { resolveSelectModeHelpHints } from '../../../lib/contextual-help'
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper'
import { ContextualHelperPanel } from './contextual-helper-panel'
import { ItemHelper } from './item-helper'
import { RegisteredToolHelper } from './registered-tool-helper'
import { RoofHelper } from './roof-helper'
type ActiveModifierKeys = {
command: boolean
shift: boolean
}
function useActiveModifierKeys(): ActiveModifierKeys {
const [modifiers, setModifiers] = useState<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() {
const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode)
const selectedIds = useViewer((s) => s.selection.selectedIds)
const isMobile = useIsMobile()
const modifiers = useActiveModifierKeys()
const selectedNodes = useScene(
useShallow((s) =>
selectedIds
.map((id) => s.nodes[id as AnyNodeId])
.filter((node): node is AnyNode => node !== undefined),
),
)
const selectModeHints = useMemo(
() =>
resolveSelectModeHelpHints({
selectedCount: selectedNodes.length,
hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)),
hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)),
commandPressed: modifiers.command,
shiftPressed: modifiers.shift,
}),
[modifiers.command, modifiers.shift, selectedNodes],
)
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null
if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate />
return <ItemHelper showEsc />
return <ItemHelper shiftPressed={modifiers.shift} showEsc />
}
if (mode === 'material-paint') {
return null
}
if (mode === 'select') {
return <ContextualHelperPanel hints={selectModeHints} />
}
// Registry-first: kinds with `def.toolHints` render through the generic
// `RegisteredToolHelper`. Today that covers ceiling / door / fence /
// item / shelf / slab / spawn / wall / window.
if (tool) {
const def = nodeRegistry.get(tool)
if (def?.toolHints && def.toolHints.length > 0) {
return <RegisteredToolHelper hints={def.toolHints} />
return <RegisteredToolHelper hints={def.toolHints} shiftPressed={modifiers.shift} />
}
}
// Legacy fallback — only `roof` remains because it hasn't migrated to
// `def.tool` / `def.toolHints` yet (no Stage D port). When roof
// migrates, this switch deletes outright.
if (tool === 'roof') return <RoofHelper />
if (tool === 'roof') return <RoofHelper shiftPressed={modifiers.shift} />
return null
}
@@ -1,40 +1,24 @@
import { ShortcutToken } from '../primitives/shortcut-token'
import { ContextualHelperPanel } from './contextual-helper-panel'
interface ItemHelperProps {
showEsc?: boolean
shiftPressed?: boolean
}
export function ItemHelper({ showEsc }: ItemHelperProps) {
export function ItemHelper({ showEsc, shiftPressed = false }: ItemHelperProps) {
return (
<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">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Place item</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="R" />
<span className="text-muted-foreground">Rotate counterclockwise</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="T" />
<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>
<ContextualHelperPanel
hints={[
{ keys: ['Left click'], label: 'Place item' },
{ keys: ['R'], label: 'Rotate counterclockwise' },
{ keys: ['T'], label: 'Rotate clockwise' },
{
keys: ['Shift'],
label: shiftPressed ? 'Guided constraints bypassed' : 'Free place',
active: shiftPressed,
},
{ keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
]}
/>
)
}
@@ -1,5 +1,5 @@
import type { ToolHint } from '@pascal-app/core'
import { ShortcutToken } from '../primitives/shortcut-token'
import { ContextualHelperPanel } from './contextual-helper-panel'
/**
* Generic helper panel rendered from `def.toolHints` data. Matches the
@@ -10,16 +10,22 @@ import { ShortcutToken } from '../primitives/shortcut-token'
* Drops the need for per-kind helper files entirely — kinds declare
* their hints as static data in their `NodeDefinition`.
*/
export function RegisteredToolHelper({ hints }: { hints: ToolHint[] }) {
export function RegisteredToolHelper({
hints,
shiftPressed = false,
}: {
hints: ToolHint[]
shiftPressed?: boolean
}) {
if (hints.length === 0) return null
return (
<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">
{hints.map((hint) => (
<div className="flex items-center gap-2 text-sm" key={`${hint.key}:${hint.label}`}>
<ShortcutToken value={hint.key} />
<span className="text-muted-foreground">{hint.label}</span>
</div>
))}
</div>
<ContextualHelperPanel
hints={hints.map((hint) => ({
keys: [hint.key],
label:
shiftPressed && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label,
active: shiftPressed && hint.key === 'Shift',
}))}
/>
)
}
@@ -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 (
<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">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Set corner</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
<ContextualHelperPanel
hints={[
{ keys: ['Left click'], label: 'Set corner' },
{
keys: ['Shift'],
label: shiftPressed ? 'Guided constraints bypassed' : 'Free corner',
active: shiftPressed,
},
{ keys: ['Esc'], label: 'Cancel' },
]}
/>
)
}
@@ -2,16 +2,11 @@
import type { AssetInput } from '@pascal-app/core'
import { resolveCdnUrl, useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useEffect } from 'react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from './../../../components/ui/primitives/tooltip'
import { triggerSFX } from './../../../lib/sfx-bus'
import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge'
import { CATALOG_ITEMS } from './catalog-items'
export function ItemCatalog({
@@ -68,12 +63,6 @@ export function ItemCatalog({
}
}, [categoryItems, selectedItem?.src, setSelectedItem])
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png'
if (attachTo === 'ceiling') return '/icons/ceiling.png'
return null
}
if (filteredItems.length === 0 && emptyState) {
return <>{emptyState}</>
}
@@ -86,7 +75,7 @@ export function ItemCatalog({
{leadingTile}
{filteredItems.map((item, index) => {
const isSelected = selectedItem?.src === item?.src
const attachmentIcon = getAttachmentIcon(item?.attachTo)
const snapTarget = resolveAssetSnapTarget(item?.attachTo)
return (
<button
className={cn(
@@ -114,14 +103,8 @@ export function ItemCatalog({
loading="eager"
src={resolveCdnUrl(item.thumbnail) || ''}
/>
{attachmentIcon && (
<div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60">
<img
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
className="h-4 w-4"
src={attachmentIcon}
/>
</div>
{snapTarget && (
<SnapTargetBadge className="absolute right-1 bottom-1" target={snapTarget} />
)}
</div>
<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'],
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: [
{
keys: ['Shift'],
action: 'Draw at any angle, bypassing the default 15° angle snap',
note: 'Hold while drawing walls, fences, slabs, ceilings, and zones.',
action: 'Bypass guided snapping and angle constraints',
note: 'Hold during the active gesture. Passive guide or measurement feedback may stay visible.',
},
{
keys: ['Shift'],
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">
<DialogTitle>Keyboard Shortcuts</DialogTitle>
<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>
</DialogHeader>
@@ -2,6 +2,7 @@ import { type AnyNodeId, type ChimneyNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import { SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -51,13 +52,15 @@ export const ChimneyTreeNode = memo(function ChimneyTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
width={14}
/>
<SnapTargetIcon target="roof">
<Image
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
width={14}
/>
</SnapTargetIcon>
}
isHovered={isHovered}
isLast={isLast}
@@ -1,9 +1,10 @@
'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 Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -22,6 +23,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({
}: DoorTreeNodeProps) {
const [isEditing, setIsEditing] = useState(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 isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
@@ -45,6 +47,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const snapTarget = resolveNodeSnapTarget(node) ?? 'wall'
return (
<TreeNodeWrapper
@@ -53,7 +56,9 @@ export const DoorTreeNode = memo(function DoorTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/door.png" width={14} />
<SnapTargetIcon target={snapTarget}>
<Image alt="" className="object-contain" height={14} src="/icons/door.png" width={14} />
</SnapTargetIcon>
}
isHovered={isHovered}
isLast={isLast}
@@ -2,6 +2,7 @@ import { type AnyNodeId, type DormerNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import { SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -57,13 +58,15 @@ export const DormerTreeNode = memo(function DormerTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
width={14}
/>
<SnapTargetIcon target="roof">
<Image
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
width={14}
/>
</SnapTargetIcon>
}
isHovered={isHovered}
isLast={isLast}
@@ -2,6 +2,7 @@ import { type AnyNodeId, type GutterNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import { SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -51,13 +52,15 @@ export const GutterTreeNode = memo(function GutterTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
width={14}
/>
<SnapTargetIcon target="roof">
<Image
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
width={14}
/>
</SnapTargetIcon>
}
isHovered={isHovered}
isLast={isLast}
@@ -3,9 +3,15 @@ import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useEffect, useState } from 'react'
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 { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
import {
focusTreeNode,
handleTreeSelection,
routeTreeSelectionToNode,
TreeNode,
TreeNodeWrapper,
} from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
const CATEGORY_ICONS: Record<string, string> = {
@@ -35,7 +41,8 @@ export const ItemTreeNode = memo(function ItemTreeNode({
const children = useScene(
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 isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
@@ -63,17 +70,15 @@ export const ItemTreeNode = memo(function ItemTreeNode({
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(
handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'structure') {
useEditor.getState().setPhase('furnish')
}
routeTreeSelectionToNode(node)
},
[nodeId, setSelection],
[node, nodeId, setSelection],
)
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
@@ -84,6 +89,7 @@ export const ItemTreeNode = memo(function ItemTreeNode({
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png'
const snapTarget = resolveNodeSnapTarget(node)
const defaultName = asset?.name || 'Item'
const hasChildren = children.length > 0
@@ -93,7 +99,15 @@ export const ItemTreeNode = memo(function ItemTreeNode({
depth={depth}
expanded={expanded}
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}
isLast={isLast}
isSelected={isSelected}
@@ -2,9 +2,14 @@ import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
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 { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
import {
focusTreeNode,
handleTreeSelection,
routeTreeSelectionToNode,
TreeNodeWrapper,
} from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
interface RegistryTreeNodeProps {
@@ -36,22 +41,21 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
const icon = presentation?.icon
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.png'
const snapTarget = resolveNodeSnapTarget(node)
const defaultName = node?.name || presentation?.label || 'Node'
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(
handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
routeTreeSelectionToNode(node)
},
[nodeId, setSelection],
[node, nodeId, setSelection],
)
return (
@@ -61,13 +65,25 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image
alt=""
className="object-contain opacity-60"
height={14}
src={iconSrc}
width={14}
/>
snapTarget ? (
<SnapTargetIcon target={snapTarget}>
<Image
alt=""
className="object-contain opacity-60"
height={14}
src={iconSrc}
width={14}
/>
</SnapTargetIcon>
) : (
<Image
alt=""
className="object-contain opacity-60"
height={14}
src={iconSrc}
width={14}
/>
)
}
isHovered={isHovered}
isLast={isLast}
@@ -5,9 +5,14 @@ import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor'
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'
interface ShelfTreeNodeProps {
@@ -34,6 +39,7 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
const children = useScene(
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 isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
@@ -63,17 +69,15 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(
handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
routeTreeSelectionToNode(node)
},
[nodeId, setSelection],
[node, nodeId, setSelection],
)
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 Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import { SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -51,13 +52,15 @@ export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
width={14}
/>
<SnapTargetIcon target="roof">
<Image
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
width={14}
/>
</SnapTargetIcon>
}
isHovered={isHovered}
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 { AnimatePresence, motion } from 'motion/react'
import { forwardRef, memo, useEffect, useRef } from 'react'
import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing'
import useEditor from '../../../../../store/use-editor'
export function handleTreeSelection(
e: React.MouseEvent,
@@ -53,6 +56,31 @@ export function focusTreeNode(nodeId: AnyNodeId) {
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 { BuildingTreeNode } from './building-tree-node'
import { CeilingTreeNode } from './ceiling-tree-node'
@@ -102,6 +130,7 @@ const treeNodeByType: Record<
ceiling: CeilingTreeNode,
chimney: ChimneyTreeNode,
dormer: DormerTreeNode,
downspout: RegistryTreeNode,
'solar-panel': SolarPanelTreeNode,
column: ColumnTreeNode,
elevator: ElevatorTreeNode,
@@ -266,10 +295,10 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
</motion.div>
) : null}
</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
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',
)}
>
@@ -1,9 +1,10 @@
'use client'
import { type AnyNodeId, useScene } from '@pascal-app/core'
import { type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -22,6 +23,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
}: WindowTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
const node = useScene((s) => s.nodes[nodeId] as WindowNode | undefined)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
@@ -45,6 +47,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const snapTarget = resolveNodeSnapTarget(node) ?? 'wall'
return (
<TreeNodeWrapper
@@ -53,7 +56,9 @@ export const WindowTreeNode = memo(function WindowTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/window.png" width={14} />
<SnapTargetIcon target={snapTarget}>
<Image alt="" className="object-contain" height={14} src="/icons/window.png" width={14} />
</SnapTargetIcon>
}
isHovered={isHovered}
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'
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
export {
resolveAssetSnapTarget,
resolveNodeSnapTarget,
type SnapTarget,
SnapTargetBadge,
SnapTargetIcon,
} from './components/ui/snap-target-badge'
export type { SaveStatus } from './hooks/use-auto-save'
// useDragAction is the React-side glue for the registry's DragAction
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
@@ -226,6 +233,7 @@ export {
linearUnitToMeters,
metersToLinearUnit,
} from './lib/measurements'
export { consumePlacementDragRelease } from './lib/placement-drag-release'
export {
addFreshPlacementMetadata,
getPlacementMetadataRecord,
@@ -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
* on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer`
* renders them. Returns the adjusted point. When `bypass` is true (Alt held)
* the point is returned unchanged and guides are cleared, matching the
* "No snap" affordance the placement tools advertise.
* renders them. Returns the adjusted point. When `bypass` is true (Alt for
* alignment-only bypass, or Shift for the full guided-constraint bypass) the
* point is returned unchanged and guides are cleared.
*
* `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`);
* the scene is stable during a single drag, so re-collecting per pointer-move
@@ -42,7 +42,7 @@ export function applyFloorplanAlignment(
point: readonly [number, number],
movingAnchors: AlignmentAnchor[],
candidates: AlignmentAnchor[],
opts?: { bypass?: boolean; threshold?: number },
opts?: { applySnap?: boolean; bypass?: boolean; threshold?: number },
): FloorplanAlignmentResult {
if (opts?.bypass) {
useAlignmentGuides.getState().clear()
@@ -57,7 +57,7 @@ export function applyFloorplanAlignment(
useAlignmentGuides.getState().set(result.guides)
if (!result.snap) {
if (!result.snap || opts?.applySnap === false) {
return { point: [point[0], point[1]], snapped: false, guides: result.guides }
}
return {
@@ -81,8 +81,9 @@ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
*
* Used by BOTH the move-preview branch and the click-commit handler so the
* committed vertex lands exactly where the preview showed it. Caller owns the
* per-kind precedence (existing-wall endpoint/join snap wins; angle-snap
* suppresses alignment) and only calls this when alignment should apply.
* per-kind precedence: existing-wall endpoint/join snap can still win, while
* angle-locked segments can pass `applySnap: false` to publish passive guide
* feedback without pulling the endpoint off its constrained ray.
*
* `excludeIds` drops those nodes' anchors from the candidate pool — used when
* dragging a wall / fence endpoint so the moving endpoint doesn't try to
@@ -90,7 +91,12 @@ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
*/
export function alignFloorplanDraftPoint(
point: readonly [number, number],
opts?: { bypass?: boolean; threshold?: number; excludeIds?: readonly string[] },
opts?: {
applySnap?: boolean
bypass?: boolean
threshold?: number
excludeIds?: readonly string[]
},
): [number, number] {
if (opts?.bypass) {
useAlignmentGuides.getState().clear()
@@ -105,7 +111,7 @@ export function alignFloorplanDraftPoint(
point,
[{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }],
candidates,
{ threshold: opts?.threshold },
{ applySnap: opts?.applySnap, threshold: opts?.threshold },
)
return snapped
}
@@ -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,
useScene,
} from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import {
consumePlacementDragRelease,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three'
import {
@@ -59,11 +64,22 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
let lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}
const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event)
if (!target) return
if (!target) {
clearTarget()
return
}
lastTarget = target
const sx = Math.round(target.localX * 20) / 20
@@ -90,8 +106,10 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId
const st = useScene.getState()
@@ -176,16 +194,29 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!lastTarget) return
onRoofClick({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as RoofEvent)
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
// Safety restore — if the tool is unmounted by something other than
// a commit / cancel path (e.g. tool change, selection wipe), leave
+19 -8
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
import { boxVentDefinition } from './definition'
@@ -122,16 +123,26 @@ const BoxVentTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<BoxVentPreview node={previewNode} />
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[0.6, 0.4, 0.6]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<BoxVentPreview node={previewNode} />
</group>
</group>
</group>
</group>
</group>
)}
</>
)
}
+15 -1
View File
@@ -8,7 +8,13 @@ import {
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import {
CursorSphere,
consumePlacementDragRelease,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
@@ -151,6 +157,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
}
const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter
wasCommitted = true
@@ -169,6 +176,11 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
// Revert mesh position and rotation immediately
const mesh = sceneRegistry.nodes.get(nodeId)
@@ -190,6 +202,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
if (!wasCommitted) {
@@ -207,6 +220,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [exitMoveMode]) // stable — node values captured via refs at mount
+1
View File
@@ -150,6 +150,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
toolHints: [
{ key: 'Left click', label: 'Trace ceiling outline' },
{ key: 'Enter', label: 'Finish ceiling' },
{ key: 'Shift', label: 'Free outline' },
{ key: 'Esc', label: 'Cancel' },
],
+10
View File
@@ -14,6 +14,7 @@ import {
} from '@pascal-app/core'
import {
CursorSphere,
consumePlacementDragRelease,
markToolCancelConsumed,
triggerSFX,
useAlignmentGuides,
@@ -189,6 +190,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
}
const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
if (isFloorplanSourcedEvent(event)) return
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
@@ -214,6 +216,12 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
activatedAtRef.current = 0
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
clearPreview()
useAlignmentGuides.getState().clear()
@@ -225,6 +233,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
useAlignmentGuides.getState().clear()
@@ -236,6 +245,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [exitMoveMode, node.id])
+25 -2
View File
@@ -10,7 +10,7 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { triggerSFX, useEditor } from '@pascal-app/editor'
import { consumePlacementDragRelease, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
@@ -89,9 +89,19 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
roofSegmentId: node.roofSegmentId,
})
const clearTarget = () => {
lastTarget = null
setSegmentXform(null)
setHitLocal(null)
setPreviewSegment(null)
}
const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event)
if (!target) return
if (!target) {
clearTarget()
return
}
lastTarget = target
const sx = Math.round(target.localX * 20) / 20
@@ -156,14 +166,27 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
event.stopPropagation()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!lastTarget) return
onClick({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as RoofEvent)
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onClick)
emitter.on('roof:leave', clearTarget)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick)
emitter.off('roof:leave', clearTarget)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [activeBuildingId, node, setMovingNode, setSelection])
+24 -12
View File
@@ -14,6 +14,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { chimneyDefinition } from './definition'
import ChimneyPreview from './preview'
@@ -139,19 +140,30 @@ const ChimneyTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !segmentXform || !hitLocal || !previewSegment) return null
// Outer group mirrors the real renderer's `position={segment.position}
// rotation-y={segment.rotation}` chain by composing the segment's
// building-local matrix (which walks roof + level + segment). Inner
// group offsets by the cursor's segment-local x/z so the chimney
// geometry (built with `position[0,2] = 0`) lands under the cursor.
return (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
<group position={[hitLocal[0], 0, hitLocal[2]]}>
<ChimneyPreview node={previewNode} segment={previewSegment} />
</group>
</group>
<>
<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}
// rotation-y={segment.rotation}` chain by composing the segment's
// building-local matrix (which walks roof + level + segment). Inner
// group offsets by the cursor's segment-local x/z so the chimney
// geometry (built with `position[0,2] = 0`) lands under the cursor.
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
<group position={[hitLocal[0], 0, hitLocal[2]]}>
<ChimneyPreview node={previewNode} segment={previewSegment} />
</group>
</group>
)}
</>
)
}
+1 -1
View File
@@ -359,7 +359,7 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
tool: () => import('./tool'),
toolHints: [
{ key: 'Left click', label: 'Place column' },
{ key: 'Alt', label: 'No snap' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' },
],
floorplan: buildColumnFloorplan,
+9
View File
@@ -16,6 +16,7 @@ import {
import {
CursorSphere,
commitFreshPlacementSubtree,
consumePlacementDragRelease,
DragBoundingBox,
getFloorStackPreviewPosition,
markToolCancelConsumed,
@@ -165,6 +166,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
}
const onGridClick = (event: GridEvent) => {
if (committed) return
if (!hasMoved) return
useAlignmentGuides.getState().clear()
// Commit at the last previewed position so the alignment snap (which
@@ -225,6 +227,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear()
@@ -244,12 +251,14 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
+33 -2
View File
@@ -9,7 +9,12 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import {
consumePlacementDragRelease,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three'
import {
@@ -57,11 +62,22 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
let lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}
const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event)
if (!target) return
if (!target) {
clearTarget()
return
}
lastTarget = target
const sx = Math.round(target.localX * 20) / 20
@@ -88,8 +104,10 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId
const st = useScene.getState()
@@ -168,16 +186,29 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!lastTarget) return
onRoofClick({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as RoofEvent)
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
+19 -8
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { cupolaDefinition } from './definition'
@@ -114,16 +115,26 @@ const CupolaTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<CupolaPreview node={previewNode} />
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[0.8, 1.2, 0.8]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<CupolaPreview node={previewNode} />
</group>
</group>
</group>
</group>
</group>
)}
</>
)
}
+1
View File
@@ -219,6 +219,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
toolHints: [
{ key: 'Left click', label: 'Place door on wall' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' },
],
+44 -6
View File
@@ -15,6 +15,7 @@ import {
import {
calculateCursorRotation,
calculateItemRotation,
consumePlacementDragRelease,
EDITOR_LAYER,
getSideFromNormal,
isValidWallSideFace,
@@ -80,6 +81,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
let currentHostId: string | null = movingDoorNode.parentId
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
let committed = false
let lastTarget: {
wallNode: WallEvent['node']
wallId: string
@@ -91,6 +93,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
valid: boolean
event: WallEvent
} | null = null
let lastRoofEvent: RoofEvent | null = null
const markHostDirty = (hostId: string | null) => {
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
@@ -254,34 +257,50 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onWallEnter = (event: WallEvent) => {
const target = resolveMoveTarget(event)
if (!target) return
if (!target) {
onWallLeave()
return
}
lastTarget = target
lastRoofEvent = null
applyPreview(target)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (!isValidWallSideFace(event.normal)) {
onWallLeave()
return
}
if (isCurvedWall(event.node)) {
hideCursor()
onWallLeave()
return
}
if (event.node.parentId !== getLevelId()) {
onWallLeave()
return
}
if (event.node.parentId !== getLevelId()) return
const target = resolveMoveTarget(event)
if (!target) return
if (!target) {
onWallLeave()
return
}
lastTarget = target
lastRoofEvent = null
applyPreview(target)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (committed) return
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
if (!target?.valid) return
committed = true
let placedId: string
@@ -349,6 +368,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useLiveTransforms.getState().clear(movingDoorNode.id)
dragAnchor = null
lastTarget = null
lastRoofEvent = null
if (isNew) return
if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId)
@@ -387,10 +407,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event)
if (!target) return
if (!target) {
onRoofLeave()
return
}
// Wall-frame drag anchor / live transform don't apply on a roof face.
dragAnchor = null
lastTarget = null
lastRoofEvent = event
useLiveTransforms.getState().clear(movingDoorNode.id)
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, {
@@ -416,8 +440,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = resolveRoofMoveTarget(event)
if (!target?.valid) return
committed = true
const segmentId = target.segment.id
let placedId: string
@@ -487,6 +513,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useLiveTransforms.getState().clear(movingDoorNode.id)
dragAnchor = null
lastTarget = null
lastRoofEvent = null
if (isNew) return
if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId)
@@ -527,6 +554,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (lastTarget) {
onWallClick(lastTarget.event)
return
}
if (lastRoofEvent) onRoofClick(lastRoofEvent)
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
@@ -536,6 +572,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
@@ -572,6 +609,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [movingDoorNode, exitMoveMode])
+60 -15
View File
@@ -3,6 +3,7 @@ import {
collectAlignmentAnchors,
DoorNode,
emitter,
type GridEvent,
isCurvedWall,
type RoofEvent,
type RoofNode,
@@ -22,12 +23,13 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
worldToSelectedBuildingLocal,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
@@ -39,6 +41,10 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false,
})
const FALLBACK_WIDTH = 0.9
const FALLBACK_HEIGHT = 2.1
const roofFallbackPoint = new Vector3()
/**
* Door tool — places DoorNodes on walls and on roof-segment wall faces
* (the generated base walls under a roof, including coplanar gable ends).
@@ -99,16 +105,47 @@ const DoorTool: React.FC = () => {
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
}
const showFallbackCursor = (event: GridEvent) => {
if (draftRef.current) return
const [x, y, z] = event.localPosition
updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false)
useAlignmentGuides.getState().clear()
}
const showRoofFallbackCursor = (event: RoofEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
useAlignmentGuides.getState().clear()
}
const showWallFallbackCursor = (event: WallEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
useAlignmentGuides.getState().clear()
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
showWallFallbackCursor(event)
return
}
const levelId = getLevelId()
if (!levelId) return
if (event.node.parentId !== levelId) return
if (!levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (event.node.parentId !== levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
destroyDraft()
@@ -158,13 +195,21 @@ const DoorTool: React.FC = () => {
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
if (!isValidWallSideFace(event.normal)) {
destroyDraft()
hideCursor()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (event.node.parentId !== getLevelId()) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
@@ -373,10 +418,8 @@ const DoorTool: React.FC = () => {
if (!target) {
// On the roof but not over a placeable wall face (slope, soffit,
// or a face the door cannot fit on).
if (draftRef.current?.roofSegmentId) {
destroyDraft()
hideCursor()
}
destroyDraft()
showRoofFallbackCursor(event)
return
}
const { segment, face, position } = target
@@ -483,6 +526,7 @@ const DoorTool: React.FC = () => {
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('grid:move', showFallbackCursor)
emitter.on('tool:cancel', onCancel)
return () => {
@@ -498,12 +542,13 @@ const DoorTool: React.FC = () => {
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('grid:move', showFallbackCursor)
emitter.off('tool:cancel', onCancel)
}
}, [])
// Cursor geometry: door outline (default 0.9 × 2.1 × 0.07)
const boxGeo = new BoxGeometry(0.9, 2.1, 0.07)
// Cursor geometry: door outline.
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
+36 -27
View File
@@ -3,6 +3,7 @@
import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { dormerDefinition } from './definition'
import DormerPreview from './preview'
import { useDormerPlacement } from './use-dormer-placement'
@@ -47,36 +48,44 @@ const DormerTool = () => {
[],
)
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
onCommit: (hit, rotation) => {
const state = useScene.getState()
const dormer = DormerNode.parse({
...dormerDefinition.defaults(),
name: `Dormer ${nextDormerNumber(state.nodes)}`,
roofSegmentId: hit.segment.id,
parentId: hit.segment.id,
// Anchor at the slope height so the renderer matches the ghost.
// The CSG still carves cleanly because it inverts T(position)
// when bringing the host into dormer-local.
position: [hit.localX, hit.localY, hit.localZ],
rotation,
})
state.createNode(dormer, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [dormer.id] })
},
})
if (!activeBuildingId || !segmentXform || !hitLocal) return null
const { activeBuildingId, clearPreview, segmentXform, hitLocal, ghostRotation } =
useDormerPlacement({
onCommit: (hit, rotation) => {
const state = useScene.getState()
const dormer = DormerNode.parse({
...dormerDefinition.defaults(),
name: `Dormer ${nextDormerNumber(state.nodes)}`,
roofSegmentId: hit.segment.id,
parentId: hit.segment.id,
// Anchor at the slope height so the renderer matches the ghost.
// The CSG still carves cleanly because it inverts T(position)
// when bringing the host into dormer-local.
position: [hit.localX, hit.localY, hit.localZ],
rotation,
})
state.createNode(dormer, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [dormer.id] })
},
})
return (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
<group position={hitLocal}>
<group rotation-y={ghostRotation}>
<DormerPreview node={previewNode} />
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={clearPreview}
size={[1.8, 1.8, 1.4]}
/>
{activeBuildingId && segmentXform && hitLocal && (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
<group position={hitLocal}>
<group rotation-y={ghostRotation}>
<DormerPreview node={previewNode} />
</group>
</group>
</group>
</group>
</group>
)}
</>
)
}
@@ -6,7 +6,7 @@ import {
type RoofSegmentNode,
sceneRegistry,
} from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor'
import { consumePlacementDragRelease, triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
@@ -58,6 +58,7 @@ export function useDormerPlacement(opts: {
onCommit: (hit: DormerPlacementHit, rotation: number) => void
}): {
activeBuildingId: string | undefined
clearPreview: () => void
segmentXform: DormerSegmentTransform | null
hitLocal: [number, number, number] | null
ghostRotation: number
@@ -78,6 +79,11 @@ export function useDormerPlacement(opts: {
const onCommitRef = useRef(opts.onCommit)
onCommitRef.current = opts.onCommit
const clearPreview = () => {
setSegmentXform(null)
setHitLocal(null)
}
useEffect(() => {
if (!activeBuildingId) return
@@ -99,6 +105,7 @@ export function useDormerPlacement(opts: {
const roofDrag = relativeStartRef.current
? createRelativeRoofDrag(relativeStartRef.current)
: null
let committed = false
let lastRelativeHit: DormerPlacementHit | null = null
const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => {
@@ -139,15 +146,27 @@ export function useDormerPlacement(opts: {
}
const onClick = (event: RoofEvent) => {
if (committed) return
const hit = roofDrag
? (lastRelativeHit ?? resolvePlacementHit(event))
: resolvePlacementHit(event)
if (!hit) return
committed = true
onCommitRef.current(hit, ghostRotationRef.current)
triggerSFX('sfx:item-place')
event.stopPropagation()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (committed) return
if (!consumePlacementDragRelease(event)) return
const hit = roofDrag ? lastRelativeHit : null
if (!hit) return
committed = true
onCommitRef.current(hit, ghostRotationRef.current)
triggerSFX('sfx:item-place')
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'r' && e.key !== 'R') return
const target = e.target as HTMLElement | null
@@ -166,17 +185,20 @@ export function useDormerPlacement(opts: {
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onClick)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [activeBuildingId])
return {
activeBuildingId: activeBuildingId ?? undefined,
clearPreview,
segmentXform,
hitLocal,
ghostRotation,
+23 -14
View File
@@ -17,6 +17,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Vector3 } from 'three'
import { computeEaveY } from '../gutter/eave-snap'
import { resolveGutterOutletById } from '../gutter/outlet-lookup'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { downspoutDefinition } from './definition'
import DownspoutPreview from './preview'
import { computeDownspoutRouting, type DownspoutRouting } from './routing'
@@ -162,22 +163,30 @@ const DownspoutTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !target) return null
return (
<group position={target.segment.position} rotation-y={target.segment.rotation}>
<group
position={[target.gutter.position[0], target.segment.eaveY, target.gutter.position[2]]}
rotation-y={target.gutter.rotation}
>
<group position={[target.outlet.x, target.outlet.y, target.outlet.z]}>
<DownspoutPreview
node={previewNodeWithDefaults(previewNode, target)}
routing={target.routing}
/>
<>
<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.gutter.position[0], target.segment.eaveY, target.gutter.position[2]]}
rotation-y={target.gutter.rotation}
>
<group position={[target.outlet.x, target.outlet.y, target.outlet.z]}>
<DownspoutPreview
node={previewNodeWithDefaults(previewNode, target)}
routing={target.routing}
/>
</group>
</group>
</group>
</group>
</group>
)}
</>
)
}
+33 -2
View File
@@ -9,7 +9,12 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import {
consumePlacementDragRelease,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three'
import {
@@ -58,11 +63,22 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
let lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}
const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event)
if (!target) return
if (!target) {
clearTarget()
return
}
lastTarget = target
const sx = Math.round(target.localX * 20) / 20
@@ -89,8 +105,10 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId
const st = useScene.getState()
@@ -169,16 +187,29 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!lastTarget) return
onRoofClick({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as RoofEvent)
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
+19 -8
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
import { eyebrowVentDefinition } from './definition'
@@ -117,16 +118,26 @@ const EyebrowVentTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<EyebrowVentPreview node={previewNode} />
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[1.2, 0.4, 0.5]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<EyebrowVentPreview node={previewNode} />
</group>
</group>
</group>
</group>
</group>
)}
</>
)
}
+10
View File
@@ -14,6 +14,7 @@ import {
} from '@pascal-app/core'
import {
CursorSphere,
consumePlacementDragRelease,
markToolCancelConsumed,
snapFenceDraftPoint,
triggerSFX,
@@ -227,6 +228,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
@@ -264,6 +266,12 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
activatedAtRef.current = 0
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [fenceId] })
@@ -279,6 +287,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
if (!wasCommitted) {
@@ -303,6 +312,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [exitMoveMode, node])
+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
// publish the guide. Alt bypasses. Returns the (possibly snapped) point.
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
// snapping. Returns the possibly snapped point.
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
+32 -2
View File
@@ -10,7 +10,12 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import {
consumePlacementDragRelease,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
@@ -71,8 +76,15 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
let lastSnap: [number, number] | null = null
let lastTarget: GutterDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setTarget(null)
}
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
const target = roofDrag.resolve(event)
if (!target) return null
@@ -85,7 +97,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
const updatePreview = (event: RoofEvent) => {
const roof = event.node as RoofNode
const target = resolveTarget(event)
if (!target) return
if (!target) {
clearTarget()
return
}
lastTarget = target
// Same snap math as the placement tool — picking-up and putting-
@@ -120,8 +135,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? resolveTarget(event)
if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId
const { snap } = target
const st = useScene.getState()
@@ -201,16 +218,29 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!lastTarget) return
onRoofClick({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as RoofEvent)
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
+19 -11
View File
@@ -11,6 +11,7 @@ import {
import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { gutterDefinition } from './definition'
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
@@ -142,19 +143,26 @@ const GutterTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !target) return null
return (
<group position={target.roof.position} rotation-y={target.roof.rotation}>
<group position={target.segment.position} rotation-y={target.segment.rotation}>
<group
position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]}
rotation-y={target.snap.rotation}
>
<GutterPreview node={previewNode} />
<>
<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.segment.position} rotation-y={target.segment.rotation}>
<group
position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]}
rotation-y={target.snap.rotation}
>
<GutterPreview node={previewNode} />
</group>
</group>
</group>
</group>
</group>
)}
</>
)
}
+32 -2
View File
@@ -9,7 +9,12 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import {
consumePlacementDragRelease,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
import {
createRelativeRoofDrag,
@@ -60,8 +65,15 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
let lastSnap: [number, number] | null = null
let lastTarget: RidgeVentDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setPreviewPos(null)
}
const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => {
const target = roofDrag.resolve(event)
if (!target) return null
@@ -75,7 +87,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
const updatePreview = (event: RoofEvent) => {
const target = resolveTarget(event)
if (!target) return
if (!target) {
clearTarget()
return
}
lastTarget = target
const sx = Math.round(target.localX * 20) / 20
@@ -100,8 +115,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? resolveTarget(event)
if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId
const st = useScene.getState()
@@ -180,16 +197,29 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!lastTarget) return
onRoofClick({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as RoofEvent)
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
+24 -7
View File
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { resolveRidgeSnap } from '../shared/ridge-snap'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { ridgeVentDefinition } from './definition'
import RidgeVentPreview from './preview'
@@ -135,14 +136,30 @@ const RidgeVentTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos) return null
return (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<RidgeVentPreview node={previewNode} />
</group>
</group>
<>
<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 rotation-y={previewYaw}>
<RidgeVentPreview node={previewNode} />
</group>
</group>
)}
</>
)
}
@@ -20,6 +20,7 @@ import {
import {
CursorSphere,
commitFreshPlacementSubtree,
consumePlacementDragRelease,
DragBoundingBox,
getFloorStackPreviewPosition,
resolvePlanarCursorPosition,
@@ -359,6 +360,7 @@ export const MoveRoofTool: React.FC<{
}
const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
if (!hasMoved) return
const [localX, , localZ] = lastLocalPosition
@@ -394,6 +396,11 @@ export const MoveRoofTool: React.FC<{
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
wasCancelled = true
useLiveTransforms.getState().clear(movingNode.id)
@@ -454,6 +461,7 @@ export const MoveRoofTool: React.FC<{
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
// Restore segment wrapper visibility (React will re-sync on next render)
@@ -485,6 +493,7 @@ export const MoveRoofTool: React.FC<{
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
@@ -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] {
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
if (buildingObj) buildingObj.worldToLocal(point)
if (buildingObj) {
buildingObj.updateWorldMatrix(true, false)
buildingObj.worldToLocal(point)
}
return [point.x, point.y, point.z]
}
+1
View File
@@ -258,6 +258,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
tool: () => import('./tool'),
toolHints: [
{ key: 'Left click', label: 'Place shelf' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' },
],
+28 -2
View File
@@ -10,7 +10,12 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import {
consumePlacementDragRelease,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import {
@@ -65,8 +70,14 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
let lastSnapX = 0
let lastSnapZ = 0
let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
setHasHit(false)
}
// Resolve which segment the cursor is over, then derive the same
// preview transform stack the placement tool uses (`skylight/tool.tsx`):
// analytical surface normal in segment-local frame → outer yaw =
@@ -77,7 +88,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
const roof = event.node as RoofNode
const target = roofDrag.resolve(event)
if (!target) {
setHasHit(false)
clearTarget()
return false
}
lastTarget = target
@@ -113,10 +124,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const st = useScene.getState()
const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId
const finalRotation = original.rotation
@@ -206,16 +219,29 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!lastTarget) return
onRoofClick({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as RoofEvent)
}
emitter.on('roof:move', onRoofMove)
emitter.on('roof:enter', onRoofEnter)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', onRoofMove)
emitter.off('roof:enter', onRoofEnter)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
+19 -8
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { skylightDefinition } from './definition'
@@ -109,16 +110,26 @@ const SkylightTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<SkylightPreview node={previewNode} />
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[1.2, 0.2, 1]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<SkylightPreview node={previewNode} />
</group>
</group>
</group>
</group>
</group>
)}
</>
)
}
+1
View File
@@ -201,6 +201,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
toolHints: [
{ key: 'Left click', label: 'Trace slab outline' },
{ key: 'Enter', label: 'Finish slab' },
{ key: 'Shift', label: 'Free outline' },
{ key: 'Esc', label: 'Cancel' },
],
+10
View File
@@ -16,6 +16,7 @@ import {
} from '@pascal-app/core'
import {
CursorSphere,
consumePlacementDragRelease,
getSegmentGridStep,
markToolCancelConsumed,
resolveAlignmentForActiveBuilding,
@@ -214,6 +215,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
}
const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
if (isFloorplanSourcedEvent(event)) return
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
@@ -245,6 +247,12 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
activatedAtRef.current = 0
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
// No scene state to roll back — we never wrote anything. Just
// restore the mesh visual.
@@ -258,6 +266,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
useAlignmentGuides.getState().clear()
@@ -269,6 +278,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [exitMoveMode, node.id, node.parentId])
+32 -2
View File
@@ -9,7 +9,13 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import {
consumePlacementDragRelease,
EDITOR_LAYER,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import {
@@ -91,11 +97,20 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
let lastSnapX = 0
let lastSnapZ = 0
let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
setHasHit(false)
}
const updateGhost = (event: RoofEvent) => {
const target = roofDrag.resolve(event)
if (!target) return
if (!target) {
clearTarget()
return
}
lastTarget = target
const sx = Math.round(target.localX * 20) / 20
@@ -127,10 +142,12 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const st = useScene.getState()
const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId
@@ -227,16 +244,29 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!lastTarget) return
onRoofClick({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as RoofEvent)
}
emitter.on('roof:move', updateGhost)
emitter.on('roof:enter', updateGhost)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', updateGhost)
emitter.off('roof:enter', updateGhost)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
+19 -8
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { solarPanelDefinition } from './definition'
@@ -130,16 +131,26 @@ const SolarPanelTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<SolarPanelPreview node={previewNode} />
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[1.8, 0.2, 1.2]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<SolarPanelPreview node={previewNode} />
</group>
</group>
</group>
</group>
</group>
)}
</>
)
}
+1
View File
@@ -100,6 +100,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
tool: () => import('./tool'),
toolHints: [
{ key: 'Left click', label: 'Place spawn point' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' },
],
+33 -2
View File
@@ -9,7 +9,12 @@ import {
type TurbineVentNode,
useScene,
} from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import {
consumePlacementDragRelease,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three'
import {
@@ -58,11 +63,22 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
let lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null
let committed = false
const roofDrag = createRelativeRoofDrag(original)
const clearTarget = () => {
lastTarget = null
lastSnap = null
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}
const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event)
if (!target) return
if (!target) {
clearTarget()
return
}
lastTarget = target
const sx = Math.round(target.localX * 20) / 20
@@ -89,8 +105,10 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId
const st = useScene.getState()
@@ -169,16 +187,29 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!lastTarget) return
onRoofClick({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as RoofEvent)
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', clearTarget)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', clearTarget)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
+19 -8
View File
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
import { turbineVentDefinition } from './definition'
@@ -117,16 +118,26 @@ const TurbineVentTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
return (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<TurbineVentPreview node={previewNode} />
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
}}
size={[0.5, 0.8, 0.5]}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<TurbineVentPreview node={previewNode} />
</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
// publish the guide. Alt bypasses. Returns the (possibly snapped) point.
const alignPoint = (point: WallPlanPoint, bypass: boolean): WallPlanPoint => {
if (bypass || alignmentCandidates.length === 0) {
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
// snapping. Returns the possibly snapped point.
const alignPoint = (
point: WallPlanPoint,
options: { applySnap?: boolean; bypass?: boolean },
): WallPlanPoint => {
if (options.bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return point
}
@@ -520,7 +524,9 @@ export const WallTool: React.FC = () => {
threshold: ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(ar.guides)
return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point
return ar.snap && options.applySnap !== false
? [point[0] + ar.snap.dx, point[1] + ar.snap.dz]
: point
}
const stopDrafting = () => {
@@ -554,7 +560,10 @@ export const WallTool: React.FC = () => {
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
})
gridPosition = alignPoint(snapResult.point, bypassAlign || angleLocked)
gridPosition = alignPoint(snapResult.point, {
applySnap: !angleLocked,
bypass: bypassAlign,
})
// Stand the magnetic beacon at the endpoint when it locked onto an
// existing wall corner / wall point; clear it for plain grid/angle moves.
useWallSnapIndicator
@@ -635,7 +644,7 @@ export const WallTool: React.FC = () => {
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}).point,
bypassAlign,
{ bypass: bypassAlign },
)
gridPosition = snappedStart
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
@@ -666,7 +675,10 @@ export const WallTool: React.FC = () => {
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}).point,
bypassAlign || angleLocked,
{
applySnap: !angleLocked,
bypass: bypassAlign,
},
)
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
+1
View File
@@ -197,6 +197,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
toolHints: [
{ key: 'Left click', label: 'Place window on wall' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' },
],
+44 -6
View File
@@ -15,6 +15,7 @@ import {
import {
calculateCursorRotation,
calculateItemRotation,
consumePlacementDragRelease,
EDITOR_LAYER,
getSideFromNormal,
isValidWallSideFace,
@@ -99,6 +100,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
let currentHostId: string | null = movingWindowNode.parentId
let committed = false
let dragAnchor: {
wallId: string
rawX: number
@@ -117,6 +119,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
valid: boolean
event: WallEvent
} | null = null
let lastRoofEvent: RoofEvent | null = null
const markHostDirty = (hostId: string | null) => {
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
@@ -285,29 +288,44 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onWallEnter = (event: WallEvent) => {
const target = resolveMoveTarget(event)
if (!target) return
if (!target) {
onWallLeave()
return
}
lastTarget = target
lastRoofEvent = null
applyPreview(target)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (!isValidWallSideFace(event.normal)) {
onWallLeave()
return
}
if (isCurvedWall(event.node)) {
hideCursor()
onWallLeave()
return
}
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
if (event.node.parentId !== getLevelId()) {
onWallLeave()
return
}
const target = resolveMoveTarget(event)
if (!target) return
if (!target) {
onWallLeave()
return
}
lastTarget = target
lastRoofEvent = null
applyPreview(target)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (committed) return
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level
@@ -315,6 +333,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
if (!target?.valid) return
committed = true
let placedId: string
@@ -387,6 +406,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useLiveTransforms.getState().clear(movingWindowNode.id)
dragAnchor = null
lastTarget = null
lastRoofEvent = null
if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall
if (currentHostId && currentHostId !== original.parentId) {
@@ -430,10 +450,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event)
if (!target) return
if (!target) {
onRoofLeave()
return
}
// Wall-frame drag anchor / live transform don't apply on a roof face.
dragAnchor = null
lastTarget = null
lastRoofEvent = event
useLiveTransforms.getState().clear(movingWindowNode.id)
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, {
@@ -459,8 +483,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = resolveRoofMoveTarget(event)
if (!target?.valid) return
committed = true
const segmentId = target.segment.id
let placedId: string
@@ -531,6 +557,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useLiveTransforms.getState().clear(movingWindowNode.id)
dragAnchor = null
lastTarget = null
lastRoofEvent = null
if (isNew) return
if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId)
@@ -571,6 +598,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (lastTarget) {
onWallClick(lastTarget.event)
return
}
if (lastRoofEvent) onRoofClick(lastRoofEvent)
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
@@ -580,6 +616,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
@@ -617,6 +654,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [movingWindowNode, exitMoveMode])
+60 -14
View File
@@ -2,6 +2,7 @@ import {
type AnyNodeId,
collectAlignmentAnchors,
emitter,
type GridEvent,
isCurvedWall,
type RoofEvent,
type RoofNode,
@@ -23,12 +24,13 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
worldToSelectedBuildingLocal,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -41,6 +43,11 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false,
})
const FALLBACK_WIDTH = 1.5
const FALLBACK_HEIGHT = 1.5
const FALLBACK_SILL_LIFT = 0.45
const roofFallbackPoint = new Vector3()
/**
* Window tool — places WindowNodes on walls and on roof-segment wall
* faces (the generated base walls under a roof, including coplanar gable
@@ -103,17 +110,48 @@ const WindowTool: React.FC = () => {
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
}
const showFallbackCursor = (event: GridEvent) => {
if (draftRef.current) return
const [x, y, z] = event.localPosition
updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
useAlignmentGuides.getState().clear()
}
const showRoofFallbackCursor = (event: RoofEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
useAlignmentGuides.getState().clear()
}
const showWallFallbackCursor = (event: WallEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
useAlignmentGuides.getState().clear()
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
showWallFallbackCursor(event)
return
}
const levelId = getLevelId()
if (!levelId) return
if (!levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
// Only interact with walls on the current level
if (event.node.parentId !== levelId) return
if (event.node.parentId !== levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
destroyDraft()
@@ -167,14 +205,22 @@ const WindowTool: React.FC = () => {
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
showWallFallbackCursor(event)
return
}
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
if (event.node.parentId !== getLevelId()) {
destroyDraft()
showWallFallbackCursor(event)
return
}
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
@@ -395,10 +441,8 @@ const WindowTool: React.FC = () => {
if (!target) {
// On the roof but not over a placeable wall face (slope, soffit,
// or a face the window cannot fit on).
if (draftRef.current?.roofSegmentId) {
destroyDraft()
hideCursor()
}
destroyDraft()
showRoofFallbackCursor(event)
return
}
const { segment, face, position } = target
@@ -499,6 +543,7 @@ const WindowTool: React.FC = () => {
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('grid:move', showFallbackCursor)
emitter.on('tool:cancel', onCancel)
return () => {
@@ -514,12 +559,13 @@ const WindowTool: React.FC = () => {
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('grid:move', showFallbackCursor)
emitter.off('tool:cancel', onCancel)
}
}, [])
// Cursor geometry: window outline rectangle (width × height × frameDepth)
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
// Cursor geometry: window outline rectangle.
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
+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`) |
| [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) |
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
| [tools](tools.md) | Editor tools structure in `apps/editor` |
| [tools](tools.md) | Editor tools structure, manipulation constraints, and Shift bypass defaults |
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
+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.
- **`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
### `<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.
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
+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 tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system.
- **No business logic in tools** — delegate geometry/constraint rules to core systems.
- **Guided manipulation is the default.** Placement, move, rotate, resize, endpoint drag,
and handle drag should behave as guided building mode: they help the user build quickly
with fewer mistakes through grid/object snapping, canonical angle increments,
alignment guides, and distance feedback. Holding Shift is the standard live bypass for
those constraints: while Shift is held, tools should commit the raw pointer/angle
proposal instead of applying sticky snap or angle corrections. Passive measurement
guides may remain visible only when they do not alter the proposal. If an interaction
cannot use Shift because of an established shortcut or topology rule, document the
opt-out in its manipulation policy and explain the replacement behavior.
- **Constraints and guides can be decoupled.** When a stronger constraint owns the
proposal, such as a wall segment's 15° angle lock, the tool may still publish passive
dashed alignment/proximity guides as long as it does not apply the guide snap delta.
Use this for chained wall segments: users keep the fast constrained draft, but still see
proximity feedback for later points. Shift remains the hard bypass for both correction
and guide feedback.
- **Help must mirror manipulation policy.** The shortcut dialog and floating helper panel
are part of the interaction contract. Static shortcut docs should describe guided
building as the default and Shift as the live bypass. Floating help should be contextual
when enough state exists: Select mode can derive direct move, direct rotate,
multi-select, and Shift-bypass tips from the selected nodes and active modifiers; active
tools can highlight the Shift bypass row while the modifier is held.
- **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store.
- **Clean up on unmount** — remove any pending/incomplete nodes *and* any live transforms/mesh offsets when the tool unmounts.
- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. `sceneRegistry` is exported from `@pascal-app/core` and is the allowed door into the Three.js graph for the narrow purposes above.