fix(editor): improve guided manipulation and snap affordances
This commit is contained in:
@@ -3,12 +3,14 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
createSceneApi,
|
||||||
type FloorplanAffordancePoint,
|
type FloorplanAffordancePoint,
|
||||||
type FloorplanAffordanceSession,
|
type FloorplanAffordanceSession,
|
||||||
type FloorplanGeometry,
|
type FloorplanGeometry,
|
||||||
type FloorplanPalette,
|
type FloorplanPalette,
|
||||||
type FloorplanPoint,
|
type FloorplanPoint,
|
||||||
type GeometryContext,
|
type GeometryContext,
|
||||||
|
isRegistryMovable,
|
||||||
kindsWithFloorplanScope,
|
kindsWithFloorplanScope,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
pauseSceneHistory,
|
pauseSceneHistory,
|
||||||
@@ -29,8 +31,15 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
import {
|
||||||
|
canDirectRotateNode,
|
||||||
|
resolveDirectRotationDragDelta,
|
||||||
|
resolveDirectRotationPatch,
|
||||||
|
} from '../../../lib/direct-manipulation'
|
||||||
|
import { createEditorApi } from '../../../lib/editor-api'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
||||||
|
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
||||||
import { useFloorplanRender } from '../floorplan-render-context'
|
import { useFloorplanRender } from '../floorplan-render-context'
|
||||||
@@ -71,6 +80,9 @@ const ENDPOINT_HIT_STROKE_WIDTH_PX = 18
|
|||||||
const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
|
const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
|
||||||
const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
|
const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
|
||||||
const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
|
const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
|
||||||
|
const DIRECT_DRAG_THRESHOLD_PX = 4
|
||||||
|
const DIRECT_ROTATE_EPSILON = 1e-6
|
||||||
|
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot of node fields captured at drag-start, used by the single-undo
|
* Snapshot of node fields captured at drag-start, used by the single-undo
|
||||||
@@ -131,6 +143,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
||||||
const hoveredId = useViewer((s) => s.hoveredId)
|
const hoveredId = useViewer((s) => s.hoveredId)
|
||||||
|
const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
|
||||||
const setHoveredId = useViewer((s) => s.setHoveredId)
|
const setHoveredId = useViewer((s) => s.setHoveredId)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
@@ -227,11 +240,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||||
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const applyEntrySelection = useCallback(
|
||||||
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
|
(id: AnyNodeId, shouldToggle: boolean) => {
|
||||||
if (event.button !== 0) return
|
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
||||||
event.stopPropagation()
|
setSelection({
|
||||||
setSelection({ selectedIds: [id] })
|
selectedIds: shouldToggle
|
||||||
|
? currentSelectedIds.includes(id)
|
||||||
|
? currentSelectedIds.filter((selectedId) => selectedId !== id)
|
||||||
|
: [...currentSelectedIds, id]
|
||||||
|
: [id],
|
||||||
|
})
|
||||||
// Setting selection re-renders the entry — the overlay pass mounts
|
// Setting selection re-renders the entry — the overlay pass mounts
|
||||||
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
||||||
// pointerdown and click. If the click target ends up on the SVG
|
// pointerdown and click. If the click target ends up on the SVG
|
||||||
@@ -240,21 +258,200 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
// selection we just set. Swallow the next click globally to break
|
// selection we just set. Swallow the next click globally to break
|
||||||
// that race; the listener removes itself after firing (or after a
|
// that race; the listener removes itself after firing (or after a
|
||||||
// safety timeout if no click follows).
|
// safety timeout if no click follows).
|
||||||
const swallowClick = (ev: Event) => {
|
swallowNextClick(200)
|
||||||
ev.stopPropagation()
|
|
||||||
ev.preventDefault()
|
|
||||||
window.removeEventListener('click', swallowClick, true)
|
|
||||||
}
|
|
||||||
window.addEventListener('click', swallowClick, true)
|
|
||||||
setTimeout(() => window.removeEventListener('click', swallowClick, true), 200)
|
|
||||||
},
|
},
|
||||||
[setSelection],
|
[setSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
event.stopPropagation()
|
||||||
|
applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
|
||||||
|
},
|
||||||
|
[applyEntrySelection],
|
||||||
|
)
|
||||||
|
|
||||||
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
|
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const startDirectMoveDrag = useCallback(
|
||||||
|
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
|
||||||
|
if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false
|
||||||
|
|
||||||
|
const node = useScene.getState().nodes[id]
|
||||||
|
if (!node || !isRegistryMovable(node.type)) return false
|
||||||
|
if (!useViewer.getState().selection.selectedIds.includes(id)) return false
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const startX = event.clientX
|
||||||
|
const startY = event.clientY
|
||||||
|
const pointerId = event.pointerId
|
||||||
|
let engaged = false
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove)
|
||||||
|
window.removeEventListener('pointerup', onEnd)
|
||||||
|
window.removeEventListener('pointercancel', onEnd)
|
||||||
|
if (engaged) {
|
||||||
|
useViewer.getState().setInputDragging(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (moveEvent: PointerEvent) => {
|
||||||
|
if (moveEvent.pointerId !== pointerId) return
|
||||||
|
if (engaged) return
|
||||||
|
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY)
|
||||||
|
if (distance < DIRECT_DRAG_THRESHOLD_PX) return
|
||||||
|
|
||||||
|
engaged = true
|
||||||
|
useViewer.getState().setInputDragging(true)
|
||||||
|
swallowNextClick(300)
|
||||||
|
createEditorApi().engageMoveDrag(node)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new PointerEvent('pointermove', {
|
||||||
|
altKey: moveEvent.altKey,
|
||||||
|
bubbles: true,
|
||||||
|
buttons: moveEvent.buttons,
|
||||||
|
clientX: moveEvent.clientX,
|
||||||
|
clientY: moveEvent.clientY,
|
||||||
|
ctrlKey: moveEvent.ctrlKey,
|
||||||
|
metaKey: moveEvent.metaKey,
|
||||||
|
pointerId,
|
||||||
|
pointerType: moveEvent.pointerType,
|
||||||
|
shiftKey: moveEvent.shiftKey,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnd = (endEvent: PointerEvent) => {
|
||||||
|
if (endEvent.pointerId !== pointerId) return
|
||||||
|
cleanup()
|
||||||
|
if (!engaged) {
|
||||||
|
applyEntrySelection(id, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onMove)
|
||||||
|
window.addEventListener('pointerup', onEnd)
|
||||||
|
window.addEventListener('pointercancel', onEnd)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[applyEntrySelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const startDirectRotateDrag = useCallback(
|
||||||
|
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
|
||||||
|
if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false
|
||||||
|
|
||||||
|
const node = useScene.getState().nodes[id]
|
||||||
|
if (!node || !canDirectRotateNode(node)) return false
|
||||||
|
const selectedIds = useViewer.getState().selection.selectedIds
|
||||||
|
if (!selectedIds.includes(id)) return false
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const nodeId = node.id as AnyNodeId
|
||||||
|
const pointerId = event.pointerId
|
||||||
|
const startX = event.clientX
|
||||||
|
const sceneApi = createSceneApi(useScene)
|
||||||
|
let lastPatch: Partial<AnyNode> | null = null
|
||||||
|
|
||||||
|
const applyDelta = (pointerEvent: PointerEvent | ReactPointerEvent<SVGGElement>) => {
|
||||||
|
const delta = resolveDirectRotationDragDelta(
|
||||||
|
startX,
|
||||||
|
pointerEvent.clientX,
|
||||||
|
DIRECT_ROTATE_RADIANS_PER_PIXEL,
|
||||||
|
pointerEvent.shiftKey,
|
||||||
|
)
|
||||||
|
if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
|
||||||
|
lastPatch = null
|
||||||
|
useLiveNodeOverrides.getState().clear(nodeId)
|
||||||
|
useScene.getState().markDirty(nodeId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const patch = resolveDirectRotationPatch(node, delta, sceneApi)
|
||||||
|
if (!patch) return
|
||||||
|
lastPatch = patch
|
||||||
|
useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
|
||||||
|
useScene.getState().markDirty(nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove, true)
|
||||||
|
window.removeEventListener('pointerup', onUp, true)
|
||||||
|
window.removeEventListener('pointercancel', onCancel, true)
|
||||||
|
window.removeEventListener('contextmenu', preventContextMenu, true)
|
||||||
|
useLiveNodeOverrides.getState().clear(nodeId)
|
||||||
|
useScene.getState().markDirty(nodeId)
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId)
|
||||||
|
useViewer.getState().setInputDragging(false)
|
||||||
|
if (document.body.style.cursor === 'ew-resize') {
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (moveEvent: PointerEvent) => {
|
||||||
|
if (moveEvent.pointerId !== pointerId) return
|
||||||
|
moveEvent.preventDefault()
|
||||||
|
moveEvent.stopPropagation()
|
||||||
|
applyDelta(moveEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = (upEvent: PointerEvent) => {
|
||||||
|
if (upEvent.pointerId !== pointerId) return
|
||||||
|
upEvent.preventDefault()
|
||||||
|
upEvent.stopPropagation()
|
||||||
|
swallowNextClick(300)
|
||||||
|
if (lastPatch) {
|
||||||
|
sceneApi.update(nodeId, lastPatch)
|
||||||
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = (cancelEvent: PointerEvent) => {
|
||||||
|
if (cancelEvent.pointerId !== pointerId) return
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
const preventContextMenu = (contextEvent: Event) => {
|
||||||
|
contextEvent.preventDefault()
|
||||||
|
contextEvent.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
useViewer.getState().setInputDragging(true)
|
||||||
|
useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId)
|
||||||
|
document.body.style.cursor = 'ew-resize'
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
applyDelta(event)
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onMove, true)
|
||||||
|
window.addEventListener('pointerup', onUp, true)
|
||||||
|
window.addEventListener('pointercancel', onCancel, true)
|
||||||
|
window.addEventListener('contextmenu', preventContextMenu, true)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleEntryPointerDown = useCallback(
|
||||||
|
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
|
||||||
|
if (startDirectMoveDrag(id, event)) return
|
||||||
|
if (startDirectRotateDrag(id, event)) return
|
||||||
|
handleSelect(id, event)
|
||||||
|
},
|
||||||
|
[handleSelect, startDirectMoveDrag, startDirectRotateDrag],
|
||||||
|
)
|
||||||
|
|
||||||
// Build the geometry list. `viewState` flows into ctx so kinds can
|
// Build the geometry list. `viewState` flows into ctx so kinds can
|
||||||
// theme their output and conditionally emit selection chrome.
|
// theme their output and conditionally emit selection chrome.
|
||||||
//
|
//
|
||||||
@@ -729,7 +926,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
onPointerDown={
|
onPointerDown={
|
||||||
isOpeningPlacementActive || isMarqueeSelectionActive
|
isOpeningPlacementActive || isMarqueeSelectionActive
|
||||||
? undefined
|
? undefined
|
||||||
: (e) => handleSelect(id, e)
|
: (e) => handleEntryPointerDown(id, e)
|
||||||
}
|
}
|
||||||
// Mirror the sidebar tree nodes' hover wiring — `useViewer.
|
// Mirror the sidebar tree nodes' hover wiring — `useViewer.
|
||||||
// hoveredId` drives the highlight halo in 3D as well as the
|
// hoveredId` drives the highlight halo in 3D as well as the
|
||||||
@@ -749,6 +946,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
>
|
>
|
||||||
<InteractiveGeometry
|
<InteractiveGeometry
|
||||||
activeDragId={activeDragId}
|
activeDragId={activeDragId}
|
||||||
|
activeRotateNodeId={activeRotateNodeId}
|
||||||
geometry={geometry}
|
geometry={geometry}
|
||||||
hatchPatternId={renderCtx?.hatchPatternId}
|
hatchPatternId={renderCtx?.hatchPatternId}
|
||||||
hoveredHandleId={hoveredHandleId}
|
hoveredHandleId={hoveredHandleId}
|
||||||
@@ -845,6 +1043,7 @@ function InteractiveGeometry({
|
|||||||
hatchPatternId,
|
hatchPatternId,
|
||||||
hoveredHandleId,
|
hoveredHandleId,
|
||||||
activeDragId,
|
activeDragId,
|
||||||
|
activeRotateNodeId,
|
||||||
isMarqueeSelectionActive,
|
isMarqueeSelectionActive,
|
||||||
nodeId,
|
nodeId,
|
||||||
sceneRotationDeg,
|
sceneRotationDeg,
|
||||||
@@ -858,6 +1057,7 @@ function InteractiveGeometry({
|
|||||||
hatchPatternId: string | undefined
|
hatchPatternId: string | undefined
|
||||||
hoveredHandleId: string | null
|
hoveredHandleId: string | null
|
||||||
activeDragId: string | null
|
activeDragId: string | null
|
||||||
|
activeRotateNodeId: AnyNodeId | null
|
||||||
isMarqueeSelectionActive: boolean
|
isMarqueeSelectionActive: boolean
|
||||||
nodeId: AnyNodeId
|
nodeId: AnyNodeId
|
||||||
sceneRotationDeg: number
|
sceneRotationDeg: number
|
||||||
@@ -1100,7 +1300,7 @@ function InteractiveGeometry({
|
|||||||
// each end pointing tangentially in opposite directions —
|
// each end pointing tangentially in opposite directions —
|
||||||
// "rotate either way."
|
// "rotate either way."
|
||||||
const handleId = makeHandleId(nodeId, g.payload)
|
const handleId = makeHandleId(nodeId, g.payload)
|
||||||
const isHovered = hoveredHandleId === handleId
|
const isHovered = hoveredHandleId === handleId || activeRotateNodeId === nodeId
|
||||||
// Arc geometry (all values precomputed for a 72° arc of
|
// Arc geometry (all values precomputed for a 72° arc of
|
||||||
// radius 0.13 — comparable footprint to `move-arrow`).
|
// radius 0.13 — comparable footprint to `move-arrow`).
|
||||||
const R = 0.13
|
const R = 0.13
|
||||||
@@ -1966,3 +2166,15 @@ function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoin
|
|||||||
// the Y axis on screen — same convention as `toSvgPlanPoint`).
|
// the Y axis on screen — same convention as `toSvgPlanPoint`).
|
||||||
return [transformed.x, transformed.y]
|
return [transformed.x, transformed.y]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function swallowNextClick(timeoutMs = 0) {
|
||||||
|
const swallowClick = (event: MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
window.removeEventListener('click', swallowClick, true)
|
||||||
|
}
|
||||||
|
window.addEventListener('click', swallowClick, true)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.removeEventListener('click', swallowClick, true)
|
||||||
|
}, timeoutMs)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
|
||||||
|
|
||||||
|
const baseArgs = {
|
||||||
|
canSelectElementFloorplanGeometry: true,
|
||||||
|
canSelectFloorplanZones: false,
|
||||||
|
currentSelectedIds: ['wall_1'],
|
||||||
|
getFloorplanHitIdAtPoint: () => 'door_1',
|
||||||
|
isWallBuildActive: false,
|
||||||
|
modifierKeys: { meta: false, ctrl: false, shift: false },
|
||||||
|
planPoint: [0, 0] as [number, number],
|
||||||
|
structureLayer: 'elements',
|
||||||
|
toPoint2D: ([x, y]: [number, number]) => ({ x, y }),
|
||||||
|
visibleZonePolygons: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveFloorplanBackgroundSelection', () => {
|
||||||
|
test('shift-click on a floorplan node toggles into the current selection', () => {
|
||||||
|
const result = resolveFloorplanBackgroundSelection({
|
||||||
|
...baseArgs,
|
||||||
|
modifierKeys: { meta: false, ctrl: false, shift: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
handled: true,
|
||||||
|
kind: 'select-elements',
|
||||||
|
selectedIds: ['wall_1', 'door_1'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shift-click on selected floorplan node toggles it out', () => {
|
||||||
|
const result = resolveFloorplanBackgroundSelection({
|
||||||
|
...baseArgs,
|
||||||
|
currentSelectedIds: ['wall_1', 'door_1'],
|
||||||
|
modifierKeys: { meta: false, ctrl: false, shift: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
handled: true,
|
||||||
|
kind: 'select-elements',
|
||||||
|
selectedIds: ['wall_1'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shift-click on empty floorplan space preserves selection', () => {
|
||||||
|
const result = resolveFloorplanBackgroundSelection({
|
||||||
|
...baseArgs,
|
||||||
|
getFloorplanHitIdAtPoint: () => null,
|
||||||
|
modifierKeys: { meta: false, ctrl: false, shift: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
handled: true,
|
||||||
|
kind: 'clear-elements',
|
||||||
|
preserveSelection: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ import type { WallPlanPoint } from '../tools/wall/wall-drafting'
|
|||||||
type ModifierKeys = {
|
type ModifierKeys = {
|
||||||
meta: boolean
|
meta: boolean
|
||||||
ctrl: boolean
|
ctrl: boolean
|
||||||
|
shift: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ZoneHitEntry = {
|
type ZoneHitEntry = {
|
||||||
@@ -85,7 +86,7 @@ export function resolveFloorplanBackgroundSelection({
|
|||||||
handled: true,
|
handled: true,
|
||||||
kind: 'select-elements',
|
kind: 'select-elements',
|
||||||
selectedIds:
|
selectedIds:
|
||||||
modifierKeys.meta || modifierKeys.ctrl
|
modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
|
||||||
? currentSelectedIds.includes(hitId)
|
? currentSelectedIds.includes(hitId)
|
||||||
? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
|
? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
|
||||||
: [...currentSelectedIds, hitId]
|
: [...currentSelectedIds, hitId]
|
||||||
@@ -105,7 +106,7 @@ export function resolveFloorplanBackgroundSelection({
|
|||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
kind: 'clear-elements',
|
kind: 'clear-elements',
|
||||||
preserveSelection: modifierKeys.meta || modifierKeys.ctrl,
|
preserveSelection: modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -868,10 +868,15 @@ function getElevatorResizeSign(handle: ElevatorResizeHandle) {
|
|||||||
return handle.endsWith('positive') ? 1 : -1
|
return handle.endsWith('positive') ? 1 : -1
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) {
|
function getSelectionModifierKeys(event?: {
|
||||||
|
metaKey?: boolean
|
||||||
|
ctrlKey?: boolean
|
||||||
|
shiftKey?: boolean
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
meta: Boolean(event?.metaKey),
|
meta: Boolean(event?.metaKey),
|
||||||
ctrl: Boolean(event?.ctrlKey),
|
ctrl: Boolean(event?.ctrlKey),
|
||||||
|
shift: Boolean(event?.shiftKey),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8660,10 +8665,11 @@ export function FloorplanPanel({
|
|||||||
// that snap wins, so skip Figma alignment and stand the beacon there.
|
// that snap wins, so skip Figma alignment and stand the beacon there.
|
||||||
const lockedToWall = wallSnap.snap !== null
|
const lockedToWall = wallSnap.snap !== null
|
||||||
let snappedPoint = wallSnapped
|
let snappedPoint = wallSnapped
|
||||||
if (lockedToWall || wallAngleSnap) {
|
if (lockedToWall) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
} else {
|
} else {
|
||||||
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
||||||
|
applySnap: !wallAngleSnap,
|
||||||
bypass: event.altKey || bypassSnap,
|
bypass: event.altKey || bypassSnap,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -9233,8 +9239,11 @@ export function FloorplanPanel({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const addFloorplanSelection = useCallback(
|
const addFloorplanSelection = useCallback(
|
||||||
(nextSelectedIds: string[], modifierKeys?: { meta: boolean; ctrl: boolean }) => {
|
(
|
||||||
const shouldAppend = Boolean(modifierKeys?.meta || modifierKeys?.ctrl)
|
nextSelectedIds: string[],
|
||||||
|
modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean },
|
||||||
|
) => {
|
||||||
|
const shouldAppend = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift)
|
||||||
|
|
||||||
if (shouldAppend) {
|
if (shouldAppend) {
|
||||||
if (nextSelectedIds.length === 0) {
|
if (nextSelectedIds.length === 0) {
|
||||||
@@ -9252,8 +9261,8 @@ export function FloorplanPanel({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const toggleFloorplanSelection = useCallback(
|
const toggleFloorplanSelection = useCallback(
|
||||||
(nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean }) => {
|
(nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean }) => {
|
||||||
const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl)
|
const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift)
|
||||||
|
|
||||||
if (shouldToggle) {
|
if (shouldToggle) {
|
||||||
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
||||||
@@ -9296,7 +9305,7 @@ export function FloorplanPanel({
|
|||||||
const commitFloorplanScreenSelection = useCallback(
|
const commitFloorplanScreenSelection = useCallback(
|
||||||
(nextSelectedIds: string[], event: PointerEvent) => {
|
(nextSelectedIds: string[], event: PointerEvent) => {
|
||||||
const modifierKeys = getSelectionModifierKeys(event)
|
const modifierKeys = getSelectionModifierKeys(event)
|
||||||
const shouldAppend = modifierKeys.meta || modifierKeys.ctrl
|
const shouldAppend = modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
|
||||||
|
|
||||||
setSelectedReferenceId(null)
|
setSelectedReferenceId(null)
|
||||||
|
|
||||||
@@ -9934,7 +9943,7 @@ export function FloorplanPanel({
|
|||||||
|
|
||||||
if (hitId) {
|
if (hitId) {
|
||||||
toggleFloorplanSelection(hitId, modifierKeys)
|
toggleFloorplanSelection(hitId, modifierKeys)
|
||||||
} else if (!(modifierKeys.meta || modifierKeys.ctrl)) {
|
} else if (!(modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift)) {
|
||||||
commitFloorplanSelection([])
|
commitFloorplanSelection([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|||||||
import { EDITOR_LAYER } from '../../lib/constants'
|
import { EDITOR_LAYER } from '../../lib/constants'
|
||||||
import { createEditorApi } from '../../lib/editor-api'
|
import { createEditorApi } from '../../lib/editor-api'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
|
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||||
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
||||||
@@ -167,6 +168,7 @@ function DimensionLabel({
|
|||||||
|
|
||||||
export function NodeArrowHandles() {
|
export function NodeArrowHandles() {
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
|
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
|
||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
@@ -180,7 +182,7 @@ export function NodeArrowHandles() {
|
|||||||
const curvingWall = useEditor((state) => state.curvingWall)
|
const curvingWall = useEditor((state) => state.curvingWall)
|
||||||
const curvingFence = useEditor((state) => state.curvingFence)
|
const curvingFence = useEditor((state) => state.curvingFence)
|
||||||
|
|
||||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId
|
||||||
const rawNode = useScene((state) =>
|
const rawNode = useScene((state) =>
|
||||||
selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null,
|
selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null,
|
||||||
)
|
)
|
||||||
@@ -1028,6 +1030,8 @@ function ArcArrow({
|
|||||||
// corner) render a two-headed curved arrow; everything else (stair
|
// corner) render a two-headed curved arrow; everything else (stair
|
||||||
// sweep, etc.) keeps the chevron.
|
// sweep, etc.) keeps the chevron.
|
||||||
const isRotateShape = descriptor.shape === 'rotate'
|
const isRotateShape = descriptor.shape === 'rotate'
|
||||||
|
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
|
||||||
|
const isDirectRotating = isRotateShape && activeRotateNodeId === liveNode.id
|
||||||
// 'node-normal' spins the node about its local +Z (a wall item flat against
|
// 'node-normal' spins the node about its local +Z (a wall item flat against
|
||||||
// its wall) instead of yaw about world-Y. The drag plane and the icon both
|
// its wall) instead of yaw about world-Y. The drag plane and the icon both
|
||||||
// tilt into that plane, and the horizontal-only wedge/ring readout is
|
// tilt into that plane, and the horizontal-only wedge/ring readout is
|
||||||
@@ -1067,7 +1071,7 @@ function ArcArrow({
|
|||||||
// arrow is hovered or dragging. Same recipe as the linear / radial
|
// arrow is hovered or dragging. Same recipe as the linear / radial
|
||||||
// decoration path.
|
// decoration path.
|
||||||
const decoration = descriptor.decoration
|
const decoration = descriptor.decoration
|
||||||
const showDecoration = Boolean(decoration) && (isHovered || isDragging)
|
const showDecoration = Boolean(decoration) && (isHovered || isDragging || isDirectRotating)
|
||||||
|
|
||||||
const activate = useHandleDrag({
|
const activate = useHandleDrag({
|
||||||
kind: 'drag',
|
kind: 'drag',
|
||||||
@@ -1142,13 +1146,6 @@ function ArcArrow({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Suppress "declared but unused" for `liveNode` — ArcArrow's apply
|
|
||||||
// operates entirely on `initialNode` (snapshot taken inside activate)
|
|
||||||
// and `delta` (live cursor angle), so the live store node doesn't
|
|
||||||
// appear in the rotation pipeline. The prop is still required because
|
|
||||||
// ArrowHandle passes it uniformly to every variant.
|
|
||||||
void liveNode
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showDecoration && decoration ? (
|
{showDecoration && decoration ? (
|
||||||
@@ -1172,7 +1169,7 @@ function ArcArrow({
|
|||||||
<HandleArrow
|
<HandleArrow
|
||||||
activeCursor={dragCursor}
|
activeCursor={dragCursor}
|
||||||
cursor={hoverCursor}
|
cursor={hoverCursor}
|
||||||
hover={isHovered}
|
hover={isHovered || isDirectRotating}
|
||||||
onHoverChange={setIsHovered}
|
onHoverChange={setIsHovered}
|
||||||
onPointerDown={activate}
|
onPointerDown={activate}
|
||||||
placement={{
|
placement={{
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
type ColumnNode,
|
type ColumnNode,
|
||||||
|
createSceneApi,
|
||||||
emitter,
|
emitter,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
|
type GridEvent,
|
||||||
getEffectiveRoofSurfaceMaterial,
|
getEffectiveRoofSurfaceMaterial,
|
||||||
getEffectiveSegmentSurfaceMaterial,
|
getEffectiveSegmentSurfaceMaterial,
|
||||||
getMaterialPresetByRef,
|
getMaterialPresetByRef,
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
type StairSegmentEvent,
|
type StairSegmentEvent,
|
||||||
type StairSurfaceMaterialRole,
|
type StairSurfaceMaterialRole,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
useLiveNodeOverrides,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
@@ -42,6 +45,13 @@ import {
|
|||||||
} from '@pascal-app/viewer'
|
} from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
|
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
|
||||||
|
import {
|
||||||
|
canDirectMoveNode,
|
||||||
|
canDirectRotateNode,
|
||||||
|
resolveDirectRotationDragDelta,
|
||||||
|
resolveDirectRotationPatch,
|
||||||
|
} from '../../lib/direct-manipulation'
|
||||||
|
import { createEditorApi } from '../../lib/editor-api'
|
||||||
import {
|
import {
|
||||||
type ActivePaintMaterial,
|
type ActivePaintMaterial,
|
||||||
buildRoofSegmentSurfaceMaterialPatch,
|
buildRoofSegmentSurfaceMaterialPatch,
|
||||||
@@ -51,13 +61,17 @@ import {
|
|||||||
hasActivePaintMaterial,
|
hasActivePaintMaterial,
|
||||||
resolveActivePaintMaterialFromSelection,
|
resolveActivePaintMaterialFromSelection,
|
||||||
} from '../../lib/material-paint'
|
} from '../../lib/material-paint'
|
||||||
import { emitDeleteSFX } from '../../lib/sfx-bus'
|
import {
|
||||||
import useEditor, {
|
resolveNodeSelectionTarget,
|
||||||
type MaterialTargetRole,
|
resolveSelectedIdsForNodeClick,
|
||||||
type Phase,
|
type SelectionModifierKeys,
|
||||||
type StructureLayer,
|
selectionModifiersFromEvent,
|
||||||
} from './../../store/use-editor'
|
} from '../../lib/selection-routing'
|
||||||
import { boxSelectHandled } from '../tools/select/box-select-state'
|
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||||
|
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||||
|
import useEditor, { type MaterialTargetRole } from './../../store/use-editor'
|
||||||
|
import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||||
|
import { swallowNextClick } from './node-arrow-handles'
|
||||||
|
|
||||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||||
// Elevators are building-scoped, so they stay selectable across level filters.
|
// Elevators are building-scoped, so they stay selectable across level filters.
|
||||||
@@ -86,11 +100,6 @@ type SelectableNodeType =
|
|||||||
| 'window'
|
| 'window'
|
||||||
| 'door'
|
| 'door'
|
||||||
|
|
||||||
type ModifierKeys = {
|
|
||||||
meta: boolean
|
|
||||||
ctrl: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaintPreviewCleanup = () => void
|
type PaintPreviewCleanup = () => void
|
||||||
|
|
||||||
type PaintInteraction = {
|
type PaintInteraction = {
|
||||||
@@ -103,14 +112,33 @@ type PaintInteraction = {
|
|||||||
|
|
||||||
interface SelectionStrategy {
|
interface SelectionStrategy {
|
||||||
types: SelectableNodeType[]
|
types: SelectableNodeType[]
|
||||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
handleSelect: (
|
||||||
|
node: AnyNode,
|
||||||
|
nativeEvent?: any,
|
||||||
|
modifierKeys?: SelectionModifierKeys,
|
||||||
|
baseSelectedIds?: readonly string[],
|
||||||
|
) => void
|
||||||
handleDeselect: () => void
|
handleDeselect: () => void
|
||||||
isValid: (node: AnyNode) => boolean
|
isValid: (node: AnyNode) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectionTarget = {
|
const DIRECT_DRAG_THRESHOLD_PX = 4
|
||||||
phase: Phase
|
const DIRECT_ROTATE_EPSILON = 1e-6
|
||||||
structureLayer?: StructureLayer
|
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
|
||||||
|
|
||||||
|
function pointerEventFromNodeEvent(event: NodeEvent): PointerEvent {
|
||||||
|
const threeEvent = event.nativeEvent as unknown as PointerEvent & {
|
||||||
|
nativeEvent?: PointerEvent
|
||||||
|
}
|
||||||
|
return threeEvent.nativeEvent ?? threeEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommandModifier(event: Pick<PointerEvent, 'metaKey' | 'ctrlKey'>): boolean {
|
||||||
|
return event.metaKey || event.ctrlKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerDistancePx(event: PointerEvent, startX: number, startY: number): number {
|
||||||
|
return Math.hypot(event.clientX - startX, event.clientY - startY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolveBuildingId = (
|
export const resolveBuildingId = (
|
||||||
@@ -619,22 +647,17 @@ function disposeHighlightedMaterials(material: Material | Material[]) {
|
|||||||
|
|
||||||
const computeNextIds = (
|
const computeNextIds = (
|
||||||
node: AnyNode,
|
node: AnyNode,
|
||||||
selectedIds: string[],
|
selectedIds: readonly string[],
|
||||||
event?: any,
|
event?: any,
|
||||||
modifierKeys?: ModifierKeys,
|
modifierKeys?: SelectionModifierKeys,
|
||||||
|
baseSelectedIds?: readonly string[],
|
||||||
): string[] => {
|
): string[] => {
|
||||||
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta
|
return resolveSelectedIdsForNodeClick({
|
||||||
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl
|
baseSelectedIds,
|
||||||
|
currentSelectedIds: selectedIds,
|
||||||
if (isMeta || isCtrl) {
|
modifierKeys: selectionModifiersFromEvent(event, modifierKeys),
|
||||||
if (selectedIds.includes(node.id)) {
|
nodeId: node.id,
|
||||||
return selectedIds.filter((id) => id !== node.id)
|
})
|
||||||
}
|
|
||||||
return [...selectedIds, node.id]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not holding modifiers: select only this node
|
|
||||||
return [node.id]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||||
@@ -667,7 +690,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
],
|
],
|
||||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
|
||||||
const { selection, setSelection } = useViewer.getState()
|
const { selection, setSelection } = useViewer.getState()
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
|
const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
|
||||||
@@ -694,7 +717,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
|
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
|
||||||
setSelection(updates)
|
setSelection(updates)
|
||||||
} else {
|
} else {
|
||||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
|
updates.selectedIds = computeNextIds(
|
||||||
|
node,
|
||||||
|
selection.selectedIds,
|
||||||
|
nativeEvent,
|
||||||
|
modifierKeys,
|
||||||
|
baseSelectedIds,
|
||||||
|
)
|
||||||
setSelection(updates)
|
setSelection(updates)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -746,7 +775,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
|
|
||||||
furnish: {
|
furnish: {
|
||||||
types: ['item'],
|
types: ['item'],
|
||||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
|
||||||
const { selection, setSelection } = useViewer.getState()
|
const { selection, setSelection } = useViewer.getState()
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const nodeLevelId = resolveLevelId(node, nodes)
|
const nodeLevelId = resolveLevelId(node, nodes)
|
||||||
@@ -760,7 +789,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
updates.buildingId = buildingId
|
updates.buildingId = buildingId
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
|
updates.selectedIds = computeNextIds(
|
||||||
|
node,
|
||||||
|
selection.selectedIds,
|
||||||
|
nativeEvent,
|
||||||
|
modifierKeys,
|
||||||
|
baseSelectedIds,
|
||||||
|
)
|
||||||
setSelection(updates)
|
setSelection(updates)
|
||||||
},
|
},
|
||||||
handleDeselect: () => {
|
handleDeselect: () => {
|
||||||
@@ -776,7 +811,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
// Registry-driven kinds with `category: 'furnish'` (shelf today,
|
// Registry-driven kinds with `category: 'furnish'` (shelf today,
|
||||||
// future furniture kinds): selectable in furnish phase if their
|
// future furniture kinds): selectable in furnish phase if their
|
||||||
// definition declares the `selectable` capability. Without this
|
// definition declares the `selectable` capability. Without this
|
||||||
// branch, shelf clicks routed to furnish phase via getSelectionTarget
|
// branch, shelf clicks routed to furnish phase via resolveNodeSelectionTarget
|
||||||
// would be rejected here — single-click selection broken.
|
// would be rejected here — single-click selection broken.
|
||||||
const def = nodeRegistry.get(node.type)
|
const def = nodeRegistry.get(node.type)
|
||||||
if (def && def.category === 'furnish' && def.capabilities.selectable) return true
|
if (def && def.category === 'furnish' && def.capabilities.selectable) return true
|
||||||
@@ -785,76 +820,14 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|
||||||
// Item is checked FIRST so its asset.category-driven routing (door/
|
|
||||||
// window items land in structure phase, everything else in furnish)
|
|
||||||
// beats the generic registry fallback below. Without this, registering
|
|
||||||
// `item` (Phase 5) made isRegistrySelectable('item') match the
|
|
||||||
// structure branch first, breaking single-click selection: first click
|
|
||||||
// switched the editor to structure phase, second click selected.
|
|
||||||
if (node.type === 'item') {
|
|
||||||
const item = node as ItemNode
|
|
||||||
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
|
||||||
return {
|
|
||||||
phase: 'structure',
|
|
||||||
structureLayer: 'elements',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
phase: 'furnish',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'zone') {
|
|
||||||
return {
|
|
||||||
phase: 'structure',
|
|
||||||
structureLayer: 'zones',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
node.type === 'wall' ||
|
|
||||||
node.type === 'fence' ||
|
|
||||||
node.type === 'column' ||
|
|
||||||
node.type === 'elevator' ||
|
|
||||||
node.type === 'slab' ||
|
|
||||||
node.type === 'ceiling' ||
|
|
||||||
node.type === 'roof' ||
|
|
||||||
node.type === 'roof-segment' ||
|
|
||||||
node.type === 'stair' ||
|
|
||||||
node.type === 'stair-segment' ||
|
|
||||||
node.type === 'spawn' ||
|
|
||||||
node.type === 'window' ||
|
|
||||||
node.type === 'door'
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
phase: 'structure',
|
|
||||||
structureLayer: 'elements',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registry-driven kinds (Phase 5+): route by `def.category`. Built-ins
|
|
||||||
// above match before this fallback. Furnish-category kinds (shelf,
|
|
||||||
// item — already handled above) land on the furnish phase; structure-
|
|
||||||
// category kinds (everything else) on structure/elements.
|
|
||||||
const def = nodeRegistry.get(node.type)
|
|
||||||
if (def) {
|
|
||||||
if (def.category === 'furnish') {
|
|
||||||
return { phase: 'furnish' }
|
|
||||||
}
|
|
||||||
return { phase: 'structure', structureLayer: 'elements' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SelectionManager = () => {
|
export const SelectionManager = () => {
|
||||||
const phase = useEditor((s) => s.phase)
|
const phase = useEditor((s) => s.phase)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
|
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
|
||||||
const modifierKeysRef = useRef<ModifierKeys>({
|
const modifierKeysRef = useRef<SelectionModifierKeys>({
|
||||||
meta: false,
|
meta: false,
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
|
shift: false,
|
||||||
})
|
})
|
||||||
const clickHandledRef = useRef(false)
|
const clickHandledRef = useRef(false)
|
||||||
|
|
||||||
@@ -1230,16 +1203,19 @@ export const SelectionManager = () => {
|
|||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
||||||
if (event.key === 'Control') modifierKeysRef.current.ctrl = true
|
if (event.key === 'Control') modifierKeysRef.current.ctrl = true
|
||||||
|
if (event.key === 'Shift') modifierKeysRef.current.shift = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyUp = (event: KeyboardEvent) => {
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Meta') modifierKeysRef.current.meta = false
|
if (event.key === 'Meta') modifierKeysRef.current.meta = false
|
||||||
if (event.key === 'Control') modifierKeysRef.current.ctrl = false
|
if (event.key === 'Control') modifierKeysRef.current.ctrl = false
|
||||||
|
if (event.key === 'Shift') modifierKeysRef.current.shift = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearModifiers = () => {
|
const clearModifiers = () => {
|
||||||
modifierKeysRef.current.meta = false
|
modifierKeysRef.current.meta = false
|
||||||
modifierKeysRef.current.ctrl = false
|
modifierKeysRef.current.ctrl = false
|
||||||
|
modifierKeysRef.current.shift = false
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
@@ -1253,6 +1229,222 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'select') return
|
||||||
|
if (movingNode || curvingWall || curvingFence) return
|
||||||
|
|
||||||
|
const onPointerDown = (event: NodeEvent) => {
|
||||||
|
const pointer = pointerEventFromNodeEvent(event)
|
||||||
|
if (pointer.button !== 0 || !isCommandModifier(pointer)) return
|
||||||
|
|
||||||
|
const node = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node
|
||||||
|
if (!canDirectMoveNode(node)) return
|
||||||
|
if (!useViewer.getState().selection.selectedIds.includes(node.id)) return
|
||||||
|
|
||||||
|
const startX = pointer.clientX
|
||||||
|
const startY = pointer.clientY
|
||||||
|
const pointerId = pointer.pointerId
|
||||||
|
const pointerTarget = pointer.target instanceof EventTarget ? pointer.target : null
|
||||||
|
let engaged = false
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove)
|
||||||
|
window.removeEventListener('pointerup', onEnd)
|
||||||
|
window.removeEventListener('pointercancel', onEnd)
|
||||||
|
if (engaged) {
|
||||||
|
useViewer.getState().setInputDragging(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (moveEvent: PointerEvent) => {
|
||||||
|
if (moveEvent.pointerId !== pointerId) return
|
||||||
|
if (engaged) return
|
||||||
|
if (pointerDistancePx(moveEvent, startX, startY) < DIRECT_DRAG_THRESHOLD_PX) return
|
||||||
|
|
||||||
|
engaged = true
|
||||||
|
event.stopPropagation()
|
||||||
|
suppressBoxSelectForPointer(event.nativeEvent)
|
||||||
|
useViewer.getState().setInputDragging(true)
|
||||||
|
swallowNextClick()
|
||||||
|
createEditorApi().engageMoveDrag(node)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (useEditor.getState().movingNode?.id !== node.id) return
|
||||||
|
pointerTarget?.dispatchEvent(
|
||||||
|
new PointerEvent('pointermove', {
|
||||||
|
altKey: moveEvent.altKey,
|
||||||
|
bubbles: true,
|
||||||
|
buttons: moveEvent.buttons,
|
||||||
|
clientX: moveEvent.clientX,
|
||||||
|
clientY: moveEvent.clientY,
|
||||||
|
ctrlKey: moveEvent.ctrlKey,
|
||||||
|
metaKey: moveEvent.metaKey,
|
||||||
|
pointerId,
|
||||||
|
pointerType: moveEvent.pointerType,
|
||||||
|
shiftKey: moveEvent.shiftKey,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnd = (endEvent: PointerEvent) => {
|
||||||
|
if (endEvent.pointerId !== pointerId) return
|
||||||
|
cleanup()
|
||||||
|
if (engaged) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const editor = useEditor.getState()
|
||||||
|
if (editor.movingNode?.id !== node.id || !editor.placementDragMode) return
|
||||||
|
editor.setMovingNode(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onMove)
|
||||||
|
window.addEventListener('pointerup', onEnd)
|
||||||
|
window.addEventListener('pointercancel', onEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTypes = [
|
||||||
|
'wall',
|
||||||
|
'fence',
|
||||||
|
'item',
|
||||||
|
'column',
|
||||||
|
'slab',
|
||||||
|
'ceiling',
|
||||||
|
'roof',
|
||||||
|
'roof-segment',
|
||||||
|
'stair',
|
||||||
|
'stair-segment',
|
||||||
|
'window',
|
||||||
|
'door',
|
||||||
|
'zone',
|
||||||
|
'shelf',
|
||||||
|
'spawn',
|
||||||
|
'elevator',
|
||||||
|
'building',
|
||||||
|
] as const
|
||||||
|
const registryKinds = getSelectableKinds().filter(
|
||||||
|
(kind) => !(allTypes as readonly string[]).includes(kind),
|
||||||
|
)
|
||||||
|
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
|
||||||
|
|
||||||
|
for (const type of subscribedKinds) {
|
||||||
|
emitter.on(`${type}:pointerdown` as any, onPointerDown as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const type of subscribedKinds) {
|
||||||
|
emitter.off(`${type}:pointerdown` as any, onPointerDown as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [curvingFence, curvingWall, mode, movingNode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'select') return
|
||||||
|
if (movingNode || curvingWall || curvingFence) return
|
||||||
|
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (event.button !== 2 || !isCommandModifier(event)) return
|
||||||
|
if (!(event.target instanceof HTMLCanvasElement)) return
|
||||||
|
|
||||||
|
const selectedIds = useViewer.getState().selection.selectedIds
|
||||||
|
const hoveredId = useViewer.getState().hoveredId as AnyNodeId | null
|
||||||
|
if (!hoveredId || !selectedIds.includes(hoveredId)) return
|
||||||
|
|
||||||
|
const node = useScene.getState().nodes[hoveredId]
|
||||||
|
if (!node || !canDirectRotateNode(node)) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const nodeId = node.id as AnyNodeId
|
||||||
|
const pointerId = event.pointerId
|
||||||
|
const startX = event.clientX
|
||||||
|
const sceneApi = createSceneApi(useScene)
|
||||||
|
let lastPatch: Partial<AnyNode> | null = null
|
||||||
|
|
||||||
|
const applyDelta = (moveEvent: PointerEvent) => {
|
||||||
|
const delta = resolveDirectRotationDragDelta(
|
||||||
|
startX,
|
||||||
|
moveEvent.clientX,
|
||||||
|
DIRECT_ROTATE_RADIANS_PER_PIXEL,
|
||||||
|
moveEvent.shiftKey,
|
||||||
|
)
|
||||||
|
if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
|
||||||
|
lastPatch = null
|
||||||
|
useLiveNodeOverrides.getState().clear(nodeId)
|
||||||
|
useScene.getState().markDirty(nodeId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const patch = resolveDirectRotationPatch(node, delta, sceneApi)
|
||||||
|
if (!patch) return
|
||||||
|
lastPatch = patch
|
||||||
|
useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
|
||||||
|
useScene.getState().markDirty(nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove, true)
|
||||||
|
window.removeEventListener('pointerup', onUp, true)
|
||||||
|
window.removeEventListener('pointercancel', onCancel, true)
|
||||||
|
window.removeEventListener('contextmenu', preventContextMenu, true)
|
||||||
|
useLiveNodeOverrides.getState().clear(nodeId)
|
||||||
|
useScene.getState().markDirty(nodeId)
|
||||||
|
useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId)
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
useViewer.getState().setInputDragging(false)
|
||||||
|
if (document.body.style.cursor === 'ew-resize') {
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (moveEvent: PointerEvent) => {
|
||||||
|
if (moveEvent.pointerId !== pointerId) return
|
||||||
|
moveEvent.preventDefault()
|
||||||
|
moveEvent.stopPropagation()
|
||||||
|
applyDelta(moveEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = (upEvent: PointerEvent) => {
|
||||||
|
if (upEvent.pointerId !== pointerId) return
|
||||||
|
upEvent.preventDefault()
|
||||||
|
upEvent.stopPropagation()
|
||||||
|
swallowNextClick()
|
||||||
|
if (lastPatch) {
|
||||||
|
sceneApi.update(nodeId, lastPatch)
|
||||||
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = (cancelEvent: PointerEvent) => {
|
||||||
|
if (cancelEvent.pointerId !== pointerId) return
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
const preventContextMenu = (contextEvent: Event) => {
|
||||||
|
contextEvent.preventDefault()
|
||||||
|
contextEvent.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
useViewer.getState().setInputDragging(true)
|
||||||
|
useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId)
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
document.body.style.cursor = 'ew-resize'
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
applyDelta(event)
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onMove, true)
|
||||||
|
window.addEventListener('pointerup', onUp, true)
|
||||||
|
window.addEventListener('pointercancel', onCancel, true)
|
||||||
|
window.addEventListener('contextmenu', preventContextMenu, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointerdown', onPointerDown, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', onPointerDown, true)
|
||||||
|
}
|
||||||
|
}, [curvingFence, curvingWall, mode, movingNode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'select') return
|
if (mode !== 'select') return
|
||||||
if (movingNode || curvingWall || curvingFence) return
|
if (movingNode || curvingWall || curvingFence) return
|
||||||
@@ -1274,12 +1466,13 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
let currentPhase = useEditor.getState().phase
|
let currentPhase = useEditor.getState().phase
|
||||||
let currentStructureLayer = useEditor.getState().structureLayer
|
let currentStructureLayer = useEditor.getState().structureLayer
|
||||||
|
const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
|
||||||
|
|
||||||
// Auto-switch between zones, structure, and furnish when clicking elements on the same level.
|
// Auto-switch between zones, structure, and furnish when clicking elements on the same level.
|
||||||
// Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan).
|
// Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan).
|
||||||
if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') {
|
if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') {
|
||||||
if (isNodeInCurrentLevel(node)) {
|
if (isNodeInCurrentLevel(node)) {
|
||||||
const target = getSelectionTarget(node)
|
const target = resolveNodeSelectionTarget(node)
|
||||||
if (target) {
|
if (target) {
|
||||||
if (target.phase !== currentPhase) {
|
if (target.phase !== currentPhase) {
|
||||||
useEditor.getState().setPhase(target.phase)
|
useEditor.getState().setPhase(target.phase)
|
||||||
@@ -1330,7 +1523,12 @@ export const SelectionManager = () => {
|
|||||||
useEditor.getState().setEditingHole(null)
|
useEditor.getState().setEditingHole(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
|
activeStrategy.handleSelect(
|
||||||
|
nodeToSelect,
|
||||||
|
event.nativeEvent,
|
||||||
|
modifierKeysRef.current,
|
||||||
|
selectedIdsBeforeRouting,
|
||||||
|
)
|
||||||
|
|
||||||
let nextMaterialTargetHandled = false
|
let nextMaterialTargetHandled = false
|
||||||
|
|
||||||
@@ -1435,9 +1633,11 @@ export const SelectionManager = () => {
|
|||||||
emitter.on(`${type}:click` as any, onClick as any)
|
emitter.on(`${type}:click` as any, onClick as any)
|
||||||
})
|
})
|
||||||
|
|
||||||
const onGridClick = () => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
if (clickHandledRef.current) return
|
if (clickHandledRef.current) return
|
||||||
if (boxSelectHandled) return
|
if (boxSelectHandled) return
|
||||||
|
const nativeEvent = event.nativeEvent
|
||||||
|
if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return
|
||||||
const { phase, structureLayer } = useEditor.getState()
|
const { phase, structureLayer } = useEditor.getState()
|
||||||
const activeStrategy = SELECTION_STRATEGIES[phase]
|
const activeStrategy = SELECTION_STRATEGIES[phase]
|
||||||
if (activeStrategy) activeStrategy.handleDeselect()
|
if (activeStrategy) activeStrategy.handleDeselect()
|
||||||
@@ -1506,7 +1706,10 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
const currentPhase = useEditor.getState().phase
|
const currentPhase = useEditor.getState().phase
|
||||||
|
|
||||||
let targetPhase: 'site' | 'structure' | 'furnish' | null = null
|
const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
|
||||||
|
const target = resolveNodeSelectionTarget(node)
|
||||||
|
let targetPhase: 'site' | 'structure' | 'furnish' | null = target?.phase ?? null
|
||||||
|
let targetStructureLayer = target?.structureLayer
|
||||||
let forceSelect = false
|
let forceSelect = false
|
||||||
|
|
||||||
if (node.type === 'building' || node.type === 'site') {
|
if (node.type === 'building' || node.type === 'site') {
|
||||||
@@ -1515,36 +1718,15 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
if (node.type === 'building') {
|
if (node.type === 'building') {
|
||||||
targetPhase = 'structure'
|
targetPhase = 'structure'
|
||||||
|
targetStructureLayer = 'elements'
|
||||||
}
|
}
|
||||||
} else if (
|
} else {
|
||||||
node.type === 'wall' ||
|
|
||||||
node.type === 'fence' ||
|
|
||||||
node.type === 'column' ||
|
|
||||||
node.type === 'elevator' ||
|
|
||||||
node.type === 'slab' ||
|
|
||||||
node.type === 'ceiling' ||
|
|
||||||
node.type === 'roof' ||
|
|
||||||
node.type === 'roof-segment' ||
|
|
||||||
node.type === 'stair' ||
|
|
||||||
node.type === 'stair-segment' ||
|
|
||||||
node.type === 'spawn' ||
|
|
||||||
node.type === 'window' ||
|
|
||||||
node.type === 'door'
|
|
||||||
) {
|
|
||||||
targetPhase = 'structure'
|
|
||||||
if (node.type === 'roof-segment' && currentPhase === 'structure') {
|
if (node.type === 'roof-segment' && currentPhase === 'structure') {
|
||||||
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
|
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
|
||||||
}
|
}
|
||||||
if (node.type === 'stair-segment' && currentPhase === 'structure') {
|
if (node.type === 'stair-segment' && currentPhase === 'structure') {
|
||||||
forceSelect = true // allow double click to dive into stair-segment even if already in structure phase
|
forceSelect = true // allow double click to dive into stair-segment even if already in structure phase
|
||||||
}
|
}
|
||||||
} else if (node.type === 'item') {
|
|
||||||
const item = node as ItemNode
|
|
||||||
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
|
||||||
targetPhase = 'structure'
|
|
||||||
} else {
|
|
||||||
targetPhase = 'furnish'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'zone') {
|
if (node.type === 'zone') {
|
||||||
@@ -1558,13 +1740,22 @@ export const SelectionManager = () => {
|
|||||||
useEditor.getState().setPhase(targetPhase)
|
useEditor.getState().setPhase(targetPhase)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
|
if (
|
||||||
useEditor.getState().setStructureLayer('elements')
|
targetPhase === 'structure' &&
|
||||||
|
targetStructureLayer &&
|
||||||
|
targetStructureLayer !== useEditor.getState().structureLayer
|
||||||
|
) {
|
||||||
|
useEditor.getState().setStructureLayer(targetStructureLayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
|
const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
|
||||||
if (strategy) {
|
if (strategy) {
|
||||||
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
|
strategy.handleSelect(
|
||||||
|
node,
|
||||||
|
event.nativeEvent,
|
||||||
|
modifierKeysRef.current,
|
||||||
|
selectedIdsBeforeRouting,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
|
|||||||
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
||||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||||
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
||||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||||
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||||
@@ -329,10 +330,15 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep)
|
const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep)
|
||||||
const wallLocked =
|
const wallLocked =
|
||||||
!bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1])
|
!bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1])
|
||||||
const snappedPoint =
|
let snappedPoint = wallSnapped
|
||||||
wallLocked || wallAngleSnap
|
if (wallLocked) {
|
||||||
? wallSnapped
|
useAlignmentGuides.getState().clear()
|
||||||
: alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey || bypassSnap })
|
} else {
|
||||||
|
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
||||||
|
applySnap: !wallAngleSnap,
|
||||||
|
bypass: event.altKey || bypassSnap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
import { resolveElevatorSupportY } from '../../../lib/elevator-support'
|
import { resolveElevatorSupportY } from '../../../lib/elevator-support'
|
||||||
|
import { consumePlacementDragRelease } from '../../../lib/placement-drag-release'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
@@ -160,6 +161,7 @@ export function MoveElevatorTool({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (wasCommitted) return
|
||||||
const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
|
const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
|
||||||
|
|
||||||
wasCommitted = true
|
wasCommitted = true
|
||||||
@@ -189,6 +191,11 @@ export function MoveElevatorTool({
|
|||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||||
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
clearPreview()
|
clearPreview()
|
||||||
@@ -231,6 +238,7 @@ export function MoveElevatorTool({
|
|||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearPreview()
|
clearPreview()
|
||||||
@@ -247,6 +255,7 @@ export function MoveElevatorTool({
|
|||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
}
|
}
|
||||||
}, [movingNode, exitMoveMode])
|
}, [movingNode, exitMoveMode])
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { stripTransient } from './placement-math'
|
import { getDetachedAttachmentPreviewLift, stripTransient } from './placement-math'
|
||||||
|
|
||||||
describe('stripTransient', () => {
|
describe('stripTransient', () => {
|
||||||
test('removes placement-only metadata flags before commit', () => {
|
test('removes placement-only metadata flags before commit', () => {
|
||||||
@@ -8,3 +8,15 @@ describe('stripTransient', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getDetachedAttachmentPreviewLift', () => {
|
||||||
|
test('raises attach-only item previews while they are detached from their host', () => {
|
||||||
|
expect(getDetachedAttachmentPreviewLift('wall')).toBeGreaterThan(0)
|
||||||
|
expect(getDetachedAttachmentPreviewLift('wall-side')).toBeGreaterThan(0)
|
||||||
|
expect(getDetachedAttachmentPreviewLift('ceiling')).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps floor item previews on the floor', () => {
|
||||||
|
expect(getDetachedAttachmentPreviewLift(undefined)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ export function getGridAlignedDimensions(
|
|||||||
return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
|
return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDetachedAttachmentPreviewLift(
|
||||||
|
attachTo: AssetInput['attachTo'] | null | undefined,
|
||||||
|
): number {
|
||||||
|
return attachTo ? 0.45 : 0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate cursor rotation in WORLD space from wall normal and orientation.
|
* Calculate cursor rotation in WORLD space from wall normal and orientation.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export const floorStrategy = {
|
|||||||
): CommitResult | null {
|
): CommitResult | null {
|
||||||
if (ctx.state.surface !== 'floor') return null
|
if (ctx.state.surface !== 'floor') return null
|
||||||
if (!(ctx.levelId && ctx.draftItem)) return null
|
if (!(ctx.levelId && ctx.draftItem)) return null
|
||||||
|
if (ctx.draftItem.asset.attachTo) return null
|
||||||
|
|
||||||
const pos: [number, number, number] = [
|
const pos: [number, number, number] = [
|
||||||
ctx.gridPosition.x,
|
ctx.gridPosition.x,
|
||||||
|
|||||||
@@ -52,7 +52,12 @@ import {
|
|||||||
type PreviewBounds,
|
type PreviewBounds,
|
||||||
updateLineGeometry,
|
updateLineGeometry,
|
||||||
} from '../shared/placement-box-geometry'
|
} from '../shared/placement-box-geometry'
|
||||||
import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
|
import {
|
||||||
|
getDetachedAttachmentPreviewLift,
|
||||||
|
getGridAlignedDimensions,
|
||||||
|
snapToGrid,
|
||||||
|
snapUpToGridStep,
|
||||||
|
} from './placement-math'
|
||||||
import {
|
import {
|
||||||
ceilingStrategy,
|
ceilingStrategy,
|
||||||
checkCanPlace,
|
checkCanPlace,
|
||||||
@@ -732,6 +737,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
previousGridPos = [...gridPos]
|
previousGridPos = [...gridPos]
|
||||||
gridPosition.current.set(...gridPos)
|
gridPosition.current.set(...gridPos)
|
||||||
const cursorPosition = getFloorVisualPosition(gridPos)
|
const cursorPosition = getFloorVisualPosition(gridPos)
|
||||||
|
if (!draft && asset.attachTo) {
|
||||||
|
cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
|
||||||
|
}
|
||||||
cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
|
cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
|
||||||
// Floor items only rotate on Y; keep the preview box (and the live
|
// Floor items only rotate on Y; keep the preview box (and the live
|
||||||
// transform the 2D floorplan mirrors) aligned with the draft's
|
// transform the 2D floorplan mirrors) aligned with the draft's
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
|
|||||||
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
|
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { swallowNextClick } from '../../editor/node-arrow-handles'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
import { DragBoundingBox } from '../shared/drag-bounding-box'
|
import { DragBoundingBox } from '../shared/drag-bounding-box'
|
||||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||||
@@ -505,6 +506,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', commitAtCursor)
|
emitter.on('grid:click', commitAtCursor)
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!useEditor.getState().placementDragMode) return
|
||||||
|
if (event.button !== 0) return
|
||||||
|
swallowNextClick()
|
||||||
|
if (!hasMovedRef.current) {
|
||||||
|
exitMoveMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commitAtCursor({
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as ClickTriggerEvent)
|
||||||
|
}
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
// Listen on every common kind's click event too. mitt's typing keeps
|
// Listen on every common kind's click event too. mitt's typing keeps
|
||||||
// `${kind}:click` as a fixed union so the cast is safe at runtime —
|
// `${kind}:click` as a fixed union so the cast is safe at runtime —
|
||||||
// we're just routing them through the shared commit path.
|
// we're just routing them through the shared commit path.
|
||||||
@@ -539,6 +555,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
window.removeEventListener('keyup', onKeyUp)
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', commitAtCursor)
|
emitter.off('grid:click', commitAtCursor)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
for (const kind of CLICK_TRIGGER_KINDS) {
|
for (const kind of CLICK_TRIGGER_KINDS) {
|
||||||
const key = `${kind}:click` as ClickKey
|
const key = `${kind}:click` as ClickKey
|
||||||
emitter.off(key, commitAtCursor as never)
|
emitter.off(key, commitAtCursor as never)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ContextualShortcutHint } from '../../../lib/contextual-help'
|
||||||
|
import { cn } from '../../../lib/utils'
|
||||||
|
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||||
|
|
||||||
|
function ShortcutSequence({ keys }: { keys: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
|
{keys.map((key, index) => (
|
||||||
|
<div className="flex items-center gap-0.5" key={`${key}-${index}`}>
|
||||||
|
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">+</span> : null}
|
||||||
|
<ShortcutToken className="h-5 px-1.5 text-[10px]" value={key} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextualHelperPanel({ hints }: { hints: ContextualShortcutHint[] }) {
|
||||||
|
if (hints.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col gap-1.5 rounded-lg border border-border bg-background/95 px-3 py-2.5 shadow-lg backdrop-blur-md">
|
||||||
|
{hints.map((hint) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid min-w-0 grid-cols-1 gap-1 rounded-md text-sm',
|
||||||
|
hint.active && '-mx-1 bg-primary/10 px-1.5 py-1 text-foreground',
|
||||||
|
)}
|
||||||
|
key={`${hint.keys.join('+')}:${hint.label}`}
|
||||||
|
>
|
||||||
|
<ShortcutSequence keys={hint.keys} />
|
||||||
|
<span className="min-w-0 text-muted-foreground text-xs leading-snug">{hint.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,44 +1,118 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { nodeRegistry } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
nodeRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||||
|
import { resolveSelectModeHelpHints } from '../../../lib/contextual-help'
|
||||||
|
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { BuildingHelper } from './building-helper'
|
import { BuildingHelper } from './building-helper'
|
||||||
|
import { ContextualHelperPanel } from './contextual-helper-panel'
|
||||||
import { ItemHelper } from './item-helper'
|
import { ItemHelper } from './item-helper'
|
||||||
import { RegisteredToolHelper } from './registered-tool-helper'
|
import { RegisteredToolHelper } from './registered-tool-helper'
|
||||||
import { RoofHelper } from './roof-helper'
|
import { RoofHelper } from './roof-helper'
|
||||||
|
|
||||||
|
type ActiveModifierKeys = {
|
||||||
|
command: boolean
|
||||||
|
shift: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function useActiveModifierKeys(): ActiveModifierKeys {
|
||||||
|
const [modifiers, setModifiers] = useState<ActiveModifierKeys>({
|
||||||
|
command: false,
|
||||||
|
shift: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateModifiers = (event: KeyboardEvent) => {
|
||||||
|
const isKeyDown = event.type === 'keydown'
|
||||||
|
setModifiers({
|
||||||
|
command:
|
||||||
|
event.metaKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
(isKeyDown && (event.key === 'Meta' || event.key === 'Control')),
|
||||||
|
shift: event.shiftKey || (isKeyDown && event.key === 'Shift'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const clearModifiers = () => {
|
||||||
|
setModifiers({ command: false, shift: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', updateModifiers)
|
||||||
|
window.addEventListener('keyup', updateModifiers)
|
||||||
|
window.addEventListener('blur', clearModifiers)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', updateModifiers)
|
||||||
|
window.removeEventListener('keyup', updateModifiers)
|
||||||
|
window.removeEventListener('blur', clearModifiers)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return modifiers
|
||||||
|
}
|
||||||
|
|
||||||
export function HelperManager() {
|
export function HelperManager() {
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const tool = useEditor((s) => s.tool)
|
const tool = useEditor((s) => s.tool)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
const modifiers = useActiveModifierKeys()
|
||||||
|
const selectedNodes = useScene(
|
||||||
|
useShallow((s) =>
|
||||||
|
selectedIds
|
||||||
|
.map((id) => s.nodes[id as AnyNodeId])
|
||||||
|
.filter((node): node is AnyNode => node !== undefined),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const selectModeHints = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveSelectModeHelpHints({
|
||||||
|
selectedCount: selectedNodes.length,
|
||||||
|
hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)),
|
||||||
|
hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)),
|
||||||
|
commandPressed: modifiers.command,
|
||||||
|
shiftPressed: modifiers.shift,
|
||||||
|
}),
|
||||||
|
[modifiers.command, modifiers.shift, selectedNodes],
|
||||||
|
)
|
||||||
|
|
||||||
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
||||||
if (isMobile) return null
|
if (isMobile) return null
|
||||||
|
|
||||||
if (movingNode) {
|
if (movingNode) {
|
||||||
if (movingNode.type === 'building') return <BuildingHelper showRotate />
|
if (movingNode.type === 'building') return <BuildingHelper showRotate />
|
||||||
return <ItemHelper showEsc />
|
return <ItemHelper shiftPressed={modifiers.shift} showEsc />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'material-paint') {
|
if (mode === 'material-paint') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'select') {
|
||||||
|
return <ContextualHelperPanel hints={selectModeHints} />
|
||||||
|
}
|
||||||
|
|
||||||
// Registry-first: kinds with `def.toolHints` render through the generic
|
// Registry-first: kinds with `def.toolHints` render through the generic
|
||||||
// `RegisteredToolHelper`. Today that covers ceiling / door / fence /
|
// `RegisteredToolHelper`. Today that covers ceiling / door / fence /
|
||||||
// item / shelf / slab / spawn / wall / window.
|
// item / shelf / slab / spawn / wall / window.
|
||||||
if (tool) {
|
if (tool) {
|
||||||
const def = nodeRegistry.get(tool)
|
const def = nodeRegistry.get(tool)
|
||||||
if (def?.toolHints && def.toolHints.length > 0) {
|
if (def?.toolHints && def.toolHints.length > 0) {
|
||||||
return <RegisteredToolHelper hints={def.toolHints} />
|
return <RegisteredToolHelper hints={def.toolHints} shiftPressed={modifiers.shift} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fallback — only `roof` remains because it hasn't migrated to
|
// Legacy fallback — only `roof` remains because it hasn't migrated to
|
||||||
// `def.tool` / `def.toolHints` yet (no Stage D port). When roof
|
// `def.tool` / `def.toolHints` yet (no Stage D port). When roof
|
||||||
// migrates, this switch deletes outright.
|
// migrates, this switch deletes outright.
|
||||||
if (tool === 'roof') return <RoofHelper />
|
if (tool === 'roof') return <RoofHelper shiftPressed={modifiers.shift} />
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,24 @@
|
|||||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
import { ContextualHelperPanel } from './contextual-helper-panel'
|
||||||
|
|
||||||
interface ItemHelperProps {
|
interface ItemHelperProps {
|
||||||
showEsc?: boolean
|
showEsc?: boolean
|
||||||
|
shiftPressed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemHelper({ showEsc }: ItemHelperProps) {
|
export function ItemHelper({ showEsc, shiftPressed = false }: ItemHelperProps) {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
<ContextualHelperPanel
|
||||||
<div className="flex items-center gap-2 text-sm">
|
hints={[
|
||||||
<ShortcutToken value="Left click" />
|
{ keys: ['Left click'], label: 'Place item' },
|
||||||
<span className="text-muted-foreground">Place item</span>
|
{ keys: ['R'], label: 'Rotate counterclockwise' },
|
||||||
</div>
|
{ keys: ['T'], label: 'Rotate clockwise' },
|
||||||
<div className="flex items-center gap-2 text-sm">
|
{
|
||||||
<ShortcutToken value="R" />
|
keys: ['Shift'],
|
||||||
<span className="text-muted-foreground">Rotate counterclockwise</span>
|
label: shiftPressed ? 'Guided constraints bypassed' : 'Free place',
|
||||||
</div>
|
active: shiftPressed,
|
||||||
<div className="flex items-center gap-2 text-sm">
|
},
|
||||||
<ShortcutToken value="T" />
|
{ keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
|
||||||
<span className="text-muted-foreground">Rotate clockwise</span>
|
]}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<ShortcutToken value="Shift" />
|
|
||||||
<span className="text-muted-foreground">Free place</span>
|
|
||||||
</div>
|
|
||||||
{showEsc && (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<ShortcutToken value="Esc" />
|
|
||||||
<span className="text-muted-foreground">Cancel</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!showEsc && (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<ShortcutToken value="Right click" />
|
|
||||||
<span className="text-muted-foreground">Cancel</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ToolHint } from '@pascal-app/core'
|
import type { ToolHint } from '@pascal-app/core'
|
||||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
import { ContextualHelperPanel } from './contextual-helper-panel'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic helper panel rendered from `def.toolHints` data. Matches the
|
* Generic helper panel rendered from `def.toolHints` data. Matches the
|
||||||
@@ -10,16 +10,22 @@ import { ShortcutToken } from '../primitives/shortcut-token'
|
|||||||
* Drops the need for per-kind helper files entirely — kinds declare
|
* Drops the need for per-kind helper files entirely — kinds declare
|
||||||
* their hints as static data in their `NodeDefinition`.
|
* their hints as static data in their `NodeDefinition`.
|
||||||
*/
|
*/
|
||||||
export function RegisteredToolHelper({ hints }: { hints: ToolHint[] }) {
|
export function RegisteredToolHelper({
|
||||||
|
hints,
|
||||||
|
shiftPressed = false,
|
||||||
|
}: {
|
||||||
|
hints: ToolHint[]
|
||||||
|
shiftPressed?: boolean
|
||||||
|
}) {
|
||||||
if (hints.length === 0) return null
|
if (hints.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
<ContextualHelperPanel
|
||||||
{hints.map((hint) => (
|
hints={hints.map((hint) => ({
|
||||||
<div className="flex items-center gap-2 text-sm" key={`${hint.key}:${hint.label}`}>
|
keys: [hint.key],
|
||||||
<ShortcutToken value={hint.key} />
|
label:
|
||||||
<span className="text-muted-foreground">{hint.label}</span>
|
shiftPressed && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label,
|
||||||
</div>
|
active: shiftPressed && hint.key === 'Shift',
|
||||||
))}
|
}))}
|
||||||
</div>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
import { ContextualHelperPanel } from './contextual-helper-panel'
|
||||||
|
|
||||||
export function RoofHelper() {
|
export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
<ContextualHelperPanel
|
||||||
<div className="flex items-center gap-2 text-sm">
|
hints={[
|
||||||
<ShortcutToken value="Left click" />
|
{ keys: ['Left click'], label: 'Set corner' },
|
||||||
<span className="text-muted-foreground">Set corner</span>
|
{
|
||||||
</div>
|
keys: ['Shift'],
|
||||||
<div className="flex items-center gap-2 text-sm">
|
label: shiftPressed ? 'Guided constraints bypassed' : 'Free corner',
|
||||||
<ShortcutToken value="Esc" />
|
active: shiftPressed,
|
||||||
<span className="text-muted-foreground">Cancel</span>
|
},
|
||||||
</div>
|
{ keys: ['Esc'], label: 'Cancel' },
|
||||||
</div>
|
]}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,11 @@
|
|||||||
|
|
||||||
import type { AssetInput } from '@pascal-app/core'
|
import type { AssetInput } from '@pascal-app/core'
|
||||||
import { resolveCdnUrl, useViewer } from '@pascal-app/viewer'
|
import { resolveCdnUrl, useViewer } from '@pascal-app/viewer'
|
||||||
import Image from 'next/image'
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from './../../../components/ui/primitives/tooltip'
|
|
||||||
import { triggerSFX } from './../../../lib/sfx-bus'
|
import { triggerSFX } from './../../../lib/sfx-bus'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
||||||
|
import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge'
|
||||||
import { CATALOG_ITEMS } from './catalog-items'
|
import { CATALOG_ITEMS } from './catalog-items'
|
||||||
|
|
||||||
export function ItemCatalog({
|
export function ItemCatalog({
|
||||||
@@ -68,12 +63,6 @@ export function ItemCatalog({
|
|||||||
}
|
}
|
||||||
}, [categoryItems, selectedItem?.src, setSelectedItem])
|
}, [categoryItems, selectedItem?.src, setSelectedItem])
|
||||||
|
|
||||||
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
|
|
||||||
if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png'
|
|
||||||
if (attachTo === 'ceiling') return '/icons/ceiling.png'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredItems.length === 0 && emptyState) {
|
if (filteredItems.length === 0 && emptyState) {
|
||||||
return <>{emptyState}</>
|
return <>{emptyState}</>
|
||||||
}
|
}
|
||||||
@@ -86,7 +75,7 @@ export function ItemCatalog({
|
|||||||
{leadingTile}
|
{leadingTile}
|
||||||
{filteredItems.map((item, index) => {
|
{filteredItems.map((item, index) => {
|
||||||
const isSelected = selectedItem?.src === item?.src
|
const isSelected = selectedItem?.src === item?.src
|
||||||
const attachmentIcon = getAttachmentIcon(item?.attachTo)
|
const snapTarget = resolveAssetSnapTarget(item?.attachTo)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -114,14 +103,8 @@ export function ItemCatalog({
|
|||||||
loading="eager"
|
loading="eager"
|
||||||
src={resolveCdnUrl(item.thumbnail) || ''}
|
src={resolveCdnUrl(item.thumbnail) || ''}
|
||||||
/>
|
/>
|
||||||
{attachmentIcon && (
|
{snapTarget && (
|
||||||
<div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60">
|
<SnapTargetBadge className="absolute right-1 bottom-1" target={snapTarget} />
|
||||||
<img
|
|
||||||
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
|
|
||||||
className="h-4 w-4"
|
|
||||||
src={attachmentIcon}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
|
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
|
||||||
|
|||||||
+31
-5
@@ -71,7 +71,32 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
|||||||
{
|
{
|
||||||
keys: ['Cmd/Ctrl', 'Left click'],
|
keys: ['Cmd/Ctrl', 'Left click'],
|
||||||
action: 'Add or remove an object from multi-selection',
|
action: 'Add or remove an object from multi-selection',
|
||||||
note: 'Works while in Select mode.',
|
note: 'Works in Select mode on the 3D canvas, the 2D floor plan, and the scene graph.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: ['Shift', 'Left click'],
|
||||||
|
action: 'Add or remove an object from canvas multi-selection',
|
||||||
|
note: 'In the scene graph, Shift-click selects the visible range like a file browser.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Direct Manipulation',
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
keys: ['Cmd/Ctrl', 'Left click'],
|
||||||
|
action: 'Move the selected movable object under the cursor',
|
||||||
|
note: 'Drag in Select mode. Guided snapping and guides are enabled by default.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: ['Cmd/Ctrl', 'Right click'],
|
||||||
|
action: 'Rotate the selected object under the cursor',
|
||||||
|
note: 'Drag left or right in Select mode. Rotation snaps to 15° increments by default.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: ['Cmd/Ctrl', 'Shift', 'Right click'],
|
||||||
|
action: 'Rotate freely',
|
||||||
|
note: 'Hold Shift during the drag to bypass the 15° rotation increment.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -80,13 +105,13 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
|||||||
shortcuts: [
|
shortcuts: [
|
||||||
{
|
{
|
||||||
keys: ['Shift'],
|
keys: ['Shift'],
|
||||||
action: 'Draw at any angle, bypassing the default 15° angle snap',
|
action: 'Bypass guided snapping and angle constraints',
|
||||||
note: 'Hold while drawing walls, fences, slabs, ceilings, and zones.',
|
note: 'Hold during the active gesture. Passive guide or measurement feedback may stay visible.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: ['Shift'],
|
keys: ['Shift'],
|
||||||
action: 'Rotate freely, bypassing the default 15° rotation snap',
|
action: 'Rotate freely, bypassing the default 15° rotation snap',
|
||||||
note: 'Hold while dragging a rotate handle.',
|
note: 'Hold while dragging a rotate handle or direct-rotation gesture.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -162,7 +187,8 @@ export function KeyboardShortcutsDialog() {
|
|||||||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Shortcuts are context-aware and depend on the current phase or tool.
|
Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift
|
||||||
|
during an active gesture to build freely.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type AnyNodeId, type ChimneyNode, useScene } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import { SnapTargetIcon } from '../../../snap-target-badge'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor from './../../../../../store/use-editor'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
@@ -51,13 +52,15 @@ export const ChimneyTreeNode = memo(function ChimneyTreeNode({
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
hasChildren={false}
|
hasChildren={false}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<SnapTargetIcon target="roof">
|
||||||
alt=""
|
<Image
|
||||||
className="object-contain opacity-60"
|
alt=""
|
||||||
height={14}
|
className="object-contain opacity-60"
|
||||||
src="/icons/roof.png"
|
height={14}
|
||||||
width={14}
|
src="/icons/roof.png"
|
||||||
/>
|
width={14}
|
||||||
|
/>
|
||||||
|
</SnapTargetIcon>
|
||||||
}
|
}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
import { type AnyNodeId, type DoorNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor from './../../../../../store/use-editor'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
@@ -22,6 +23,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({
|
|||||||
}: DoorTreeNodeProps) {
|
}: DoorTreeNodeProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
||||||
|
const node = useScene((s) => s.nodes[nodeId] as DoorNode | undefined)
|
||||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
const setSelection = useViewer((state) => state.setSelection)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
@@ -45,6 +47,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({
|
|||||||
|
|
||||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||||
|
const snapTarget = resolveNodeSnapTarget(node) ?? 'wall'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNodeWrapper
|
<TreeNodeWrapper
|
||||||
@@ -53,7 +56,9 @@ export const DoorTreeNode = memo(function DoorTreeNode({
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
hasChildren={false}
|
hasChildren={false}
|
||||||
icon={
|
icon={
|
||||||
<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}
|
isHovered={isHovered}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type AnyNodeId, type DormerNode, useScene } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import { SnapTargetIcon } from '../../../snap-target-badge'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor from './../../../../../store/use-editor'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
@@ -57,13 +58,15 @@ export const DormerTreeNode = memo(function DormerTreeNode({
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
hasChildren={false}
|
hasChildren={false}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<SnapTargetIcon target="roof">
|
||||||
alt=""
|
<Image
|
||||||
className="object-contain opacity-60"
|
alt=""
|
||||||
height={14}
|
className="object-contain opacity-60"
|
||||||
src="/icons/roof.png"
|
height={14}
|
||||||
width={14}
|
src="/icons/roof.png"
|
||||||
/>
|
width={14}
|
||||||
|
/>
|
||||||
|
</SnapTargetIcon>
|
||||||
}
|
}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type AnyNodeId, type GutterNode, useScene } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import { SnapTargetIcon } from '../../../snap-target-badge'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor from './../../../../../store/use-editor'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
@@ -51,13 +52,15 @@ export const GutterTreeNode = memo(function GutterTreeNode({
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
hasChildren={false}
|
hasChildren={false}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<SnapTargetIcon target="roof">
|
||||||
alt=""
|
<Image
|
||||||
className="object-contain opacity-60"
|
alt=""
|
||||||
height={14}
|
className="object-contain opacity-60"
|
||||||
src="/icons/roof.png"
|
height={14}
|
||||||
width={14}
|
src="/icons/roof.png"
|
||||||
/>
|
width={14}
|
||||||
|
/>
|
||||||
|
</SnapTargetIcon>
|
||||||
}
|
}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useEffect, useState } from 'react'
|
import { memo, useCallback, useEffect, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
import {
|
||||||
|
focusTreeNode,
|
||||||
|
handleTreeSelection,
|
||||||
|
routeTreeSelectionToNode,
|
||||||
|
TreeNode,
|
||||||
|
TreeNodeWrapper,
|
||||||
|
} from './tree-node'
|
||||||
import { TreeNodeActions } from './tree-node-actions'
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
const CATEGORY_ICONS: Record<string, string> = {
|
const CATEGORY_ICONS: Record<string, string> = {
|
||||||
@@ -35,7 +41,8 @@ export const ItemTreeNode = memo(function ItemTreeNode({
|
|||||||
const children = useScene(
|
const children = useScene(
|
||||||
useShallow((s) => (s.nodes[nodeId] as ItemNode | undefined)?.children ?? []),
|
useShallow((s) => (s.nodes[nodeId] as ItemNode | undefined)?.children ?? []),
|
||||||
)
|
)
|
||||||
const asset = useScene((s) => (s.nodes[nodeId] as ItemNode | undefined)?.asset)
|
const node = useScene((s) => s.nodes[nodeId] as ItemNode | undefined)
|
||||||
|
const asset = node?.asset
|
||||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
const setSelection = useViewer((state) => state.setSelection)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
@@ -63,17 +70,15 @@ export const ItemTreeNode = memo(function ItemTreeNode({
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const handled = handleTreeSelection(
|
handleTreeSelection(
|
||||||
e,
|
e,
|
||||||
nodeId,
|
nodeId,
|
||||||
useViewer.getState().selection.selectedIds,
|
useViewer.getState().selection.selectedIds,
|
||||||
setSelection,
|
setSelection,
|
||||||
)
|
)
|
||||||
if (!handled && useEditor.getState().phase === 'structure') {
|
routeTreeSelectionToNode(node)
|
||||||
useEditor.getState().setPhase('furnish')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[nodeId, setSelection],
|
[node, nodeId, setSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
||||||
@@ -84,6 +89,7 @@ export const ItemTreeNode = memo(function ItemTreeNode({
|
|||||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||||
|
|
||||||
const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png'
|
const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png'
|
||||||
|
const snapTarget = resolveNodeSnapTarget(node)
|
||||||
const defaultName = asset?.name || 'Item'
|
const defaultName = asset?.name || 'Item'
|
||||||
const hasChildren = children.length > 0
|
const hasChildren = children.length > 0
|
||||||
|
|
||||||
@@ -93,7 +99,15 @@ export const ItemTreeNode = memo(function ItemTreeNode({
|
|||||||
depth={depth}
|
depth={depth}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
hasChildren={hasChildren}
|
hasChildren={hasChildren}
|
||||||
icon={<Image alt="" className="object-contain" height={14} src={iconSrc} width={14} />}
|
icon={
|
||||||
|
snapTarget ? (
|
||||||
|
<SnapTargetIcon target={snapTarget}>
|
||||||
|
<Image alt="" className="object-contain" height={14} src={iconSrc} width={14} />
|
||||||
|
</SnapTargetIcon>
|
||||||
|
) : (
|
||||||
|
<Image alt="" className="object-contain" height={14} src={iconSrc} width={14} />
|
||||||
|
)
|
||||||
|
}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
|||||||
+30
-14
@@ -2,9 +2,14 @@ import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
import {
|
||||||
|
focusTreeNode,
|
||||||
|
handleTreeSelection,
|
||||||
|
routeTreeSelectionToNode,
|
||||||
|
TreeNodeWrapper,
|
||||||
|
} from './tree-node'
|
||||||
import { TreeNodeActions } from './tree-node-actions'
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
interface RegistryTreeNodeProps {
|
interface RegistryTreeNodeProps {
|
||||||
@@ -36,22 +41,21 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
|
|||||||
const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
|
const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
|
||||||
const icon = presentation?.icon
|
const icon = presentation?.icon
|
||||||
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.png'
|
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.png'
|
||||||
|
const snapTarget = resolveNodeSnapTarget(node)
|
||||||
const defaultName = node?.name || presentation?.label || 'Node'
|
const defaultName = node?.name || presentation?.label || 'Node'
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const handled = handleTreeSelection(
|
handleTreeSelection(
|
||||||
e,
|
e,
|
||||||
nodeId,
|
nodeId,
|
||||||
useViewer.getState().selection.selectedIds,
|
useViewer.getState().selection.selectedIds,
|
||||||
setSelection,
|
setSelection,
|
||||||
)
|
)
|
||||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
routeTreeSelectionToNode(node)
|
||||||
useEditor.getState().setPhase('structure')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[nodeId, setSelection],
|
[node, nodeId, setSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,13 +65,25 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
hasChildren={false}
|
hasChildren={false}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
snapTarget ? (
|
||||||
alt=""
|
<SnapTargetIcon target={snapTarget}>
|
||||||
className="object-contain opacity-60"
|
<Image
|
||||||
height={14}
|
alt=""
|
||||||
src={iconSrc}
|
className="object-contain opacity-60"
|
||||||
width={14}
|
height={14}
|
||||||
/>
|
src={iconSrc}
|
||||||
|
width={14}
|
||||||
|
/>
|
||||||
|
</SnapTargetIcon>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="object-contain opacity-60"
|
||||||
|
height={14}
|
||||||
|
src={iconSrc}
|
||||||
|
width={14}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useEffect, useState } from 'react'
|
import { memo, useCallback, useEffect, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
import {
|
||||||
|
focusTreeNode,
|
||||||
|
handleTreeSelection,
|
||||||
|
routeTreeSelectionToNode,
|
||||||
|
TreeNode,
|
||||||
|
TreeNodeWrapper,
|
||||||
|
} from './tree-node'
|
||||||
import { TreeNodeActions } from './tree-node-actions'
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
interface ShelfTreeNodeProps {
|
interface ShelfTreeNodeProps {
|
||||||
@@ -34,6 +39,7 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
|
|||||||
const children = useScene(
|
const children = useScene(
|
||||||
useShallow((s) => (s.nodes[nodeId] as ShelfNode | undefined)?.children ?? []),
|
useShallow((s) => (s.nodes[nodeId] as ShelfNode | undefined)?.children ?? []),
|
||||||
)
|
)
|
||||||
|
const node = useScene((s) => s.nodes[nodeId] as ShelfNode | undefined)
|
||||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
const setSelection = useViewer((state) => state.setSelection)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
@@ -63,17 +69,15 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const handled = handleTreeSelection(
|
handleTreeSelection(
|
||||||
e,
|
e,
|
||||||
nodeId,
|
nodeId,
|
||||||
useViewer.getState().selection.selectedIds,
|
useViewer.getState().selection.selectedIds,
|
||||||
setSelection,
|
setSelection,
|
||||||
)
|
)
|
||||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
routeTreeSelectionToNode(node)
|
||||||
useEditor.getState().setPhase('structure')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[nodeId, setSelection],
|
[node, nodeId, setSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
||||||
|
|||||||
+10
-7
@@ -2,6 +2,7 @@ import { type AnyNodeId, type SolarPanelNode, useScene } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import { SnapTargetIcon } from '../../../snap-target-badge'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor from './../../../../../store/use-editor'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
@@ -51,13 +52,15 @@ export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
hasChildren={false}
|
hasChildren={false}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<SnapTargetIcon target="roof">
|
||||||
alt=""
|
<Image
|
||||||
className="object-contain opacity-60"
|
alt=""
|
||||||
height={14}
|
className="object-contain opacity-60"
|
||||||
src="/icons/roof.png"
|
height={14}
|
||||||
width={14}
|
src="/icons/roof.png"
|
||||||
/>
|
width={14}
|
||||||
|
/>
|
||||||
|
</SnapTargetIcon>
|
||||||
}
|
}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { forwardRef, memo, useEffect, useRef } from 'react'
|
import { forwardRef, memo, useEffect, useRef } from 'react'
|
||||||
|
import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing'
|
||||||
|
import useEditor from '../../../../../store/use-editor'
|
||||||
|
|
||||||
export function handleTreeSelection(
|
export function handleTreeSelection(
|
||||||
e: React.MouseEvent,
|
e: React.MouseEvent,
|
||||||
@@ -53,6 +56,31 @@ export function focusTreeNode(nodeId: AnyNodeId) {
|
|||||||
emitter.emit('camera-controls:focus', { nodeId })
|
emitter.emit('camera-controls:focus', { nodeId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function routeTreeSelectionToNode(node: AnyNode | null | undefined) {
|
||||||
|
const target = node ? resolveNodeSelectionTarget(node) : null
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
const selectedIdsAfterClick = useViewer.getState().selection.selectedIds
|
||||||
|
const editor = useEditor.getState()
|
||||||
|
let didRoute = false
|
||||||
|
|
||||||
|
if (target.phase !== editor.phase) {
|
||||||
|
editor.setPhase(target.phase)
|
||||||
|
didRoute = true
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
target.phase === 'structure' &&
|
||||||
|
target.structureLayer &&
|
||||||
|
target.structureLayer !== useEditor.getState().structureLayer
|
||||||
|
) {
|
||||||
|
useEditor.getState().setStructureLayer(target.structureLayer)
|
||||||
|
didRoute = true
|
||||||
|
}
|
||||||
|
if (didRoute) {
|
||||||
|
useViewer.getState().setSelection({ selectedIds: selectedIdsAfterClick })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
import { cn } from '../../../../../lib/utils'
|
import { cn } from '../../../../../lib/utils'
|
||||||
import { BuildingTreeNode } from './building-tree-node'
|
import { BuildingTreeNode } from './building-tree-node'
|
||||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||||
@@ -102,6 +130,7 @@ const treeNodeByType: Record<
|
|||||||
ceiling: CeilingTreeNode,
|
ceiling: CeilingTreeNode,
|
||||||
chimney: ChimneyTreeNode,
|
chimney: ChimneyTreeNode,
|
||||||
dormer: DormerTreeNode,
|
dormer: DormerTreeNode,
|
||||||
|
downspout: RegistryTreeNode,
|
||||||
'solar-panel': SolarPanelTreeNode,
|
'solar-panel': SolarPanelTreeNode,
|
||||||
column: ColumnTreeNode,
|
column: ColumnTreeNode,
|
||||||
elevator: ElevatorTreeNode,
|
elevator: ElevatorTreeNode,
|
||||||
@@ -266,10 +295,10 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
|
'flex h-5 w-5 shrink-0 items-center justify-center transition-all duration-200',
|
||||||
!isSelected && 'opacity-60 grayscale',
|
!isSelected && 'opacity-60 grayscale',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
import { type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor from './../../../../../store/use-editor'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
@@ -22,6 +23,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
|
|||||||
}: WindowTreeNodeProps) {
|
}: WindowTreeNodeProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
||||||
|
const node = useScene((s) => s.nodes[nodeId] as WindowNode | undefined)
|
||||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
const setSelection = useViewer((state) => state.setSelection)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
@@ -45,6 +47,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
|
|||||||
|
|
||||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||||
|
const snapTarget = resolveNodeSnapTarget(node) ?? 'wall'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNodeWrapper
|
<TreeNodeWrapper
|
||||||
@@ -53,7 +56,9 @@ export const WindowTreeNode = memo(function WindowTreeNode({
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
hasChildren={false}
|
hasChildren={false}
|
||||||
icon={
|
icon={
|
||||||
<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}
|
isHovered={isHovered}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type { AnyNode } from '@pascal-app/core'
|
||||||
|
import { resolveAssetSnapTarget, resolveNodeSnapTarget } from './snap-target-badge'
|
||||||
|
|
||||||
|
describe('resolveAssetSnapTarget', () => {
|
||||||
|
test('maps wall-hosted catalog assets to a wall badge', () => {
|
||||||
|
expect(resolveAssetSnapTarget('wall')).toBe('wall')
|
||||||
|
expect(resolveAssetSnapTarget('wall-side')).toBe('wall')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps ceiling-hosted catalog assets to a ceiling badge', () => {
|
||||||
|
expect(resolveAssetSnapTarget('ceiling')).toBe('ceiling')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not badge floor assets', () => {
|
||||||
|
expect(resolveAssetSnapTarget(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveNodeSnapTarget', () => {
|
||||||
|
test('prefers roof attachment when a node is hosted by a roof segment', () => {
|
||||||
|
const node = {
|
||||||
|
id: 'window_1',
|
||||||
|
type: 'window',
|
||||||
|
roofSegmentId: 'roof-segment_1',
|
||||||
|
} as unknown as AnyNode
|
||||||
|
|
||||||
|
expect(resolveNodeSnapTarget(node)).toBe('roof')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('badges gutter-hosted downspouts as roof accessories', () => {
|
||||||
|
const node = {
|
||||||
|
id: 'downspout_1',
|
||||||
|
type: 'downspout',
|
||||||
|
gutterId: 'gutter_1',
|
||||||
|
} as unknown as AnyNode
|
||||||
|
|
||||||
|
expect(resolveNodeSnapTarget(node)).toBe('roof')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { AnyNode, AssetInput } from '@pascal-app/core'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
export type SnapTarget = 'wall' | 'ceiling' | 'roof'
|
||||||
|
export type SnapTargetBadgeSize = 'tile' | 'tree'
|
||||||
|
|
||||||
|
const SNAP_TARGET_ICONS: Record<SnapTarget, string> = {
|
||||||
|
wall: '/icons/wall.png',
|
||||||
|
ceiling: '/icons/ceiling.png',
|
||||||
|
roof: '/icons/roof.png',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAP_TARGET_LABELS: Record<SnapTarget, string> = {
|
||||||
|
wall: 'Wall attachment',
|
||||||
|
ceiling: 'Ceiling attachment',
|
||||||
|
roof: 'Roof attachment',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAP_TARGET_BADGE_SIZE_CLASSES: Record<SnapTargetBadgeSize, string> = {
|
||||||
|
tile: 'h-6 w-6 rounded-md',
|
||||||
|
tree: 'h-3.5 w-3.5 rounded-[3px]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAP_TARGET_ICON_SIZE_CLASSES: Record<SnapTargetBadgeSize, string> = {
|
||||||
|
tile: 'h-[18px] w-[18px]',
|
||||||
|
tree: 'h-2.5 w-2.5',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAssetSnapTarget(attachTo: AssetInput['attachTo']): SnapTarget | null {
|
||||||
|
if (attachTo === 'wall' || attachTo === 'wall-side') return 'wall'
|
||||||
|
if (attachTo === 'ceiling') return 'ceiling'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNodeSnapTarget(node: AnyNode | null | undefined): SnapTarget | null {
|
||||||
|
if (!node) return null
|
||||||
|
if ('roofSegmentId' in node && typeof node.roofSegmentId === 'string') return 'roof'
|
||||||
|
if (node.type === 'downspout') return 'roof'
|
||||||
|
if (node.type === 'door' || node.type === 'window') return 'wall'
|
||||||
|
if (node.type === 'item') return resolveAssetSnapTarget(node.asset?.attachTo)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SnapTargetBadge({
|
||||||
|
className,
|
||||||
|
size = 'tile',
|
||||||
|
target,
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
size?: SnapTargetBadgeSize
|
||||||
|
target: SnapTarget
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center bg-black/65 ring-1 ring-white/20',
|
||||||
|
SNAP_TARGET_BADGE_SIZE_CLASSES[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt={SNAP_TARGET_LABELS[target]}
|
||||||
|
className={cn('object-contain', SNAP_TARGET_ICON_SIZE_CLASSES[size])}
|
||||||
|
src={SNAP_TARGET_ICONS[target]}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SnapTargetIcon({
|
||||||
|
children,
|
||||||
|
target,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
target: SnapTarget
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="relative inline-flex h-5 w-5 items-center justify-center">
|
||||||
|
{children}
|
||||||
|
<SnapTargetBadge
|
||||||
|
className="-right-1.5 -bottom-1.5 absolute"
|
||||||
|
size="tree"
|
||||||
|
target={target}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -165,6 +165,13 @@ export {
|
|||||||
} from './components/ui/sidebar/panels/settings-panel'
|
} from './components/ui/sidebar/panels/settings-panel'
|
||||||
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
||||||
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
||||||
|
export {
|
||||||
|
resolveAssetSnapTarget,
|
||||||
|
resolveNodeSnapTarget,
|
||||||
|
type SnapTarget,
|
||||||
|
SnapTargetBadge,
|
||||||
|
SnapTargetIcon,
|
||||||
|
} from './components/ui/snap-target-badge'
|
||||||
export type { SaveStatus } from './hooks/use-auto-save'
|
export type { SaveStatus } from './hooks/use-auto-save'
|
||||||
// useDragAction is the React-side glue for the registry's DragAction
|
// useDragAction is the React-side glue for the registry's DragAction
|
||||||
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
|
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
|
||||||
@@ -226,6 +233,7 @@ export {
|
|||||||
linearUnitToMeters,
|
linearUnitToMeters,
|
||||||
metersToLinearUnit,
|
metersToLinearUnit,
|
||||||
} from './lib/measurements'
|
} from './lib/measurements'
|
||||||
|
export { consumePlacementDragRelease } from './lib/placement-drag-release'
|
||||||
export {
|
export {
|
||||||
addFreshPlacementMetadata,
|
addFreshPlacementMetadata,
|
||||||
getPlacementMetadataRecord,
|
getPlacementMetadataRecord,
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { resolveSelectModeHelpHints } from './contextual-help'
|
||||||
|
|
||||||
|
describe('resolveSelectModeHelpHints', () => {
|
||||||
|
test('stays hidden in idle select mode with no selection', () => {
|
||||||
|
expect(
|
||||||
|
resolveSelectModeHelpHints({
|
||||||
|
selectedCount: 0,
|
||||||
|
hasMovableSelection: false,
|
||||||
|
hasRotatableSelection: false,
|
||||||
|
commandPressed: false,
|
||||||
|
shiftPressed: false,
|
||||||
|
}),
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows multi-select guidance when a modifier is held without selection', () => {
|
||||||
|
expect(
|
||||||
|
resolveSelectModeHelpHints({
|
||||||
|
selectedCount: 0,
|
||||||
|
hasMovableSelection: false,
|
||||||
|
hasRotatableSelection: false,
|
||||||
|
commandPressed: true,
|
||||||
|
shiftPressed: false,
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
keys: ['Cmd/Ctrl', 'Left click'],
|
||||||
|
label: 'Add or remove objects from the selection',
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows direct manipulation tips for selected movable and rotatable nodes', () => {
|
||||||
|
const hints = resolveSelectModeHelpHints({
|
||||||
|
selectedCount: 1,
|
||||||
|
hasMovableSelection: true,
|
||||||
|
hasRotatableSelection: true,
|
||||||
|
commandPressed: false,
|
||||||
|
shiftPressed: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(hints).toContainEqual({
|
||||||
|
keys: ['Cmd/Ctrl', 'Left click'],
|
||||||
|
label: 'Drag selected movable object',
|
||||||
|
})
|
||||||
|
expect(hints).toContainEqual({
|
||||||
|
keys: ['Cmd/Ctrl', 'Right click'],
|
||||||
|
label: 'Drag left or right to rotate selected object',
|
||||||
|
})
|
||||||
|
expect(hints).toContainEqual({
|
||||||
|
keys: ['Shift'],
|
||||||
|
label: 'Hold to bypass snaps and angle steps',
|
||||||
|
active: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switches direct manipulation labels while constraints are bypassed', () => {
|
||||||
|
const hints = resolveSelectModeHelpHints({
|
||||||
|
selectedCount: 1,
|
||||||
|
hasMovableSelection: true,
|
||||||
|
hasRotatableSelection: true,
|
||||||
|
commandPressed: true,
|
||||||
|
shiftPressed: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(hints).toContainEqual({
|
||||||
|
keys: ['Cmd/Ctrl', 'Left click'],
|
||||||
|
label: 'Drag selected movable object freely',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
expect(hints).toContainEqual({
|
||||||
|
keys: ['Cmd/Ctrl', 'Right click'],
|
||||||
|
label: 'Drag left or right to rotate freely',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
expect(hints).toContainEqual({
|
||||||
|
keys: ['Shift'],
|
||||||
|
label: 'Guided constraints bypassed',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
export type ContextualShortcutHint = {
|
||||||
|
keys: string[]
|
||||||
|
label: string
|
||||||
|
active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectModeHelpContext = {
|
||||||
|
selectedCount: number
|
||||||
|
hasMovableSelection: boolean
|
||||||
|
hasRotatableSelection: boolean
|
||||||
|
commandPressed: boolean
|
||||||
|
shiftPressed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_KEY = 'Cmd/Ctrl'
|
||||||
|
const LEFT_CLICK = 'Left click'
|
||||||
|
const RIGHT_CLICK = 'Right click'
|
||||||
|
const SHIFT_KEY = 'Shift'
|
||||||
|
|
||||||
|
export function resolveSelectModeHelpHints({
|
||||||
|
selectedCount,
|
||||||
|
hasMovableSelection,
|
||||||
|
hasRotatableSelection,
|
||||||
|
commandPressed,
|
||||||
|
shiftPressed,
|
||||||
|
}: SelectModeHelpContext): ContextualShortcutHint[] {
|
||||||
|
const hints: ContextualShortcutHint[] = []
|
||||||
|
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
if (!commandPressed && !shiftPressed) return hints
|
||||||
|
|
||||||
|
hints.push({
|
||||||
|
keys: [commandPressed ? COMMAND_KEY : SHIFT_KEY, LEFT_CLICK],
|
||||||
|
label: 'Add or remove objects from the selection',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
return hints
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandPressed) {
|
||||||
|
if (hasMovableSelection) {
|
||||||
|
hints.push({
|
||||||
|
keys: [COMMAND_KEY, LEFT_CLICK],
|
||||||
|
label: shiftPressed
|
||||||
|
? 'Drag selected movable object freely'
|
||||||
|
: 'Drag selected movable object with guides',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRotatableSelection) {
|
||||||
|
hints.push({
|
||||||
|
keys: [COMMAND_KEY, RIGHT_CLICK],
|
||||||
|
label: shiftPressed
|
||||||
|
? 'Drag left or right to rotate freely'
|
||||||
|
: 'Drag left or right to rotate',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hints.push({
|
||||||
|
keys: [COMMAND_KEY, LEFT_CLICK],
|
||||||
|
label: 'Click without dragging to add or remove objects',
|
||||||
|
active: commandPressed && !shiftPressed,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if (hasMovableSelection) {
|
||||||
|
hints.push({
|
||||||
|
keys: [COMMAND_KEY, LEFT_CLICK],
|
||||||
|
label: 'Drag selected movable object',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRotatableSelection) {
|
||||||
|
hints.push({
|
||||||
|
keys: [COMMAND_KEY, RIGHT_CLICK],
|
||||||
|
label: 'Drag left or right to rotate selected object',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hints.push({
|
||||||
|
keys: [SHIFT_KEY],
|
||||||
|
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps',
|
||||||
|
active: shiftPressed,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!commandPressed) {
|
||||||
|
hints.push({
|
||||||
|
keys: [COMMAND_KEY, LEFT_CLICK],
|
||||||
|
label: 'Add or remove objects from the selection',
|
||||||
|
})
|
||||||
|
hints.push({
|
||||||
|
keys: [SHIFT_KEY, LEFT_CLICK],
|
||||||
|
label: 'Add or remove objects on the canvas',
|
||||||
|
active: shiftPressed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeDefinition,
|
||||||
|
DEFAULT_ANGLE_STEP,
|
||||||
|
nodeRegistry,
|
||||||
|
registerNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
canDirectMoveNode,
|
||||||
|
resolveDirectRotationDragDelta,
|
||||||
|
snapDirectRotationDelta,
|
||||||
|
} from './direct-manipulation'
|
||||||
|
|
||||||
|
function registerTestDefinition(kind: string, overrides: Partial<AnyNodeDefinition>) {
|
||||||
|
if (nodeRegistry.has(kind)) return
|
||||||
|
registerNode({
|
||||||
|
kind,
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: z.object({ type: z.literal(kind) }) as never,
|
||||||
|
category: 'structure',
|
||||||
|
defaults: () => ({ type: kind }) as never,
|
||||||
|
capabilities: {},
|
||||||
|
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
||||||
|
...overrides,
|
||||||
|
} as AnyNodeDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('snapDirectRotationDelta', () => {
|
||||||
|
test('snaps rotation deltas to the default angle increment', () => {
|
||||||
|
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.49, false)).toBe(0)
|
||||||
|
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.51, false)).toBeCloseTo(
|
||||||
|
DEFAULT_ANGLE_STEP,
|
||||||
|
)
|
||||||
|
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * -1.49, false)).toBeCloseTo(
|
||||||
|
-DEFAULT_ANGLE_STEP,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the raw rotation delta while free-rotating', () => {
|
||||||
|
const rawDelta = DEFAULT_ANGLE_STEP * 0.42
|
||||||
|
expect(snapDirectRotationDelta(rawDelta, true)).toBe(rawDelta)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveDirectRotationDragDelta', () => {
|
||||||
|
test('maps horizontal pointer motion to the direct rotation delta direction', () => {
|
||||||
|
const radiansPerPixel = DEFAULT_ANGLE_STEP / 12
|
||||||
|
|
||||||
|
expect(resolveDirectRotationDragDelta(100, 112, radiansPerPixel, false)).toBeCloseTo(
|
||||||
|
-DEFAULT_ANGLE_STEP,
|
||||||
|
)
|
||||||
|
expect(resolveDirectRotationDragDelta(100, 88, radiansPerPixel, false)).toBeCloseTo(
|
||||||
|
DEFAULT_ANGLE_STEP,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps unsnapped drag deltas while free-rotating', () => {
|
||||||
|
expect(resolveDirectRotationDragDelta(100, 103, 0.1, true)).toBeCloseTo(-0.3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('canDirectMoveNode', () => {
|
||||||
|
test('excludes floorplan-only move targets from 3D direct move', () => {
|
||||||
|
const kind = 'direct-move-floorplan-only-test'
|
||||||
|
registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
|
||||||
|
|
||||||
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('excludes bespoke move tools from 3D direct move', () => {
|
||||||
|
const kind = 'direct-move-bespoke-tool-test'
|
||||||
|
registerTestDefinition(kind, {
|
||||||
|
affordanceTools: {
|
||||||
|
move: async () => ({ default: () => null }),
|
||||||
|
} as never,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts nodes with the generic movable capability', () => {
|
||||||
|
const kind = 'direct-move-movable-test'
|
||||||
|
registerTestDefinition(kind, {
|
||||||
|
capabilities: {
|
||||||
|
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||||
|
},
|
||||||
|
} as Partial<AnyNodeDefinition>)
|
||||||
|
|
||||||
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type ArcResizeHandle,
|
||||||
|
createSceneApi,
|
||||||
|
DEFAULT_ANGLE_STEP,
|
||||||
|
type HandleDescriptor,
|
||||||
|
nodeRegistry,
|
||||||
|
type SceneApi,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
|
function resolveHandles(node: AnyNode): HandleDescriptor<AnyNode>[] {
|
||||||
|
const handles = nodeRegistry.get(node.type)?.handles
|
||||||
|
if (!handles) return []
|
||||||
|
return (
|
||||||
|
typeof handles === 'function' ? handles(node as never) : handles
|
||||||
|
) as HandleDescriptor<AnyNode>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDirectRotateHandle(node: AnyNode): ArcResizeHandle<AnyNode> | null {
|
||||||
|
for (const handle of resolveHandles(node)) {
|
||||||
|
if (handle.kind === 'arc-resize' && handle.shape === 'rotate') {
|
||||||
|
return handle as ArcResizeHandle<AnyNode>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canDirectRotateNode(node: AnyNode): boolean {
|
||||||
|
return (
|
||||||
|
getDirectRotateHandle(node) !== null ||
|
||||||
|
nodeRegistry.get(node.type)?.capabilities?.rotatable !== undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canDirectMoveNode(node: AnyNode): boolean {
|
||||||
|
return nodeRegistry.get(node.type)?.capabilities?.movable !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapDirectRotationDelta(delta: number, free: boolean): number {
|
||||||
|
return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDirectRotationDragDelta(
|
||||||
|
startX: number,
|
||||||
|
clientX: number,
|
||||||
|
radiansPerPixel: number,
|
||||||
|
free: boolean,
|
||||||
|
): number {
|
||||||
|
return snapDirectRotationDelta((startX - clientX) * radiansPerPixel, free)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDirectRotationPatch(
|
||||||
|
node: AnyNode,
|
||||||
|
delta: number,
|
||||||
|
sceneApi: SceneApi = createSceneApi(useScene),
|
||||||
|
): Partial<AnyNode> | null {
|
||||||
|
const rotateHandle = getDirectRotateHandle(node)
|
||||||
|
if (rotateHandle) {
|
||||||
|
return rotateHandle.apply(node, delta, sceneApi) as Partial<AnyNode>
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotation = (node as { rotation?: unknown }).rotation
|
||||||
|
if (typeof rotation === 'number') {
|
||||||
|
return { rotation: rotation - delta } as Partial<AnyNode>
|
||||||
|
}
|
||||||
|
if (Array.isArray(rotation)) {
|
||||||
|
const [rx = 0, ry = 0, rz = 0] = rotation as [number?, number?, number?]
|
||||||
|
return { rotation: [rx, ry - delta, rz] } as Partial<AnyNode>
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
|
import { applyFloorplanAlignment } from './apply-alignment'
|
||||||
|
|
||||||
|
describe('applyFloorplanAlignment', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('can publish passive guides without applying snap', () => {
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
|
||||||
|
const result = applyFloorplanAlignment(
|
||||||
|
[0.04, 2],
|
||||||
|
[{ nodeId: 'draft', kind: 'corner', x: 0.04, z: 2 }],
|
||||||
|
[{ nodeId: 'wall_a', kind: 'corner', x: 0, z: 0 }],
|
||||||
|
{ applySnap: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.point).toEqual([0.04, 2])
|
||||||
|
expect(result.snapped).toBe(false)
|
||||||
|
expect(result.guides).toHaveLength(1)
|
||||||
|
expect(useAlignmentGuides.getState().guides).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,9 +30,9 @@ export type FloorplanAlignmentResult = {
|
|||||||
*
|
*
|
||||||
* Publishes guides to the `useAlignmentGuides` store as a side effect — set
|
* Publishes guides to the `useAlignmentGuides` store as a side effect — set
|
||||||
* on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer`
|
* on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer`
|
||||||
* renders them. Returns the adjusted point. When `bypass` is true (Alt held)
|
* renders them. Returns the adjusted point. When `bypass` is true (Alt for
|
||||||
* the point is returned unchanged and guides are cleared, matching the
|
* alignment-only bypass, or Shift for the full guided-constraint bypass) the
|
||||||
* "No snap" affordance the placement tools advertise.
|
* point is returned unchanged and guides are cleared.
|
||||||
*
|
*
|
||||||
* `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`);
|
* `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`);
|
||||||
* the scene is stable during a single drag, so re-collecting per pointer-move
|
* the scene is stable during a single drag, so re-collecting per pointer-move
|
||||||
@@ -42,7 +42,7 @@ export function applyFloorplanAlignment(
|
|||||||
point: readonly [number, number],
|
point: readonly [number, number],
|
||||||
movingAnchors: AlignmentAnchor[],
|
movingAnchors: AlignmentAnchor[],
|
||||||
candidates: AlignmentAnchor[],
|
candidates: AlignmentAnchor[],
|
||||||
opts?: { bypass?: boolean; threshold?: number },
|
opts?: { applySnap?: boolean; bypass?: boolean; threshold?: number },
|
||||||
): FloorplanAlignmentResult {
|
): FloorplanAlignmentResult {
|
||||||
if (opts?.bypass) {
|
if (opts?.bypass) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -57,7 +57,7 @@ export function applyFloorplanAlignment(
|
|||||||
|
|
||||||
useAlignmentGuides.getState().set(result.guides)
|
useAlignmentGuides.getState().set(result.guides)
|
||||||
|
|
||||||
if (!result.snap) {
|
if (!result.snap || opts?.applySnap === false) {
|
||||||
return { point: [point[0], point[1]], snapped: false, guides: result.guides }
|
return { point: [point[0], point[1]], snapped: false, guides: result.guides }
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -81,8 +81,9 @@ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
|
|||||||
*
|
*
|
||||||
* Used by BOTH the move-preview branch and the click-commit handler so the
|
* Used by BOTH the move-preview branch and the click-commit handler so the
|
||||||
* committed vertex lands exactly where the preview showed it. Caller owns the
|
* committed vertex lands exactly where the preview showed it. Caller owns the
|
||||||
* per-kind precedence (existing-wall endpoint/join snap wins; angle-snap
|
* per-kind precedence: existing-wall endpoint/join snap can still win, while
|
||||||
* suppresses alignment) and only calls this when alignment should apply.
|
* angle-locked segments can pass `applySnap: false` to publish passive guide
|
||||||
|
* feedback without pulling the endpoint off its constrained ray.
|
||||||
*
|
*
|
||||||
* `excludeIds` drops those nodes' anchors from the candidate pool — used when
|
* `excludeIds` drops those nodes' anchors from the candidate pool — used when
|
||||||
* dragging a wall / fence endpoint so the moving endpoint doesn't try to
|
* dragging a wall / fence endpoint so the moving endpoint doesn't try to
|
||||||
@@ -90,7 +91,12 @@ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
|
|||||||
*/
|
*/
|
||||||
export function alignFloorplanDraftPoint(
|
export function alignFloorplanDraftPoint(
|
||||||
point: readonly [number, number],
|
point: readonly [number, number],
|
||||||
opts?: { bypass?: boolean; threshold?: number; excludeIds?: readonly string[] },
|
opts?: {
|
||||||
|
applySnap?: boolean
|
||||||
|
bypass?: boolean
|
||||||
|
threshold?: number
|
||||||
|
excludeIds?: readonly string[]
|
||||||
|
},
|
||||||
): [number, number] {
|
): [number, number] {
|
||||||
if (opts?.bypass) {
|
if (opts?.bypass) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -105,7 +111,7 @@ export function alignFloorplanDraftPoint(
|
|||||||
point,
|
point,
|
||||||
[{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }],
|
[{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }],
|
||||||
candidates,
|
candidates,
|
||||||
{ threshold: opts?.threshold },
|
{ applySnap: opts?.applySnap, threshold: opts?.threshold },
|
||||||
)
|
)
|
||||||
return snapped
|
return snapped
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import useEditor from '../store/use-editor'
|
||||||
|
|
||||||
|
function swallowNextClick(timeoutMs = 300) {
|
||||||
|
const swallow = (event: Event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('click', swallow, { capture: true, once: true })
|
||||||
|
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePlacementDragRelease(event: PointerEvent): boolean {
|
||||||
|
if (!useEditor.getState().placementDragMode) return false
|
||||||
|
if (event.button !== 0) return false
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
swallowNextClick()
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type { AnyNode } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
resolveNodeSelectionTarget,
|
||||||
|
resolveSelectedIdsForNodeClick,
|
||||||
|
selectionModifiersFromEvent,
|
||||||
|
} from './selection-routing'
|
||||||
|
|
||||||
|
describe('resolveSelectedIdsForNodeClick', () => {
|
||||||
|
test('preserves the pre-routing selection when a phase switch clears current ids', () => {
|
||||||
|
expect(
|
||||||
|
resolveSelectedIdsForNodeClick({
|
||||||
|
baseSelectedIds: ['wall_1'],
|
||||||
|
currentSelectedIds: [],
|
||||||
|
modifierKeys: { meta: true, ctrl: false, shift: false },
|
||||||
|
nodeId: 'item_1',
|
||||||
|
}),
|
||||||
|
).toEqual(['wall_1', 'item_1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toggles from the pre-routing selection while a modifier is held', () => {
|
||||||
|
expect(
|
||||||
|
resolveSelectedIdsForNodeClick({
|
||||||
|
baseSelectedIds: ['wall_1', 'item_1'],
|
||||||
|
currentSelectedIds: [],
|
||||||
|
modifierKeys: { meta: false, ctrl: false, shift: true },
|
||||||
|
nodeId: 'item_1',
|
||||||
|
}),
|
||||||
|
).toEqual(['wall_1'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectionModifiersFromEvent', () => {
|
||||||
|
test('falls back to tracked modifier state when the click event omits keys', () => {
|
||||||
|
expect(selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false })).toEqual({
|
||||||
|
meta: false,
|
||||||
|
ctrl: true,
|
||||||
|
shift: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prefers explicit event key state over stale tracked modifiers', () => {
|
||||||
|
expect(
|
||||||
|
selectionModifiersFromEvent(
|
||||||
|
{ metaKey: false, ctrlKey: false, shiftKey: false },
|
||||||
|
{ meta: true, ctrl: true, shift: true },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
meta: false,
|
||||||
|
ctrl: false,
|
||||||
|
shift: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveNodeSelectionTarget', () => {
|
||||||
|
test('routes furniture items to furnish', () => {
|
||||||
|
const node = {
|
||||||
|
id: 'item_1',
|
||||||
|
type: 'item',
|
||||||
|
asset: { category: 'furniture' },
|
||||||
|
} as unknown as AnyNode
|
||||||
|
|
||||||
|
expect(resolveNodeSelectionTarget(node)).toEqual({ phase: 'furnish' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('routes door and window catalog items to structure', () => {
|
||||||
|
const node = {
|
||||||
|
id: 'item_1',
|
||||||
|
type: 'item',
|
||||||
|
asset: { category: 'door' },
|
||||||
|
} as unknown as AnyNode
|
||||||
|
|
||||||
|
expect(resolveNodeSelectionTarget(node)).toEqual({
|
||||||
|
phase: 'structure',
|
||||||
|
structureLayer: 'elements',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { type AnyNode, type ItemNode, nodeRegistry } from '@pascal-app/core'
|
||||||
|
|
||||||
|
export type SelectionModifierKeys = {
|
||||||
|
meta: boolean
|
||||||
|
ctrl: boolean
|
||||||
|
shift: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeSelectionTarget = {
|
||||||
|
phase: 'site' | 'structure' | 'furnish'
|
||||||
|
structureLayer?: 'zones' | 'elements'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelectionModifierActive(keys: SelectionModifierKeys): boolean {
|
||||||
|
return keys.meta || keys.ctrl || keys.shift
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionModifiersFromEvent(
|
||||||
|
event?: {
|
||||||
|
metaKey?: boolean
|
||||||
|
ctrlKey?: boolean
|
||||||
|
shiftKey?: boolean
|
||||||
|
nativeEvent?: {
|
||||||
|
metaKey?: boolean
|
||||||
|
ctrlKey?: boolean
|
||||||
|
shiftKey?: boolean
|
||||||
|
}
|
||||||
|
} | null,
|
||||||
|
fallback?: Partial<SelectionModifierKeys>,
|
||||||
|
): SelectionModifierKeys {
|
||||||
|
const fromEvent = (
|
||||||
|
key: keyof SelectionModifierKeys,
|
||||||
|
eventKey: 'metaKey' | 'ctrlKey' | 'shiftKey',
|
||||||
|
) => {
|
||||||
|
if (typeof event?.[eventKey] === 'boolean') return event[eventKey]
|
||||||
|
if (typeof event?.nativeEvent?.[eventKey] === 'boolean') return event.nativeEvent[eventKey]
|
||||||
|
return Boolean(fallback?.[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: fromEvent('meta', 'metaKey'),
|
||||||
|
ctrl: fromEvent('ctrl', 'ctrlKey'),
|
||||||
|
shift: fromEvent('shift', 'shiftKey'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSelectedIdsForNodeClick({
|
||||||
|
baseSelectedIds,
|
||||||
|
currentSelectedIds,
|
||||||
|
modifierKeys,
|
||||||
|
nodeId,
|
||||||
|
}: {
|
||||||
|
baseSelectedIds?: readonly string[]
|
||||||
|
currentSelectedIds: readonly string[]
|
||||||
|
modifierKeys: SelectionModifierKeys
|
||||||
|
nodeId: string
|
||||||
|
}): string[] {
|
||||||
|
if (isSelectionModifierActive(modifierKeys)) {
|
||||||
|
const selectedIds = baseSelectedIds ?? currentSelectedIds
|
||||||
|
if (selectedIds.includes(nodeId)) {
|
||||||
|
return selectedIds.filter((id) => id !== nodeId)
|
||||||
|
}
|
||||||
|
return [...selectedIds, nodeId]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [nodeId]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNodeSelectionTarget(node: AnyNode): NodeSelectionTarget | null {
|
||||||
|
if (node.type === 'building') {
|
||||||
|
return { phase: 'site' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'zone') {
|
||||||
|
return {
|
||||||
|
phase: 'structure',
|
||||||
|
structureLayer: 'zones',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'item') {
|
||||||
|
const item = node as ItemNode
|
||||||
|
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
||||||
|
return {
|
||||||
|
phase: 'structure',
|
||||||
|
structureLayer: 'elements',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { phase: 'furnish' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.type === 'wall' ||
|
||||||
|
node.type === 'fence' ||
|
||||||
|
node.type === 'column' ||
|
||||||
|
node.type === 'elevator' ||
|
||||||
|
node.type === 'slab' ||
|
||||||
|
node.type === 'ceiling' ||
|
||||||
|
node.type === 'roof' ||
|
||||||
|
node.type === 'roof-segment' ||
|
||||||
|
node.type === 'stair' ||
|
||||||
|
node.type === 'stair-segment' ||
|
||||||
|
node.type === 'spawn' ||
|
||||||
|
node.type === 'window' ||
|
||||||
|
node.type === 'door'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
phase: 'structure',
|
||||||
|
structureLayer: 'elements',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const def = nodeRegistry.get(node.type)
|
||||||
|
if (!def) return null
|
||||||
|
|
||||||
|
if (def.category === 'furnish') {
|
||||||
|
return { phase: 'furnish' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase: 'structure',
|
||||||
|
structureLayer: 'elements',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { AnyNodeId } from '@pascal-app/core'
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
type DirectManipulationFeedbackState = {
|
||||||
|
activeRotateNodeId: AnyNodeId | null
|
||||||
|
setActiveRotateNodeId(nodeId: AnyNodeId | null): void
|
||||||
|
clearActiveRotateNodeId(nodeId?: AnyNodeId): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDirectManipulationFeedback = create<DirectManipulationFeedbackState>((set) => ({
|
||||||
|
activeRotateNodeId: null,
|
||||||
|
setActiveRotateNodeId: (activeRotateNodeId) => set({ activeRotateNodeId }),
|
||||||
|
clearActiveRotateNodeId: (nodeId) =>
|
||||||
|
set((state) => {
|
||||||
|
if (nodeId !== undefined && state.activeRotateNodeId !== nodeId) return {}
|
||||||
|
return { activeRotateNodeId: null }
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default useDirectManipulationFeedback
|
||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import {
|
||||||
|
consumePlacementDragRelease,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import {
|
import {
|
||||||
@@ -59,11 +64,22 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
let lastTarget: RelativeRoofDragTarget | null = null
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
let committed = false
|
||||||
const roofDrag = createRelativeRoofDrag(original)
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
|
const clearTarget = () => {
|
||||||
|
lastTarget = null
|
||||||
|
lastSnap = null
|
||||||
|
setPreviewPos(null)
|
||||||
|
setPreviewSurfaceQuat(null)
|
||||||
|
}
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
clearTarget()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(target.localX * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
@@ -90,8 +106,10 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const target = lastTarget ?? roofDrag.resolve(event)
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
committed = true
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
@@ -176,16 +194,29 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (!lastTarget) return
|
||||||
|
onRoofClick({
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as RoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('roof:move', updatePreview)
|
emitter.on('roof:move', updatePreview)
|
||||||
emitter.on('roof:enter', updatePreview)
|
emitter.on('roof:enter', updatePreview)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', clearTarget)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', clearTarget)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
// Safety restore — if the tool is unmounted by something other than
|
// Safety restore — if the tool is unmounted by something other than
|
||||||
// a commit / cancel path (e.g. tool change, selection wipe), leave
|
// a commit / cancel path (e.g. tool change, selection wipe), leave
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import { boxVentDefinition } from './definition'
|
import { boxVentDefinition } from './definition'
|
||||||
@@ -122,16 +123,26 @@ const BoxVentTool = () => {
|
|||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={previewPos}>
|
<>
|
||||||
<group rotation-y={previewYaw}>
|
<RoofAttachmentFallbackPreview
|
||||||
<group quaternion={previewSurfaceQuat}>
|
activeBuildingId={activeBuildingId}
|
||||||
<BoxVentPreview node={previewNode} />
|
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>
|
)}
|
||||||
</group>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import {
|
||||||
|
CursorSphere,
|
||||||
|
consumePlacementDragRelease,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
@@ -151,6 +157,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (wasCommitted) return
|
||||||
const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter
|
const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter
|
||||||
|
|
||||||
wasCommitted = true
|
wasCommitted = true
|
||||||
@@ -169,6 +176,11 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
|||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||||
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
// Revert mesh position and rotation immediately
|
// Revert mesh position and rotation immediately
|
||||||
const mesh = sceneRegistry.nodes.get(nodeId)
|
const mesh = sceneRegistry.nodes.get(nodeId)
|
||||||
@@ -190,6 +202,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
|||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (!wasCommitted) {
|
if (!wasCommitted) {
|
||||||
@@ -207,6 +220,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
|||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
}
|
}
|
||||||
}, [exitMoveMode]) // stable — node values captured via refs at mount
|
}, [exitMoveMode]) // stable — node values captured via refs at mount
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
|||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Trace ceiling outline' },
|
{ key: 'Left click', label: 'Trace ceiling outline' },
|
||||||
{ key: 'Enter', label: 'Finish ceiling' },
|
{ key: 'Enter', label: 'Finish ceiling' },
|
||||||
|
{ key: 'Shift', label: 'Free outline' },
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
|
consumePlacementDragRelease,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
@@ -189,6 +190,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (wasCommitted) return
|
||||||
if (isFloorplanSourcedEvent(event)) return
|
if (isFloorplanSourcedEvent(event)) return
|
||||||
if (Date.now() - activatedAtRef.current < 150) {
|
if (Date.now() - activatedAtRef.current < 150) {
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
@@ -214,6 +216,12 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
activatedAtRef.current = 0
|
||||||
|
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||||
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
clearPreview()
|
clearPreview()
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -225,6 +233,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -236,6 +245,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
}
|
}
|
||||||
}, [exitMoveMode, node.id])
|
}, [exitMoveMode, node.id])
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { triggerSFX, useEditor } from '@pascal-app/editor'
|
import { consumePlacementDragRelease, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
@@ -89,9 +89,19 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
|||||||
roofSegmentId: node.roofSegmentId,
|
roofSegmentId: node.roofSegmentId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const clearTarget = () => {
|
||||||
|
lastTarget = null
|
||||||
|
setSegmentXform(null)
|
||||||
|
setHitLocal(null)
|
||||||
|
setPreviewSegment(null)
|
||||||
|
}
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
clearTarget()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(target.localX * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
@@ -156,14 +166,27 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (!lastTarget) return
|
||||||
|
onClick({
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as RoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('roof:move', updatePreview)
|
emitter.on('roof:move', updatePreview)
|
||||||
emitter.on('roof:enter', updatePreview)
|
emitter.on('roof:enter', updatePreview)
|
||||||
emitter.on('roof:click', onClick)
|
emitter.on('roof:click', onClick)
|
||||||
|
emitter.on('roof:leave', clearTarget)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
|
emitter.off('roof:leave', clearTarget)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
}
|
}
|
||||||
}, [activeBuildingId, node, setMovingNode, setSelection])
|
}, [activeBuildingId, node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { chimneyDefinition } from './definition'
|
import { chimneyDefinition } from './definition'
|
||||||
import ChimneyPreview from './preview'
|
import ChimneyPreview from './preview'
|
||||||
@@ -139,19 +140,30 @@ const ChimneyTool = () => {
|
|||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
if (!activeBuildingId || !segmentXform || !hitLocal || !previewSegment) return null
|
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
<>
|
||||||
<group position={[hitLocal[0], 0, hitLocal[2]]}>
|
<RoofAttachmentFallbackPreview
|
||||||
<ChimneyPreview node={previewNode} segment={previewSegment} />
|
activeBuildingId={activeBuildingId}
|
||||||
</group>
|
onInvalidTarget={() => {
|
||||||
</group>
|
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'),
|
tool: () => import('./tool'),
|
||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Place column' },
|
{ key: 'Left click', label: 'Place column' },
|
||||||
{ key: 'Alt', label: 'No snap' },
|
{ key: 'Shift', label: 'Free place' },
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
],
|
],
|
||||||
floorplan: buildColumnFloorplan,
|
floorplan: buildColumnFloorplan,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
commitFreshPlacementSubtree,
|
commitFreshPlacementSubtree,
|
||||||
|
consumePlacementDragRelease,
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
getFloorStackPreviewPosition,
|
getFloorStackPreviewPosition,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
@@ -165,6 +166,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (committed) return
|
||||||
if (!hasMoved) return
|
if (!hasMoved) return
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
// Commit at the last previewed position so the alignment snap (which
|
// Commit at the last previewed position so the alignment snap (which
|
||||||
@@ -225,6 +227,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||||
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
useLiveTransforms.getState().clear(node.id)
|
useLiveTransforms.getState().clear(node.id)
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -244,12 +251,14 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import {
|
||||||
|
consumePlacementDragRelease,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import {
|
import {
|
||||||
@@ -57,11 +62,22 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
let lastTarget: RelativeRoofDragTarget | null = null
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
let committed = false
|
||||||
const roofDrag = createRelativeRoofDrag(original)
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
|
const clearTarget = () => {
|
||||||
|
lastTarget = null
|
||||||
|
lastSnap = null
|
||||||
|
setPreviewPos(null)
|
||||||
|
setPreviewSurfaceQuat(null)
|
||||||
|
}
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
clearTarget()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(target.localX * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
@@ -88,8 +104,10 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const target = lastTarget ?? roofDrag.resolve(event)
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
committed = true
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
@@ -168,16 +186,29 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (!lastTarget) return
|
||||||
|
onRoofClick({
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as RoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('roof:move', updatePreview)
|
emitter.on('roof:move', updatePreview)
|
||||||
emitter.on('roof:enter', updatePreview)
|
emitter.on('roof:enter', updatePreview)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', clearTarget)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', clearTarget)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import { cupolaDefinition } from './definition'
|
import { cupolaDefinition } from './definition'
|
||||||
@@ -114,16 +115,26 @@ const CupolaTool = () => {
|
|||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={previewPos}>
|
<>
|
||||||
<group rotation-y={previewYaw}>
|
<RoofAttachmentFallbackPreview
|
||||||
<group quaternion={previewSurfaceQuat}>
|
activeBuildingId={activeBuildingId}
|
||||||
<CupolaPreview node={previewNode} />
|
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>
|
)}
|
||||||
</group>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
|||||||
|
|
||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Place door on wall' },
|
{ key: 'Left click', label: 'Place door on wall' },
|
||||||
|
{ key: 'Shift', label: 'Free place' },
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
calculateCursorRotation,
|
calculateCursorRotation,
|
||||||
calculateItemRotation,
|
calculateItemRotation,
|
||||||
|
consumePlacementDragRelease,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
getSideFromNormal,
|
getSideFromNormal,
|
||||||
isValidWallSideFace,
|
isValidWallSideFace,
|
||||||
@@ -80,6 +81,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
|
|
||||||
let currentHostId: string | null = movingDoorNode.parentId
|
let currentHostId: string | null = movingDoorNode.parentId
|
||||||
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
||||||
|
let committed = false
|
||||||
let lastTarget: {
|
let lastTarget: {
|
||||||
wallNode: WallEvent['node']
|
wallNode: WallEvent['node']
|
||||||
wallId: string
|
wallId: string
|
||||||
@@ -91,6 +93,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
valid: boolean
|
valid: boolean
|
||||||
event: WallEvent
|
event: WallEvent
|
||||||
} | null = null
|
} | null = null
|
||||||
|
let lastRoofEvent: RoofEvent | null = null
|
||||||
|
|
||||||
const markHostDirty = (hostId: string | null) => {
|
const markHostDirty = (hostId: string | null) => {
|
||||||
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||||
@@ -254,34 +257,50 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
const target = resolveMoveTarget(event)
|
const target = resolveMoveTarget(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
onWallLeave()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
lastRoofEvent = null
|
||||||
applyPreview(target)
|
applyPreview(target)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) {
|
||||||
|
onWallLeave()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isCurvedWall(event.node)) {
|
if (isCurvedWall(event.node)) {
|
||||||
hideCursor()
|
onWallLeave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.node.parentId !== getLevelId()) {
|
||||||
|
onWallLeave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.node.parentId !== getLevelId()) return
|
|
||||||
|
|
||||||
const target = resolveMoveTarget(event)
|
const target = resolveMoveTarget(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
onWallLeave()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
lastRoofEvent = null
|
||||||
applyPreview(target)
|
applyPreview(target)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallClick = (event: WallEvent) => {
|
const onWallClick = (event: WallEvent) => {
|
||||||
|
if (committed) return
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
if (isCurvedWall(event.node)) return
|
if (isCurvedWall(event.node)) return
|
||||||
if (event.node.parentId !== getLevelId()) return
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||||
if (!target?.valid) return
|
if (!target?.valid) return
|
||||||
|
committed = true
|
||||||
|
|
||||||
let placedId: string
|
let placedId: string
|
||||||
|
|
||||||
@@ -349,6 +368,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
|
lastRoofEvent = null
|
||||||
if (isNew) return
|
if (isNew) return
|
||||||
if (currentHostId && currentHostId !== original.parentId) {
|
if (currentHostId && currentHostId !== original.parentId) {
|
||||||
markHostDirty(currentHostId)
|
markHostDirty(currentHostId)
|
||||||
@@ -387,10 +407,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
|
|
||||||
const onRoofHover = (event: RoofEvent) => {
|
const onRoofHover = (event: RoofEvent) => {
|
||||||
const target = resolveRoofMoveTarget(event)
|
const target = resolveRoofMoveTarget(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
onRoofLeave()
|
||||||
|
return
|
||||||
|
}
|
||||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
|
lastRoofEvent = event
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
if (currentHostId !== target.segment.id) {
|
if (currentHostId !== target.segment.id) {
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
@@ -416,8 +440,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const target = resolveRoofMoveTarget(event)
|
const target = resolveRoofMoveTarget(event)
|
||||||
if (!target?.valid) return
|
if (!target?.valid) return
|
||||||
|
committed = true
|
||||||
const segmentId = target.segment.id
|
const segmentId = target.segment.id
|
||||||
|
|
||||||
let placedId: string
|
let placedId: string
|
||||||
@@ -487,6 +513,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
|
lastRoofEvent = null
|
||||||
if (isNew) return
|
if (isNew) return
|
||||||
if (currentHostId && currentHostId !== original.parentId) {
|
if (currentHostId && currentHostId !== original.parentId) {
|
||||||
markHostDirty(currentHostId)
|
markHostDirty(currentHostId)
|
||||||
@@ -527,6 +554,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (lastTarget) {
|
||||||
|
onWallClick(lastTarget.event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('wall:enter', onWallEnter)
|
emitter.on('wall:enter', onWallEnter)
|
||||||
emitter.on('wall:move', onWallMove)
|
emitter.on('wall:move', onWallMove)
|
||||||
emitter.on('wall:click', onWallClick)
|
emitter.on('wall:click', onWallClick)
|
||||||
@@ -536,6 +572,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
emitter.on('roof:leave', onRoofLeave)
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
||||||
@@ -572,6 +609,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
emitter.off('roof:leave', onRoofLeave)
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
}
|
}
|
||||||
}, [movingDoorNode, exitMoveMode])
|
}, [movingDoorNode, exitMoveMode])
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
@@ -22,12 +23,13 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
import {
|
import {
|
||||||
getRoofWallOpeningCursorPose,
|
getRoofWallOpeningCursorPose,
|
||||||
type RoofWallOpeningTarget,
|
type RoofWallOpeningTarget,
|
||||||
resolveRoofWallOpeningTarget,
|
resolveRoofWallOpeningTarget,
|
||||||
|
worldToSelectedBuildingLocal,
|
||||||
} from '../shared/roof-wall-opening-placement'
|
} from '../shared/roof-wall-opening-placement'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||||
@@ -39,6 +41,10 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const FALLBACK_WIDTH = 0.9
|
||||||
|
const FALLBACK_HEIGHT = 2.1
|
||||||
|
const roofFallbackPoint = new Vector3()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Door tool — places DoorNodes on walls and on roof-segment wall faces
|
* Door tool — places DoorNodes on walls and on roof-segment wall faces
|
||||||
* (the generated base walls under a roof, including coplanar gable ends).
|
* (the generated base walls under a roof, including coplanar gable ends).
|
||||||
@@ -99,16 +105,47 @@ const DoorTool: React.FC = () => {
|
|||||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showFallbackCursor = (event: GridEvent) => {
|
||||||
|
if (draftRef.current) return
|
||||||
|
const [x, y, z] = event.localPosition
|
||||||
|
updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||||
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||||
|
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showWallFallbackCursor = (event: WallEvent) => {
|
||||||
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||||
|
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) {
|
||||||
|
destroyDraft()
|
||||||
|
showWallFallbackCursor(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isCurvedWall(event.node)) {
|
if (isCurvedWall(event.node)) {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
showWallFallbackCursor(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const levelId = getLevelId()
|
const levelId = getLevelId()
|
||||||
if (!levelId) return
|
if (!levelId) {
|
||||||
if (event.node.parentId !== levelId) return
|
destroyDraft()
|
||||||
|
showWallFallbackCursor(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.node.parentId !== levelId) {
|
||||||
|
destroyDraft()
|
||||||
|
showWallFallbackCursor(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
|
|
||||||
@@ -158,13 +195,21 @@ const DoorTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) {
|
||||||
if (isCurvedWall(event.node)) {
|
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
showWallFallbackCursor(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isCurvedWall(event.node)) {
|
||||||
|
destroyDraft()
|
||||||
|
showWallFallbackCursor(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.node.parentId !== getLevelId()) {
|
||||||
|
destroyDraft()
|
||||||
|
showWallFallbackCursor(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.node.parentId !== getLevelId()) return
|
|
||||||
|
|
||||||
const side = getSideFromNormal(event.normal)
|
const side = getSideFromNormal(event.normal)
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
@@ -373,10 +418,8 @@ const DoorTool: React.FC = () => {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
// On the roof but not over a placeable wall face (slope, soffit,
|
// On the roof but not over a placeable wall face (slope, soffit,
|
||||||
// or a face the door cannot fit on).
|
// or a face the door cannot fit on).
|
||||||
if (draftRef.current?.roofSegmentId) {
|
destroyDraft()
|
||||||
destroyDraft()
|
showRoofFallbackCursor(event)
|
||||||
hideCursor()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { segment, face, position } = target
|
const { segment, face, position } = target
|
||||||
@@ -483,6 +526,7 @@ const DoorTool: React.FC = () => {
|
|||||||
emitter.on('roof:move', onRoofHover)
|
emitter.on('roof:move', onRoofHover)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
emitter.on('roof:leave', onRoofLeave)
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
|
emitter.on('grid:move', showFallbackCursor)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -498,12 +542,13 @@ const DoorTool: React.FC = () => {
|
|||||||
emitter.off('roof:move', onRoofHover)
|
emitter.off('roof:move', onRoofHover)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
emitter.off('roof:leave', onRoofLeave)
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
|
emitter.off('grid:move', showFallbackCursor)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Cursor geometry: door outline (default 0.9 × 2.1 × 0.07)
|
// Cursor geometry: door outline.
|
||||||
const boxGeo = new BoxGeometry(0.9, 2.1, 0.07)
|
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
||||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
const edgesGeo = new EdgesGeometry(boxGeo)
|
||||||
boxGeo.dispose()
|
boxGeo.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core'
|
import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { dormerDefinition } from './definition'
|
import { dormerDefinition } from './definition'
|
||||||
import DormerPreview from './preview'
|
import DormerPreview from './preview'
|
||||||
import { useDormerPlacement } from './use-dormer-placement'
|
import { useDormerPlacement } from './use-dormer-placement'
|
||||||
@@ -47,36 +48,44 @@ const DormerTool = () => {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
|
const { activeBuildingId, clearPreview, segmentXform, hitLocal, ghostRotation } =
|
||||||
onCommit: (hit, rotation) => {
|
useDormerPlacement({
|
||||||
const state = useScene.getState()
|
onCommit: (hit, rotation) => {
|
||||||
const dormer = DormerNode.parse({
|
const state = useScene.getState()
|
||||||
...dormerDefinition.defaults(),
|
const dormer = DormerNode.parse({
|
||||||
name: `Dormer ${nextDormerNumber(state.nodes)}`,
|
...dormerDefinition.defaults(),
|
||||||
roofSegmentId: hit.segment.id,
|
name: `Dormer ${nextDormerNumber(state.nodes)}`,
|
||||||
parentId: hit.segment.id,
|
roofSegmentId: hit.segment.id,
|
||||||
// Anchor at the slope height so the renderer matches the ghost.
|
parentId: hit.segment.id,
|
||||||
// The CSG still carves cleanly because it inverts T(position)
|
// Anchor at the slope height so the renderer matches the ghost.
|
||||||
// when bringing the host into dormer-local.
|
// The CSG still carves cleanly because it inverts T(position)
|
||||||
position: [hit.localX, hit.localY, hit.localZ],
|
// when bringing the host into dormer-local.
|
||||||
rotation,
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
})
|
rotation,
|
||||||
state.createNode(dormer, hit.segment.id as AnyNodeId)
|
})
|
||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.createNode(dormer, hit.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [dormer.id] })
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
},
|
setSelection({ selectedIds: [dormer.id] })
|
||||||
})
|
},
|
||||||
|
})
|
||||||
if (!activeBuildingId || !segmentXform || !hitLocal) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
<>
|
||||||
<group position={hitLocal}>
|
<RoofAttachmentFallbackPreview
|
||||||
<group rotation-y={ghostRotation}>
|
activeBuildingId={activeBuildingId}
|
||||||
<DormerPreview node={previewNode} />
|
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>
|
)}
|
||||||
</group>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { triggerSFX } from '@pascal-app/editor'
|
import { consumePlacementDragRelease, triggerSFX } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
@@ -58,6 +58,7 @@ export function useDormerPlacement(opts: {
|
|||||||
onCommit: (hit: DormerPlacementHit, rotation: number) => void
|
onCommit: (hit: DormerPlacementHit, rotation: number) => void
|
||||||
}): {
|
}): {
|
||||||
activeBuildingId: string | undefined
|
activeBuildingId: string | undefined
|
||||||
|
clearPreview: () => void
|
||||||
segmentXform: DormerSegmentTransform | null
|
segmentXform: DormerSegmentTransform | null
|
||||||
hitLocal: [number, number, number] | null
|
hitLocal: [number, number, number] | null
|
||||||
ghostRotation: number
|
ghostRotation: number
|
||||||
@@ -78,6 +79,11 @@ export function useDormerPlacement(opts: {
|
|||||||
const onCommitRef = useRef(opts.onCommit)
|
const onCommitRef = useRef(opts.onCommit)
|
||||||
onCommitRef.current = opts.onCommit
|
onCommitRef.current = opts.onCommit
|
||||||
|
|
||||||
|
const clearPreview = () => {
|
||||||
|
setSegmentXform(null)
|
||||||
|
setHitLocal(null)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeBuildingId) return
|
if (!activeBuildingId) return
|
||||||
|
|
||||||
@@ -99,6 +105,7 @@ export function useDormerPlacement(opts: {
|
|||||||
const roofDrag = relativeStartRef.current
|
const roofDrag = relativeStartRef.current
|
||||||
? createRelativeRoofDrag(relativeStartRef.current)
|
? createRelativeRoofDrag(relativeStartRef.current)
|
||||||
: null
|
: null
|
||||||
|
let committed = false
|
||||||
let lastRelativeHit: DormerPlacementHit | null = null
|
let lastRelativeHit: DormerPlacementHit | null = null
|
||||||
|
|
||||||
const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => {
|
const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => {
|
||||||
@@ -139,15 +146,27 @@ export function useDormerPlacement(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onClick = (event: RoofEvent) => {
|
const onClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const hit = roofDrag
|
const hit = roofDrag
|
||||||
? (lastRelativeHit ?? resolvePlacementHit(event))
|
? (lastRelativeHit ?? resolvePlacementHit(event))
|
||||||
: resolvePlacementHit(event)
|
: resolvePlacementHit(event)
|
||||||
if (!hit) return
|
if (!hit) return
|
||||||
|
committed = true
|
||||||
onCommitRef.current(hit, ghostRotationRef.current)
|
onCommitRef.current(hit, ghostRotationRef.current)
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (committed) return
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
const hit = roofDrag ? lastRelativeHit : null
|
||||||
|
if (!hit) return
|
||||||
|
committed = true
|
||||||
|
onCommitRef.current(hit, ghostRotationRef.current)
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
}
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'r' && e.key !== 'R') return
|
if (e.key !== 'r' && e.key !== 'R') return
|
||||||
const target = e.target as HTMLElement | null
|
const target = e.target as HTMLElement | null
|
||||||
@@ -166,17 +185,20 @@ export function useDormerPlacement(opts: {
|
|||||||
emitter.on('roof:enter', updatePreview)
|
emitter.on('roof:enter', updatePreview)
|
||||||
emitter.on('roof:click', onClick)
|
emitter.on('roof:click', onClick)
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
}
|
}
|
||||||
}, [activeBuildingId])
|
}, [activeBuildingId])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeBuildingId: activeBuildingId ?? undefined,
|
activeBuildingId: activeBuildingId ?? undefined,
|
||||||
|
clearPreview,
|
||||||
segmentXform,
|
segmentXform,
|
||||||
hitLocal,
|
hitLocal,
|
||||||
ghostRotation,
|
ghostRotation,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
import { computeEaveY } from '../gutter/eave-snap'
|
import { computeEaveY } from '../gutter/eave-snap'
|
||||||
import { resolveGutterOutletById } from '../gutter/outlet-lookup'
|
import { resolveGutterOutletById } from '../gutter/outlet-lookup'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { downspoutDefinition } from './definition'
|
import { downspoutDefinition } from './definition'
|
||||||
import DownspoutPreview from './preview'
|
import DownspoutPreview from './preview'
|
||||||
import { computeDownspoutRouting, type DownspoutRouting } from './routing'
|
import { computeDownspoutRouting, type DownspoutRouting } from './routing'
|
||||||
@@ -162,22 +163,30 @@ const DownspoutTool = () => {
|
|||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
if (!activeBuildingId || !target) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={target.segment.position} rotation-y={target.segment.rotation}>
|
<>
|
||||||
<group
|
<RoofAttachmentFallbackPreview
|
||||||
position={[target.gutter.position[0], target.segment.eaveY, target.gutter.position[2]]}
|
activeBuildingId={activeBuildingId}
|
||||||
rotation-y={target.gutter.rotation}
|
onInvalidTarget={() => setTarget(null)}
|
||||||
>
|
size={[0.2, 2.5, 0.2]}
|
||||||
<group position={[target.outlet.x, target.outlet.y, target.outlet.z]}>
|
validTarget="gutter"
|
||||||
<DownspoutPreview
|
/>
|
||||||
node={previewNodeWithDefaults(previewNode, target)}
|
{activeBuildingId && target && (
|
||||||
routing={target.routing}
|
<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>
|
)}
|
||||||
</group>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import {
|
||||||
|
consumePlacementDragRelease,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import {
|
import {
|
||||||
@@ -58,11 +63,22 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
let lastTarget: RelativeRoofDragTarget | null = null
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
let committed = false
|
||||||
const roofDrag = createRelativeRoofDrag(original)
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
|
const clearTarget = () => {
|
||||||
|
lastTarget = null
|
||||||
|
lastSnap = null
|
||||||
|
setPreviewPos(null)
|
||||||
|
setPreviewSurfaceQuat(null)
|
||||||
|
}
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
clearTarget()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(target.localX * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
@@ -89,8 +105,10 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const target = lastTarget ?? roofDrag.resolve(event)
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
committed = true
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
@@ -169,16 +187,29 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (!lastTarget) return
|
||||||
|
onRoofClick({
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as RoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('roof:move', updatePreview)
|
emitter.on('roof:move', updatePreview)
|
||||||
emitter.on('roof:enter', updatePreview)
|
emitter.on('roof:enter', updatePreview)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', clearTarget)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', clearTarget)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import { eyebrowVentDefinition } from './definition'
|
import { eyebrowVentDefinition } from './definition'
|
||||||
@@ -117,16 +118,26 @@ const EyebrowVentTool = () => {
|
|||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={previewPos}>
|
<>
|
||||||
<group rotation-y={previewYaw}>
|
<RoofAttachmentFallbackPreview
|
||||||
<group quaternion={previewSurfaceQuat}>
|
activeBuildingId={activeBuildingId}
|
||||||
<EyebrowVentPreview node={previewNode} />
|
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>
|
)}
|
||||||
</group>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
|
consumePlacementDragRelease,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
snapFenceDraftPoint,
|
snapFenceDraftPoint,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
@@ -227,6 +228,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (wasCommitted) return
|
||||||
if (Date.now() - activatedAtRef.current < 150) {
|
if (Date.now() - activatedAtRef.current < 150) {
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
return
|
return
|
||||||
@@ -264,6 +266,12 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
activatedAtRef.current = 0
|
||||||
|
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||||
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
restoreOriginal()
|
restoreOriginal()
|
||||||
useViewer.getState().setSelection({ selectedIds: [fenceId] })
|
useViewer.getState().setSelection({ selectedIds: [fenceId] })
|
||||||
@@ -279,6 +287,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (!wasCommitted) {
|
if (!wasCommitted) {
|
||||||
@@ -303,6 +312,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
}
|
}
|
||||||
}, [exitMoveMode, node])
|
}, [exitMoveMode, node])
|
||||||
|
|
||||||
|
|||||||
@@ -466,7 +466,8 @@ export const FenceTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Align the drafted point onto another object's nearest real anchor and
|
// Align the drafted point onto another object's nearest real anchor and
|
||||||
// publish the guide. Alt bypasses. Returns the (possibly snapped) point.
|
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
|
||||||
|
// snapping. Returns the possibly snapped point.
|
||||||
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
||||||
if (bypass || alignmentCandidates.length === 0) {
|
if (bypass || alignmentCandidates.length === 0) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import {
|
||||||
|
consumePlacementDragRelease,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
|
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
|
||||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||||
@@ -71,8 +76,15 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
let lastTarget: GutterDragTarget | null = null
|
let lastTarget: GutterDragTarget | null = null
|
||||||
|
let committed = false
|
||||||
const roofDrag = createRelativeRoofDrag(original)
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
|
const clearTarget = () => {
|
||||||
|
lastTarget = null
|
||||||
|
lastSnap = null
|
||||||
|
setTarget(null)
|
||||||
|
}
|
||||||
|
|
||||||
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
|
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = roofDrag.resolve(event)
|
||||||
if (!target) return null
|
if (!target) return null
|
||||||
@@ -85,7 +97,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const roof = event.node as RoofNode
|
const roof = event.node as RoofNode
|
||||||
const target = resolveTarget(event)
|
const target = resolveTarget(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
clearTarget()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
|
||||||
// Same snap math as the placement tool — picking-up and putting-
|
// Same snap math as the placement tool — picking-up and putting-
|
||||||
@@ -120,8 +135,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const target = lastTarget ?? resolveTarget(event)
|
const target = lastTarget ?? resolveTarget(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
committed = true
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
const { snap } = target
|
const { snap } = target
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
@@ -201,16 +218,29 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (!lastTarget) return
|
||||||
|
onRoofClick({
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as RoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('roof:move', updatePreview)
|
emitter.on('roof:move', updatePreview)
|
||||||
emitter.on('roof:enter', updatePreview)
|
emitter.on('roof:enter', updatePreview)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', clearTarget)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', clearTarget)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { triggerSFX } from '@pascal-app/editor'
|
import { triggerSFX } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { gutterDefinition } from './definition'
|
import { gutterDefinition } from './definition'
|
||||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||||
@@ -142,19 +143,26 @@ const GutterTool = () => {
|
|||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
if (!activeBuildingId || !target) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
<>
|
||||||
<group position={target.segment.position} rotation-y={target.segment.rotation}>
|
<RoofAttachmentFallbackPreview
|
||||||
<group
|
activeBuildingId={activeBuildingId}
|
||||||
position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]}
|
onInvalidTarget={() => setTarget(null)}
|
||||||
rotation-y={target.snap.rotation}
|
size={[2, 0.2, 0.25]}
|
||||||
>
|
/>
|
||||||
<GutterPreview node={previewNode} />
|
{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>
|
)}
|
||||||
</group>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import {
|
||||||
|
consumePlacementDragRelease,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
createRelativeRoofDrag,
|
createRelativeRoofDrag,
|
||||||
@@ -60,8 +65,15 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
|||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
let lastTarget: RidgeVentDragTarget | null = null
|
let lastTarget: RidgeVentDragTarget | null = null
|
||||||
|
let committed = false
|
||||||
const roofDrag = createRelativeRoofDrag(original)
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
|
const clearTarget = () => {
|
||||||
|
lastTarget = null
|
||||||
|
lastSnap = null
|
||||||
|
setPreviewPos(null)
|
||||||
|
}
|
||||||
|
|
||||||
const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => {
|
const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = roofDrag.resolve(event)
|
||||||
if (!target) return null
|
if (!target) return null
|
||||||
@@ -75,7 +87,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
|||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const target = resolveTarget(event)
|
const target = resolveTarget(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
clearTarget()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(target.localX * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
@@ -100,8 +115,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const target = lastTarget ?? resolveTarget(event)
|
const target = lastTarget ?? resolveTarget(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
committed = true
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
@@ -180,16 +197,29 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
|||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (!lastTarget) return
|
||||||
|
onRoofClick({
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as RoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('roof:move', updatePreview)
|
emitter.on('roof:move', updatePreview)
|
||||||
emitter.on('roof:enter', updatePreview)
|
emitter.on('roof:enter', updatePreview)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', clearTarget)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', clearTarget)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { resolveRidgeSnap } from '../shared/ridge-snap'
|
import { resolveRidgeSnap } from '../shared/ridge-snap'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { ridgeVentDefinition } from './definition'
|
import { ridgeVentDefinition } from './definition'
|
||||||
import RidgeVentPreview from './preview'
|
import RidgeVentPreview from './preview'
|
||||||
@@ -135,14 +136,30 @@ const RidgeVentTool = () => {
|
|||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
if (!activeBuildingId || !previewPos) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={previewPos}>
|
<>
|
||||||
<group rotation-y={previewYaw}>
|
<RoofAttachmentFallbackPreview
|
||||||
<RidgeVentPreview node={previewNode} />
|
activeBuildingId={activeBuildingId}
|
||||||
</group>
|
isValidRoofTarget={(event) => {
|
||||||
</group>
|
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 {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
commitFreshPlacementSubtree,
|
commitFreshPlacementSubtree,
|
||||||
|
consumePlacementDragRelease,
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
getFloorStackPreviewPosition,
|
getFloorStackPreviewPosition,
|
||||||
resolvePlanarCursorPosition,
|
resolvePlanarCursorPosition,
|
||||||
@@ -359,6 +360,7 @@ export const MoveRoofTool: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (wasCommitted) return
|
||||||
if (!hasMoved) return
|
if (!hasMoved) return
|
||||||
const [localX, , localZ] = lastLocalPosition
|
const [localX, , localZ] = lastLocalPosition
|
||||||
|
|
||||||
@@ -394,6 +396,11 @@ export const MoveRoofTool: React.FC<{
|
|||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||||
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
useLiveTransforms.getState().clear(movingNode.id)
|
useLiveTransforms.getState().clear(movingNode.id)
|
||||||
@@ -454,6 +461,7 @@ export const MoveRoofTool: React.FC<{
|
|||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Restore segment wrapper visibility (React will re-sync on next render)
|
// Restore segment wrapper visibility (React will re-sync on next render)
|
||||||
@@ -485,6 +493,7 @@ export const MoveRoofTool: React.FC<{
|
|||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
}
|
}
|
||||||
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
|
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
type GutterEvent,
|
||||||
|
type RoofEvent,
|
||||||
|
sceneRegistry,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { DragBoundingBox } from '@pascal-app/editor'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
|
|
||||||
|
const INVALID_PREVIEW_COLOR = 0xef_44_44
|
||||||
|
type ValidTarget = 'roof' | 'gutter'
|
||||||
|
|
||||||
|
export function RoofAttachmentFallbackPreview({
|
||||||
|
activeBuildingId,
|
||||||
|
isValidRoofTarget,
|
||||||
|
lift = 0,
|
||||||
|
onInvalidTarget,
|
||||||
|
size,
|
||||||
|
validTarget = 'roof',
|
||||||
|
}: {
|
||||||
|
activeBuildingId: string | null | undefined
|
||||||
|
isValidRoofTarget?: (event: RoofEvent) => boolean
|
||||||
|
lift?: number
|
||||||
|
onInvalidTarget?: () => void
|
||||||
|
size: [number, number, number]
|
||||||
|
validTarget?: ValidTarget
|
||||||
|
}) {
|
||||||
|
const [position, setPosition] = useState<[number, number, number] | null>(null)
|
||||||
|
const lastValidTargetEventRef = useRef<unknown>(null)
|
||||||
|
const localPointRef = useRef(new Vector3())
|
||||||
|
const isValidRoofTargetRef = useRef(isValidRoofTarget)
|
||||||
|
const onInvalidTargetRef = useRef(onInvalidTarget)
|
||||||
|
isValidRoofTargetRef.current = isValidRoofTarget
|
||||||
|
onInvalidTargetRef.current = onInvalidTarget
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeBuildingId) {
|
||||||
|
setPosition(null)
|
||||||
|
lastValidTargetEventRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackValidHit = (nativeEvent: unknown) => {
|
||||||
|
lastValidTargetEventRef.current = nativeEvent
|
||||||
|
setPosition(null)
|
||||||
|
}
|
||||||
|
const showInvalidAt = (x: number, y: number, z: number) => {
|
||||||
|
setPosition([x, y + lift, z])
|
||||||
|
onInvalidTargetRef.current?.()
|
||||||
|
}
|
||||||
|
const showInvalidAtWorld = (event: RoofEvent) => {
|
||||||
|
const point = localPointRef.current.set(...event.position)
|
||||||
|
const building = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||||
|
if (building) {
|
||||||
|
building.updateWorldMatrix(true, false)
|
||||||
|
building.worldToLocal(point)
|
||||||
|
}
|
||||||
|
showInvalidAt(point.x, 0, point.z)
|
||||||
|
}
|
||||||
|
const onRoofHit = (event: RoofEvent) => {
|
||||||
|
if (isValidRoofTargetRef.current?.(event) === false) {
|
||||||
|
showInvalidAtWorld(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackValidHit(event.nativeEvent)
|
||||||
|
}
|
||||||
|
const onGutterHit = (event: GutterEvent) => trackValidHit(event.nativeEvent)
|
||||||
|
|
||||||
|
const onGridMove = (event: GridEvent) => {
|
||||||
|
if (event.nativeEvent === lastValidTargetEventRef.current) return
|
||||||
|
const [x, y, z] = event.localPosition
|
||||||
|
showInvalidAt(x, y, z)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validTarget === 'roof') {
|
||||||
|
emitter.on('roof:enter', onRoofHit)
|
||||||
|
emitter.on('roof:move', onRoofHit)
|
||||||
|
} else {
|
||||||
|
emitter.on('gutter:enter', onGutterHit)
|
||||||
|
emitter.on('gutter:move', onGutterHit)
|
||||||
|
}
|
||||||
|
emitter.on('grid:move', onGridMove)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (validTarget === 'roof') {
|
||||||
|
emitter.off('roof:enter', onRoofHit)
|
||||||
|
emitter.off('roof:move', onRoofHit)
|
||||||
|
} else {
|
||||||
|
emitter.off('gutter:enter', onGutterHit)
|
||||||
|
emitter.off('gutter:move', onGutterHit)
|
||||||
|
}
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
|
}
|
||||||
|
}, [activeBuildingId, lift, validTarget])
|
||||||
|
|
||||||
|
if (!(activeBuildingId && position)) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragBoundingBox
|
||||||
|
color={INVALID_PREVIEW_COLOR}
|
||||||
|
nodeId="roof-attachment-fallback"
|
||||||
|
position={position}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -88,7 +88,10 @@ const cursorPoint = new Vector3()
|
|||||||
export function worldToSelectedBuildingLocal(point: Vector3): [number, number, number] {
|
export function worldToSelectedBuildingLocal(point: Vector3): [number, number, number] {
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
const buildingId = useViewer.getState().selection.buildingId
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
|
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
|
||||||
if (buildingObj) buildingObj.worldToLocal(point)
|
if (buildingObj) {
|
||||||
|
buildingObj.updateWorldMatrix(true, false)
|
||||||
|
buildingObj.worldToLocal(point)
|
||||||
|
}
|
||||||
return [point.x, point.y, point.z]
|
return [point.x, point.y, point.z]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
|||||||
tool: () => import('./tool'),
|
tool: () => import('./tool'),
|
||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Place shelf' },
|
{ key: 'Left click', label: 'Place shelf' },
|
||||||
|
{ key: 'Shift', label: 'Free place' },
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import {
|
||||||
|
consumePlacementDragRelease,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import {
|
import {
|
||||||
@@ -65,8 +70,14 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
|||||||
let lastSnapX = 0
|
let lastSnapX = 0
|
||||||
let lastSnapZ = 0
|
let lastSnapZ = 0
|
||||||
let lastTarget: RelativeRoofDragTarget | null = null
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
let committed = false
|
||||||
const roofDrag = createRelativeRoofDrag(original)
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
|
const clearTarget = () => {
|
||||||
|
lastTarget = null
|
||||||
|
setHasHit(false)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve which segment the cursor is over, then derive the same
|
// Resolve which segment the cursor is over, then derive the same
|
||||||
// preview transform stack the placement tool uses (`skylight/tool.tsx`):
|
// preview transform stack the placement tool uses (`skylight/tool.tsx`):
|
||||||
// analytical surface normal in segment-local frame → outer yaw =
|
// analytical surface normal in segment-local frame → outer yaw =
|
||||||
@@ -77,7 +88,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
|||||||
const roof = event.node as RoofNode
|
const roof = event.node as RoofNode
|
||||||
const target = roofDrag.resolve(event)
|
const target = roofDrag.resolve(event)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
setHasHit(false)
|
clearTarget()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
@@ -113,10 +124,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
const target = lastTarget ?? roofDrag.resolve(event)
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
committed = true
|
||||||
|
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
const finalRotation = original.rotation
|
const finalRotation = original.rotation
|
||||||
@@ -206,16 +219,29 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
|||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (!lastTarget) return
|
||||||
|
onRoofClick({
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as RoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('roof:move', onRoofMove)
|
emitter.on('roof:move', onRoofMove)
|
||||||
emitter.on('roof:enter', onRoofEnter)
|
emitter.on('roof:enter', onRoofEnter)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', clearTarget)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('roof:move', onRoofMove)
|
emitter.off('roof:move', onRoofMove)
|
||||||
emitter.off('roof:enter', onRoofEnter)
|
emitter.off('roof:enter', onRoofEnter)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', clearTarget)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import { skylightDefinition } from './definition'
|
import { skylightDefinition } from './definition'
|
||||||
@@ -109,16 +110,26 @@ const SkylightTool = () => {
|
|||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={previewPos}>
|
<>
|
||||||
<group rotation-y={previewYaw}>
|
<RoofAttachmentFallbackPreview
|
||||||
<group quaternion={previewSurfaceQuat}>
|
activeBuildingId={activeBuildingId}
|
||||||
<SkylightPreview node={previewNode} />
|
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>
|
)}
|
||||||
</group>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
|||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Trace slab outline' },
|
{ key: 'Left click', label: 'Trace slab outline' },
|
||||||
{ key: 'Enter', label: 'Finish slab' },
|
{ key: 'Enter', label: 'Finish slab' },
|
||||||
|
{ key: 'Shift', label: 'Free outline' },
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
|
consumePlacementDragRelease,
|
||||||
getSegmentGridStep,
|
getSegmentGridStep,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
resolveAlignmentForActiveBuilding,
|
resolveAlignmentForActiveBuilding,
|
||||||
@@ -214,6 +215,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (wasCommitted) return
|
||||||
if (isFloorplanSourcedEvent(event)) return
|
if (isFloorplanSourcedEvent(event)) return
|
||||||
if (Date.now() - activatedAtRef.current < 150) {
|
if (Date.now() - activatedAtRef.current < 150) {
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
@@ -245,6 +247,12 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
activatedAtRef.current = 0
|
||||||
|
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||||
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
// No scene state to roll back — we never wrote anything. Just
|
// No scene state to roll back — we never wrote anything. Just
|
||||||
// restore the mesh visual.
|
// restore the mesh visual.
|
||||||
@@ -258,6 +266,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -269,6 +278,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
}
|
}
|
||||||
}, [exitMoveMode, node.id, node.parentId])
|
}, [exitMoveMode, node.id, node.parentId])
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import {
|
||||||
|
consumePlacementDragRelease,
|
||||||
|
EDITOR_LAYER,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import {
|
import {
|
||||||
@@ -91,11 +97,20 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
|||||||
let lastSnapX = 0
|
let lastSnapX = 0
|
||||||
let lastSnapZ = 0
|
let lastSnapZ = 0
|
||||||
let lastTarget: RelativeRoofDragTarget | null = null
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
let committed = false
|
||||||
const roofDrag = createRelativeRoofDrag(original)
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
|
const clearTarget = () => {
|
||||||
|
lastTarget = null
|
||||||
|
setHasHit(false)
|
||||||
|
}
|
||||||
|
|
||||||
const updateGhost = (event: RoofEvent) => {
|
const updateGhost = (event: RoofEvent) => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
clearTarget()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(target.localX * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
@@ -127,10 +142,12 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
const target = lastTarget ?? roofDrag.resolve(event)
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
committed = true
|
||||||
|
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
|
|
||||||
@@ -227,16 +244,29 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
|||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (!lastTarget) return
|
||||||
|
onRoofClick({
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as RoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('roof:move', updateGhost)
|
emitter.on('roof:move', updateGhost)
|
||||||
emitter.on('roof:enter', updateGhost)
|
emitter.on('roof:enter', updateGhost)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', clearTarget)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('roof:move', updateGhost)
|
emitter.off('roof:move', updateGhost)
|
||||||
emitter.off('roof:enter', updateGhost)
|
emitter.off('roof:enter', updateGhost)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', clearTarget)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import { solarPanelDefinition } from './definition'
|
import { solarPanelDefinition } from './definition'
|
||||||
@@ -130,16 +131,26 @@ const SolarPanelTool = () => {
|
|||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={previewPos}>
|
<>
|
||||||
<group rotation-y={previewYaw}>
|
<RoofAttachmentFallbackPreview
|
||||||
<group quaternion={previewSurfaceQuat}>
|
activeBuildingId={activeBuildingId}
|
||||||
<SolarPanelPreview node={previewNode} />
|
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>
|
)}
|
||||||
</group>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
|||||||
tool: () => import('./tool'),
|
tool: () => import('./tool'),
|
||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Place spawn point' },
|
{ key: 'Left click', label: 'Place spawn point' },
|
||||||
|
{ key: 'Shift', label: 'Free place' },
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
type TurbineVentNode,
|
type TurbineVentNode,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import {
|
||||||
|
consumePlacementDragRelease,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import {
|
import {
|
||||||
@@ -58,11 +63,22 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
|||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
let lastTarget: RelativeRoofDragTarget | null = null
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
let committed = false
|
||||||
const roofDrag = createRelativeRoofDrag(original)
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
|
const clearTarget = () => {
|
||||||
|
lastTarget = null
|
||||||
|
lastSnap = null
|
||||||
|
setPreviewPos(null)
|
||||||
|
setPreviewSurfaceQuat(null)
|
||||||
|
}
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
clearTarget()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(target.localX * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
@@ -89,8 +105,10 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const target = lastTarget ?? roofDrag.resolve(event)
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
committed = true
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
@@ -169,16 +187,29 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
|||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (!lastTarget) return
|
||||||
|
onRoofClick({
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as RoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('roof:move', updatePreview)
|
emitter.on('roof:move', updatePreview)
|
||||||
emitter.on('roof:enter', updatePreview)
|
emitter.on('roof:enter', updatePreview)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', clearTarget)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', clearTarget)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import { turbineVentDefinition } from './definition'
|
import { turbineVentDefinition } from './definition'
|
||||||
@@ -117,16 +118,26 @@ const TurbineVentTool = () => {
|
|||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={previewPos}>
|
<>
|
||||||
<group rotation-y={previewYaw}>
|
<RoofAttachmentFallbackPreview
|
||||||
<group quaternion={previewSurfaceQuat}>
|
activeBuildingId={activeBuildingId}
|
||||||
<TurbineVentPreview node={previewNode} />
|
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>
|
)}
|
||||||
</group>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -508,9 +508,13 @@ export const WallTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Align the drafted point onto another object's nearest real anchor and
|
// Align the drafted point onto another object's nearest real anchor and
|
||||||
// publish the guide. Alt bypasses. Returns the (possibly snapped) point.
|
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
|
||||||
const alignPoint = (point: WallPlanPoint, bypass: boolean): WallPlanPoint => {
|
// snapping. Returns the possibly snapped point.
|
||||||
if (bypass || alignmentCandidates.length === 0) {
|
const alignPoint = (
|
||||||
|
point: WallPlanPoint,
|
||||||
|
options: { applySnap?: boolean; bypass?: boolean },
|
||||||
|
): WallPlanPoint => {
|
||||||
|
if (options.bypass || alignmentCandidates.length === 0) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
return point
|
return point
|
||||||
}
|
}
|
||||||
@@ -520,7 +524,9 @@ export const WallTool: React.FC = () => {
|
|||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
useAlignmentGuides.getState().set(ar.guides)
|
useAlignmentGuides.getState().set(ar.guides)
|
||||||
return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point
|
return ar.snap && options.applySnap !== false
|
||||||
|
? [point[0] + ar.snap.dx, point[1] + ar.snap.dz]
|
||||||
|
: point
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopDrafting = () => {
|
const stopDrafting = () => {
|
||||||
@@ -554,7 +560,10 @@ export const WallTool: React.FC = () => {
|
|||||||
bypassSnap,
|
bypassSnap,
|
||||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||||
})
|
})
|
||||||
gridPosition = alignPoint(snapResult.point, bypassAlign || angleLocked)
|
gridPosition = alignPoint(snapResult.point, {
|
||||||
|
applySnap: !angleLocked,
|
||||||
|
bypass: bypassAlign,
|
||||||
|
})
|
||||||
// Stand the magnetic beacon at the endpoint when it locked onto an
|
// Stand the magnetic beacon at the endpoint when it locked onto an
|
||||||
// existing wall corner / wall point; clear it for plain grid/angle moves.
|
// existing wall corner / wall point; clear it for plain grid/angle moves.
|
||||||
useWallSnapIndicator
|
useWallSnapIndicator
|
||||||
@@ -635,7 +644,7 @@ export const WallTool: React.FC = () => {
|
|||||||
bypassSnap,
|
bypassSnap,
|
||||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||||
}).point,
|
}).point,
|
||||||
bypassAlign,
|
{ bypass: bypassAlign },
|
||||||
)
|
)
|
||||||
gridPosition = snappedStart
|
gridPosition = snappedStart
|
||||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||||
@@ -666,7 +675,10 @@ export const WallTool: React.FC = () => {
|
|||||||
bypassSnap,
|
bypassSnap,
|
||||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||||
}).point,
|
}).point,
|
||||||
bypassAlign || angleLocked,
|
{
|
||||||
|
applySnap: !angleLocked,
|
||||||
|
bypass: bypassAlign,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
const dx = snappedEnd[0] - startingPoint.current.x
|
const dx = snappedEnd[0] - startingPoint.current.x
|
||||||
const dz = snappedEnd[1] - startingPoint.current.z
|
const dz = snappedEnd[1] - startingPoint.current.z
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
|||||||
|
|
||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Place window on wall' },
|
{ key: 'Left click', label: 'Place window on wall' },
|
||||||
|
{ key: 'Shift', label: 'Free place' },
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
calculateCursorRotation,
|
calculateCursorRotation,
|
||||||
calculateItemRotation,
|
calculateItemRotation,
|
||||||
|
consumePlacementDragRelease,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
getSideFromNormal,
|
getSideFromNormal,
|
||||||
isValidWallSideFace,
|
isValidWallSideFace,
|
||||||
@@ -99,6 +100,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
let currentHostId: string | null = movingWindowNode.parentId
|
let currentHostId: string | null = movingWindowNode.parentId
|
||||||
|
let committed = false
|
||||||
let dragAnchor: {
|
let dragAnchor: {
|
||||||
wallId: string
|
wallId: string
|
||||||
rawX: number
|
rawX: number
|
||||||
@@ -117,6 +119,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
valid: boolean
|
valid: boolean
|
||||||
event: WallEvent
|
event: WallEvent
|
||||||
} | null = null
|
} | null = null
|
||||||
|
let lastRoofEvent: RoofEvent | null = null
|
||||||
|
|
||||||
const markHostDirty = (hostId: string | null) => {
|
const markHostDirty = (hostId: string | null) => {
|
||||||
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||||
@@ -285,29 +288,44 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
const target = resolveMoveTarget(event)
|
const target = resolveMoveTarget(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
onWallLeave()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
lastRoofEvent = null
|
||||||
applyPreview(target)
|
applyPreview(target)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) {
|
||||||
|
onWallLeave()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isCurvedWall(event.node)) {
|
if (isCurvedWall(event.node)) {
|
||||||
hideCursor()
|
onWallLeave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only interact with walls on the current level
|
// Only interact with walls on the current level
|
||||||
if (event.node.parentId !== getLevelId()) return
|
if (event.node.parentId !== getLevelId()) {
|
||||||
|
onWallLeave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const target = resolveMoveTarget(event)
|
const target = resolveMoveTarget(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
onWallLeave()
|
||||||
|
return
|
||||||
|
}
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
|
lastRoofEvent = null
|
||||||
applyPreview(target)
|
applyPreview(target)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallClick = (event: WallEvent) => {
|
const onWallClick = (event: WallEvent) => {
|
||||||
|
if (committed) return
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
if (isCurvedWall(event.node)) return
|
if (isCurvedWall(event.node)) return
|
||||||
// Only interact with walls on the current level
|
// Only interact with walls on the current level
|
||||||
@@ -315,6 +333,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
|
|
||||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||||
if (!target?.valid) return
|
if (!target?.valid) return
|
||||||
|
committed = true
|
||||||
|
|
||||||
let placedId: string
|
let placedId: string
|
||||||
|
|
||||||
@@ -387,6 +406,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
|
lastRoofEvent = null
|
||||||
if (isNew) return // No original to restore for duplicates
|
if (isNew) return // No original to restore for duplicates
|
||||||
// Move mode: restore to original position while off-wall
|
// Move mode: restore to original position while off-wall
|
||||||
if (currentHostId && currentHostId !== original.parentId) {
|
if (currentHostId && currentHostId !== original.parentId) {
|
||||||
@@ -430,10 +450,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
|
|
||||||
const onRoofHover = (event: RoofEvent) => {
|
const onRoofHover = (event: RoofEvent) => {
|
||||||
const target = resolveRoofMoveTarget(event)
|
const target = resolveRoofMoveTarget(event)
|
||||||
if (!target) return
|
if (!target) {
|
||||||
|
onRoofLeave()
|
||||||
|
return
|
||||||
|
}
|
||||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
|
lastRoofEvent = event
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
if (currentHostId !== target.segment.id) {
|
if (currentHostId !== target.segment.id) {
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
@@ -459,8 +483,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
if (committed) return
|
||||||
const target = resolveRoofMoveTarget(event)
|
const target = resolveRoofMoveTarget(event)
|
||||||
if (!target?.valid) return
|
if (!target?.valid) return
|
||||||
|
committed = true
|
||||||
const segmentId = target.segment.id
|
const segmentId = target.segment.id
|
||||||
|
|
||||||
let placedId: string
|
let placedId: string
|
||||||
@@ -531,6 +557,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
|
lastRoofEvent = null
|
||||||
if (isNew) return
|
if (isNew) return
|
||||||
if (currentHostId && currentHostId !== original.parentId) {
|
if (currentHostId && currentHostId !== original.parentId) {
|
||||||
markHostDirty(currentHostId)
|
markHostDirty(currentHostId)
|
||||||
@@ -571,6 +598,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (lastTarget) {
|
||||||
|
onWallClick(lastTarget.event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('wall:enter', onWallEnter)
|
emitter.on('wall:enter', onWallEnter)
|
||||||
emitter.on('wall:move', onWallMove)
|
emitter.on('wall:move', onWallMove)
|
||||||
emitter.on('wall:click', onWallClick)
|
emitter.on('wall:click', onWallClick)
|
||||||
@@ -580,6 +616,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
emitter.on('roof:leave', onRoofLeave)
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||||
@@ -617,6 +654,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
emitter.off('roof:leave', onRoofLeave)
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
}
|
}
|
||||||
}, [movingWindowNode, exitMoveMode])
|
}, [movingWindowNode, exitMoveMode])
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
emitter,
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
@@ -23,12 +24,13 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
import {
|
import {
|
||||||
getRoofWallOpeningCursorPose,
|
getRoofWallOpeningCursorPose,
|
||||||
type RoofWallOpeningTarget,
|
type RoofWallOpeningTarget,
|
||||||
resolveRoofWallOpeningTarget,
|
resolveRoofWallOpeningTarget,
|
||||||
|
worldToSelectedBuildingLocal,
|
||||||
} from '../shared/roof-wall-opening-placement'
|
} from '../shared/roof-wall-opening-placement'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||||
@@ -41,6 +43,11 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const FALLBACK_WIDTH = 1.5
|
||||||
|
const FALLBACK_HEIGHT = 1.5
|
||||||
|
const FALLBACK_SILL_LIFT = 0.45
|
||||||
|
const roofFallbackPoint = new Vector3()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Window tool — places WindowNodes on walls and on roof-segment wall
|
* Window tool — places WindowNodes on walls and on roof-segment wall
|
||||||
* faces (the generated base walls under a roof, including coplanar gable
|
* faces (the generated base walls under a roof, including coplanar gable
|
||||||
@@ -103,17 +110,48 @@ const WindowTool: React.FC = () => {
|
|||||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showFallbackCursor = (event: GridEvent) => {
|
||||||
|
if (draftRef.current) return
|
||||||
|
const [x, y, z] = event.localPosition
|
||||||
|
updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||||
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||||
|
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showWallFallbackCursor = (event: WallEvent) => {
|
||||||
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||||
|
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) {
|
||||||
|
destroyDraft()
|
||||||
|
showWallFallbackCursor(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isCurvedWall(event.node)) {
|
if (isCurvedWall(event.node)) {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
showWallFallbackCursor(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const levelId = getLevelId()
|
const levelId = getLevelId()
|
||||||
if (!levelId) return
|
if (!levelId) {
|
||||||
|
destroyDraft()
|
||||||
|
showWallFallbackCursor(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Only interact with walls on the current level
|
// Only interact with walls on the current level
|
||||||
if (event.node.parentId !== levelId) return
|
if (event.node.parentId !== levelId) {
|
||||||
|
destroyDraft()
|
||||||
|
showWallFallbackCursor(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
|
|
||||||
@@ -167,14 +205,22 @@ const WindowTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) {
|
||||||
|
destroyDraft()
|
||||||
|
showWallFallbackCursor(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isCurvedWall(event.node)) {
|
if (isCurvedWall(event.node)) {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
showWallFallbackCursor(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only interact with walls on the current level
|
// Only interact with walls on the current level
|
||||||
if (event.node.parentId !== getLevelId()) return
|
if (event.node.parentId !== getLevelId()) {
|
||||||
|
destroyDraft()
|
||||||
|
showWallFallbackCursor(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const side = getSideFromNormal(event.normal)
|
const side = getSideFromNormal(event.normal)
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
@@ -395,10 +441,8 @@ const WindowTool: React.FC = () => {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
// On the roof but not over a placeable wall face (slope, soffit,
|
// On the roof but not over a placeable wall face (slope, soffit,
|
||||||
// or a face the window cannot fit on).
|
// or a face the window cannot fit on).
|
||||||
if (draftRef.current?.roofSegmentId) {
|
destroyDraft()
|
||||||
destroyDraft()
|
showRoofFallbackCursor(event)
|
||||||
hideCursor()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { segment, face, position } = target
|
const { segment, face, position } = target
|
||||||
@@ -499,6 +543,7 @@ const WindowTool: React.FC = () => {
|
|||||||
emitter.on('roof:move', onRoofHover)
|
emitter.on('roof:move', onRoofHover)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
emitter.on('roof:leave', onRoofLeave)
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
|
emitter.on('grid:move', showFallbackCursor)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -514,12 +559,13 @@ const WindowTool: React.FC = () => {
|
|||||||
emitter.off('roof:move', onRoofHover)
|
emitter.off('roof:move', onRoofHover)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
emitter.off('roof:leave', onRoofLeave)
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
|
emitter.off('grid:move', showFallbackCursor)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Cursor geometry: window outline rectangle (width × height × frameDepth)
|
// Cursor geometry: window outline rectangle.
|
||||||
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
|
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
||||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
const edgesGeo = new EdgesGeometry(boxGeo)
|
||||||
boxGeo.dispose()
|
boxGeo.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
|
|||||||
| [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) |
|
| [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) |
|
||||||
| [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) |
|
| [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) |
|
||||||
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
|
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
|
||||||
| [tools](tools.md) | Editor tools structure in `apps/editor` |
|
| [tools](tools.md) | Editor tools structure, manipulation constraints, and Shift bypass defaults |
|
||||||
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
|
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
|
||||||
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
|
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
|
||||||
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
|
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
|
||||||
|
|||||||
@@ -184,6 +184,28 @@ If the system also handles cascades, animations, or material updates, keep `def.
|
|||||||
- **Dispose on rebuild.** The generic system disposes the previous children's geometry + material before swapping. Custom systems that imperatively add children must dispose what they replace, or accept the GPU-memory cost.
|
- **Dispose on rebuild.** The generic system disposes the previous children's geometry + material before swapping. Custom systems that imperatively add children must dispose what they replace, or accept the GPU-memory cost.
|
||||||
- **`def.renderer` overrides the generic renderer.** Once you set it, you own the mount — `<ParametricNodeRenderer>` is not invoked. The generic geometry system still runs for the kind if `def.geometry` is set, so a custom renderer can register an empty group and let the system fill it.
|
- **`def.renderer` overrides the generic renderer.** Once you set it, you own the mount — `<ParametricNodeRenderer>` is not invoked. The generic geometry system still runs for the kind if `def.geometry` is set, so a custom renderer can register an empty group and let the system fill it.
|
||||||
|
|
||||||
|
## `toolHints`
|
||||||
|
|
||||||
|
`toolHints?: ToolHint[]` is the registry-owned source for the floating helper shown while
|
||||||
|
a registered placement or draw tool is active.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ToolHint = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep labels short and action-oriented. Prefer the default guided-building language:
|
||||||
|
snapping, angle increments, guides, and validation are active unless the user holds Shift
|
||||||
|
during the gesture. A `Shift` hint should describe the bypass in user terms, such as
|
||||||
|
`Free angle`, `Free place`, or `Bypass guided constraints`.
|
||||||
|
|
||||||
|
`HelperManager` renders `def.toolHints` through `RegisteredToolHelper`, and active Shift
|
||||||
|
state can update the row to show that guided constraints are currently bypassed. Select
|
||||||
|
mode is not owned by a node definition, so its helper is derived separately from
|
||||||
|
selection state, selected-node move/rotate capabilities, and held modifiers.
|
||||||
|
|
||||||
## Pitfalls
|
## Pitfalls
|
||||||
|
|
||||||
### `<GeometrySystem>` must not mutate `group.position` / `group.rotation`
|
### `<GeometrySystem>` must not mutate `group.position` / `group.rotation`
|
||||||
|
|||||||
@@ -73,6 +73,21 @@ phase: 'furnish' → selectable: furniture items only
|
|||||||
|
|
||||||
Clicking a node of a different phase auto-switches the phase. Double-click drills into a context level.
|
Clicking a node of a different phase auto-switches the phase. Double-click drills into a context level.
|
||||||
|
|
||||||
|
In Select mode, 3D and 2D canvas selection share the same modifier vocabulary:
|
||||||
|
|
||||||
|
- `Ctrl/Meta + click` toggles the clicked object in `selectedIds`.
|
||||||
|
- `Shift + click` also toggles the clicked canvas object so users can multi-select from
|
||||||
|
either viewport. The scene graph keeps file-browser semantics: `Shift + click` selects
|
||||||
|
the visible range between the last selected row and the clicked row.
|
||||||
|
- `Ctrl/Meta + left-drag` on a selected movable object starts direct move from the canvas.
|
||||||
|
- `Ctrl/Meta + right-drag` on a selected rotatable object starts direct rotation from the
|
||||||
|
canvas. Rotation snaps to the default angle increment unless Shift is held during the
|
||||||
|
drag.
|
||||||
|
|
||||||
|
The floating helper in `packages/editor/src/components/ui/helpers/helper-manager.tsx`
|
||||||
|
mirrors these rules from current selection state and held modifiers. Keep that helper and
|
||||||
|
the shortcut dialog in sync when changing selection gestures.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|||||||
@@ -66,6 +66,27 @@ export function MyTool() {
|
|||||||
- The offset must be cleared on tool unmount, cancel, *and* commit — both `mesh.position.set(0, 0, 0)` and `useLiveTransforms.clear(id)`.
|
- The offset must be cleared on tool unmount, cancel, *and* commit — both `mesh.position.set(0, 0, 0)` and `useLiveTransforms.clear(id)`.
|
||||||
- The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system.
|
- The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system.
|
||||||
- **No business logic in tools** — delegate geometry/constraint rules to core systems.
|
- **No business logic in tools** — delegate geometry/constraint rules to core systems.
|
||||||
|
- **Guided manipulation is the default.** Placement, move, rotate, resize, endpoint drag,
|
||||||
|
and handle drag should behave as guided building mode: they help the user build quickly
|
||||||
|
with fewer mistakes through grid/object snapping, canonical angle increments,
|
||||||
|
alignment guides, and distance feedback. Holding Shift is the standard live bypass for
|
||||||
|
those constraints: while Shift is held, tools should commit the raw pointer/angle
|
||||||
|
proposal instead of applying sticky snap or angle corrections. Passive measurement
|
||||||
|
guides may remain visible only when they do not alter the proposal. If an interaction
|
||||||
|
cannot use Shift because of an established shortcut or topology rule, document the
|
||||||
|
opt-out in its manipulation policy and explain the replacement behavior.
|
||||||
|
- **Constraints and guides can be decoupled.** When a stronger constraint owns the
|
||||||
|
proposal, such as a wall segment's 15° angle lock, the tool may still publish passive
|
||||||
|
dashed alignment/proximity guides as long as it does not apply the guide snap delta.
|
||||||
|
Use this for chained wall segments: users keep the fast constrained draft, but still see
|
||||||
|
proximity feedback for later points. Shift remains the hard bypass for both correction
|
||||||
|
and guide feedback.
|
||||||
|
- **Help must mirror manipulation policy.** The shortcut dialog and floating helper panel
|
||||||
|
are part of the interaction contract. Static shortcut docs should describe guided
|
||||||
|
building as the default and Shift as the live bypass. Floating help should be contextual
|
||||||
|
when enough state exists: Select mode can derive direct move, direct rotate,
|
||||||
|
multi-select, and Shift-bypass tips from the selected nodes and active modifiers; active
|
||||||
|
tools can highlight the Shift bypass row while the modifier is held.
|
||||||
- **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store.
|
- **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store.
|
||||||
- **Clean up on unmount** — remove any pending/incomplete nodes *and* any live transforms/mesh offsets when the tool unmounts.
|
- **Clean up on unmount** — remove any pending/incomplete nodes *and* any live transforms/mesh offsets when the tool unmounts.
|
||||||
- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. `sceneRegistry` is exported from `@pascal-app/core` and is the allowed door into the Three.js graph for the narrow purposes above.
|
- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. `sceneRegistry` is exported from `@pascal-app/core` and is the allowed door into the Three.js graph for the narrow purposes above.
|
||||||
|
|||||||
Reference in New Issue
Block a user