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()