fix(editor): improve guided manipulation and snap affordances
This commit is contained in:
@@ -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">
|
||||
|
||||
+31
-5
@@ -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}
|
||||
|
||||
+30
-14
@@ -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])
|
||||
|
||||
+10
-7
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
|
||||
| [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) |
|
||||
| [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) |
|
||||
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
|
||||
| [tools](tools.md) | Editor tools structure in `apps/editor` |
|
||||
| [tools](tools.md) | Editor tools structure, manipulation constraints, and Shift bypass defaults |
|
||||
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
|
||||
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
|
||||
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
|
||||
|
||||
@@ -184,6 +184,28 @@ If the system also handles cascades, animations, or material updates, keep `def.
|
||||
- **Dispose on rebuild.** The generic system disposes the previous children's geometry + material before swapping. Custom systems that imperatively add children must dispose what they replace, or accept the GPU-memory cost.
|
||||
- **`def.renderer` overrides the generic renderer.** Once you set it, you own the mount — `<ParametricNodeRenderer>` is not invoked. The generic geometry system still runs for the kind if `def.geometry` is set, so a custom renderer can register an empty group and let the system fill it.
|
||||
|
||||
## `toolHints`
|
||||
|
||||
`toolHints?: ToolHint[]` is the registry-owned source for the floating helper shown while
|
||||
a registered placement or draw tool is active.
|
||||
|
||||
```ts
|
||||
type ToolHint = {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
```
|
||||
|
||||
Keep labels short and action-oriented. Prefer the default guided-building language:
|
||||
snapping, angle increments, guides, and validation are active unless the user holds Shift
|
||||
during the gesture. A `Shift` hint should describe the bypass in user terms, such as
|
||||
`Free angle`, `Free place`, or `Bypass guided constraints`.
|
||||
|
||||
`HelperManager` renders `def.toolHints` through `RegisteredToolHelper`, and active Shift
|
||||
state can update the row to show that guided constraints are currently bypassed. Select
|
||||
mode is not owned by a node definition, so its helper is derived separately from
|
||||
selection state, selected-node move/rotate capabilities, and held modifiers.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
### `<GeometrySystem>` must not mutate `group.position` / `group.rotation`
|
||||
|
||||
@@ -73,6 +73,21 @@ phase: 'furnish' → selectable: furniture items only
|
||||
|
||||
Clicking a node of a different phase auto-switches the phase. Double-click drills into a context level.
|
||||
|
||||
In Select mode, 3D and 2D canvas selection share the same modifier vocabulary:
|
||||
|
||||
- `Ctrl/Meta + click` toggles the clicked object in `selectedIds`.
|
||||
- `Shift + click` also toggles the clicked canvas object so users can multi-select from
|
||||
either viewport. The scene graph keeps file-browser semantics: `Shift + click` selects
|
||||
the visible range between the last selected row and the clicked row.
|
||||
- `Ctrl/Meta + left-drag` on a selected movable object starts direct move from the canvas.
|
||||
- `Ctrl/Meta + right-drag` on a selected rotatable object starts direct rotation from the
|
||||
canvas. Rotation snaps to the default angle increment unless Shift is held during the
|
||||
drag.
|
||||
|
||||
The floating helper in `packages/editor/src/components/ui/helpers/helper-manager.tsx`
|
||||
mirrors these rules from current selection state and held modifiers. Keep that helper and
|
||||
the shortcut dialog in sync when changing selection gestures.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -66,6 +66,27 @@ export function MyTool() {
|
||||
- The offset must be cleared on tool unmount, cancel, *and* commit — both `mesh.position.set(0, 0, 0)` and `useLiveTransforms.clear(id)`.
|
||||
- The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system.
|
||||
- **No business logic in tools** — delegate geometry/constraint rules to core systems.
|
||||
- **Guided manipulation is the default.** Placement, move, rotate, resize, endpoint drag,
|
||||
and handle drag should behave as guided building mode: they help the user build quickly
|
||||
with fewer mistakes through grid/object snapping, canonical angle increments,
|
||||
alignment guides, and distance feedback. Holding Shift is the standard live bypass for
|
||||
those constraints: while Shift is held, tools should commit the raw pointer/angle
|
||||
proposal instead of applying sticky snap or angle corrections. Passive measurement
|
||||
guides may remain visible only when they do not alter the proposal. If an interaction
|
||||
cannot use Shift because of an established shortcut or topology rule, document the
|
||||
opt-out in its manipulation policy and explain the replacement behavior.
|
||||
- **Constraints and guides can be decoupled.** When a stronger constraint owns the
|
||||
proposal, such as a wall segment's 15° angle lock, the tool may still publish passive
|
||||
dashed alignment/proximity guides as long as it does not apply the guide snap delta.
|
||||
Use this for chained wall segments: users keep the fast constrained draft, but still see
|
||||
proximity feedback for later points. Shift remains the hard bypass for both correction
|
||||
and guide feedback.
|
||||
- **Help must mirror manipulation policy.** The shortcut dialog and floating helper panel
|
||||
are part of the interaction contract. Static shortcut docs should describe guided
|
||||
building as the default and Shift as the live bypass. Floating help should be contextual
|
||||
when enough state exists: Select mode can derive direct move, direct rotate,
|
||||
multi-select, and Shift-bypass tips from the selected nodes and active modifiers; active
|
||||
tools can highlight the Shift bypass row while the modifier is held.
|
||||
- **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store.
|
||||
- **Clean up on unmount** — remove any pending/incomplete nodes *and* any live transforms/mesh offsets when the tool unmounts.
|
||||
- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. `sceneRegistry` is exported from `@pascal-app/core` and is the allowed door into the Three.js graph for the narrow purposes above.
|
||||
|
||||
Reference in New Issue
Block a user