fix(editor): improve guided manipulation and snap affordances
This commit is contained in:
@@ -9,7 +9,12 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
@@ -59,11 +64,22 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
let committed = false
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
lastSnap = null
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
@@ -90,8 +106,10 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const st = useScene.getState()
|
||||
|
||||
@@ -176,16 +194,29 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!lastTarget) return
|
||||
onRoofClick({
|
||||
nativeEvent: event,
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
} as unknown as RoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('roof:move', updatePreview)
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', clearTarget)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', clearTarget)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
// Safety restore — if the tool is unmounted by something other than
|
||||
// a commit / cancel path (e.g. tool change, selection wipe), leave
|
||||
|
||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import { boxVentDefinition } from './definition'
|
||||
@@ -122,16 +123,26 @@ const BoxVentTool = () => {
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
||||
|
||||
return (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<BoxVentPreview node={previewNode} />
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[0.6, 0.4, 0.6]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<BoxVentPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
CursorSphere,
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -151,6 +157,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (wasCommitted) return
|
||||
const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter
|
||||
|
||||
wasCommitted = true
|
||||
@@ -169,6 +176,11 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
// Revert mesh position and rotation immediately
|
||||
const mesh = sceneRegistry.nodes.get(nodeId)
|
||||
@@ -190,6 +202,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
@@ -207,6 +220,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
}
|
||||
}, [exitMoveMode]) // stable — node values captured via refs at mount
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Trace ceiling outline' },
|
||||
{ key: 'Enter', label: 'Finish ceiling' },
|
||||
{ key: 'Shift', label: 'Free outline' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
@@ -189,6 +190,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (wasCommitted) return
|
||||
if (isFloorplanSourcedEvent(event)) return
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
@@ -214,6 +216,12 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
activatedAtRef.current = 0
|
||||
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
clearPreview()
|
||||
useAlignmentGuides.getState().clear()
|
||||
@@ -225,6 +233,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
@@ -236,6 +245,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
}
|
||||
}, [exitMoveMode, node.id])
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { consumePlacementDragRelease, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -89,9 +89,19 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
roofSegmentId: node.roofSegmentId,
|
||||
})
|
||||
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
setSegmentXform(null)
|
||||
setHitLocal(null)
|
||||
setPreviewSegment(null)
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
@@ -156,14 +166,27 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!lastTarget) return
|
||||
onClick({
|
||||
nativeEvent: event,
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
} as unknown as RoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('roof:move', updatePreview)
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onClick)
|
||||
emitter.on('roof:leave', clearTarget)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onClick)
|
||||
emitter.off('roof:leave', clearTarget)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
}
|
||||
}, [activeBuildingId, node, setMovingNode, setSelection])
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { chimneyDefinition } from './definition'
|
||||
import ChimneyPreview from './preview'
|
||||
@@ -139,19 +140,30 @@ const ChimneyTool = () => {
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !segmentXform || !hitLocal || !previewSegment) return null
|
||||
|
||||
// Outer group mirrors the real renderer's `position={segment.position}
|
||||
// rotation-y={segment.rotation}` chain by composing the segment's
|
||||
// building-local matrix (which walks roof + level + segment). Inner
|
||||
// group offsets by the cursor's segment-local x/z so the chimney
|
||||
// geometry (built with `position[0,2] = 0`) lands under the cursor.
|
||||
return (
|
||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||
<group position={[hitLocal[0], 0, hitLocal[2]]}>
|
||||
<ChimneyPreview node={previewNode} segment={previewSegment} />
|
||||
</group>
|
||||
</group>
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={() => {
|
||||
setSegmentXform(null)
|
||||
setHitLocal(null)
|
||||
setPreviewSegment(null)
|
||||
}}
|
||||
size={[1, 2.5, 1]}
|
||||
/>
|
||||
{activeBuildingId && segmentXform && hitLocal && previewSegment && (
|
||||
// Outer group mirrors the real renderer's `position={segment.position}
|
||||
// rotation-y={segment.rotation}` chain by composing the segment's
|
||||
// building-local matrix (which walks roof + level + segment). Inner
|
||||
// group offsets by the cursor's segment-local x/z so the chimney
|
||||
// geometry (built with `position[0,2] = 0`) lands under the cursor.
|
||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||
<group position={[hitLocal[0], 0, hitLocal[2]]}>
|
||||
<ChimneyPreview node={previewNode} segment={previewSegment} />
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -359,7 +359,7 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place column' },
|
||||
{ key: 'Alt', label: 'No snap' },
|
||||
{ key: 'Shift', label: 'Free place' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
floorplan: buildColumnFloorplan,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
CursorSphere,
|
||||
commitFreshPlacementSubtree,
|
||||
consumePlacementDragRelease,
|
||||
DragBoundingBox,
|
||||
getFloorStackPreviewPosition,
|
||||
markToolCancelConsumed,
|
||||
@@ -165,6 +166,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (committed) return
|
||||
if (!hasMoved) return
|
||||
useAlignmentGuides.getState().clear()
|
||||
// Commit at the last previewed position so the alignment snap (which
|
||||
@@ -225,6 +227,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
@@ -244,12 +251,14 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
@@ -57,11 +62,22 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
let committed = false
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
lastSnap = null
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
@@ -88,8 +104,10 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const st = useScene.getState()
|
||||
|
||||
@@ -168,16 +186,29 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!lastTarget) return
|
||||
onRoofClick({
|
||||
nativeEvent: event,
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
} as unknown as RoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('roof:move', updatePreview)
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', clearTarget)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', clearTarget)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
|
||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import { cupolaDefinition } from './definition'
|
||||
@@ -114,16 +115,26 @@ const CupolaTool = () => {
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
||||
|
||||
return (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<CupolaPreview node={previewNode} />
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[0.8, 1.2, 0.8]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<CupolaPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place door on wall' },
|
||||
{ key: 'Shift', label: 'Free place' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
consumePlacementDragRelease,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
@@ -80,6 +81,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
let currentHostId: string | null = movingDoorNode.parentId
|
||||
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
||||
let committed = false
|
||||
let lastTarget: {
|
||||
wallNode: WallEvent['node']
|
||||
wallId: string
|
||||
@@ -91,6 +93,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
valid: boolean
|
||||
event: WallEvent
|
||||
} | null = null
|
||||
let lastRoofEvent: RoofEvent | null = null
|
||||
|
||||
const markHostDirty = (hostId: string | null) => {
|
||||
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||
@@ -254,34 +257,50 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
applyPreview(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
hideCursor()
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
applyPreview(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (committed) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
committed = true
|
||||
|
||||
let placedId: string
|
||||
|
||||
@@ -349,6 +368,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
if (isNew) return
|
||||
if (currentHostId && currentHostId !== original.parentId) {
|
||||
markHostDirty(currentHostId)
|
||||
@@ -387,10 +407,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const onRoofHover = (event: RoofEvent) => {
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
onRoofLeave()
|
||||
return
|
||||
}
|
||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = event
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
if (currentHostId !== target.segment.id) {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
@@ -416,8 +440,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
committed = true
|
||||
const segmentId = target.segment.id
|
||||
|
||||
let placedId: string
|
||||
@@ -487,6 +513,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
if (isNew) return
|
||||
if (currentHostId && currentHostId !== original.parentId) {
|
||||
markHostDirty(currentHostId)
|
||||
@@ -527,6 +554,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (lastTarget) {
|
||||
onWallClick(lastTarget.event)
|
||||
return
|
||||
}
|
||||
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
@@ -536,6 +572,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', onRoofLeave)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
||||
@@ -572,6 +609,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', onRoofLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
}
|
||||
}, [movingDoorNode, exitMoveMode])
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
DoorNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
isCurvedWall,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
@@ -22,12 +23,13 @@ import {
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
getRoofWallOpeningCursorPose,
|
||||
type RoofWallOpeningTarget,
|
||||
resolveRoofWallOpeningTarget,
|
||||
worldToSelectedBuildingLocal,
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
@@ -39,6 +41,10 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const FALLBACK_WIDTH = 0.9
|
||||
const FALLBACK_HEIGHT = 2.1
|
||||
const roofFallbackPoint = new Vector3()
|
||||
|
||||
/**
|
||||
* Door tool — places DoorNodes on walls and on roof-segment wall faces
|
||||
* (the generated base walls under a roof, including coplanar gable ends).
|
||||
@@ -99,16 +105,47 @@ const DoorTool: React.FC = () => {
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const showFallbackCursor = (event: GridEvent) => {
|
||||
if (draftRef.current) return
|
||||
const [x, y, z] = event.localPosition
|
||||
updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const showWallFallbackCursor = (event: WallEvent) => {
|
||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
const levelId = getLevelId()
|
||||
if (!levelId) return
|
||||
if (event.node.parentId !== levelId) return
|
||||
if (!levelId) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== levelId) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
|
||||
destroyDraft()
|
||||
|
||||
@@ -158,13 +195,21 @@ const DoorTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
@@ -373,10 +418,8 @@ const DoorTool: React.FC = () => {
|
||||
if (!target) {
|
||||
// On the roof but not over a placeable wall face (slope, soffit,
|
||||
// or a face the door cannot fit on).
|
||||
if (draftRef.current?.roofSegmentId) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
}
|
||||
destroyDraft()
|
||||
showRoofFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
const { segment, face, position } = target
|
||||
@@ -483,6 +526,7 @@ const DoorTool: React.FC = () => {
|
||||
emitter.on('roof:move', onRoofHover)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', onRoofLeave)
|
||||
emitter.on('grid:move', showFallbackCursor)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
@@ -498,12 +542,13 @@ const DoorTool: React.FC = () => {
|
||||
emitter.off('roof:move', onRoofHover)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', onRoofLeave)
|
||||
emitter.off('grid:move', showFallbackCursor)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cursor geometry: door outline (default 0.9 × 2.1 × 0.07)
|
||||
const boxGeo = new BoxGeometry(0.9, 2.1, 0.07)
|
||||
// Cursor geometry: door outline.
|
||||
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
||||
boxGeo.dispose()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { dormerDefinition } from './definition'
|
||||
import DormerPreview from './preview'
|
||||
import { useDormerPlacement } from './use-dormer-placement'
|
||||
@@ -47,36 +48,44 @@ const DormerTool = () => {
|
||||
[],
|
||||
)
|
||||
|
||||
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
|
||||
onCommit: (hit, rotation) => {
|
||||
const state = useScene.getState()
|
||||
const dormer = DormerNode.parse({
|
||||
...dormerDefinition.defaults(),
|
||||
name: `Dormer ${nextDormerNumber(state.nodes)}`,
|
||||
roofSegmentId: hit.segment.id,
|
||||
parentId: hit.segment.id,
|
||||
// Anchor at the slope height so the renderer matches the ghost.
|
||||
// The CSG still carves cleanly because it inverts T(position)
|
||||
// when bringing the host into dormer-local.
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
rotation,
|
||||
})
|
||||
state.createNode(dormer, hit.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [dormer.id] })
|
||||
},
|
||||
})
|
||||
|
||||
if (!activeBuildingId || !segmentXform || !hitLocal) return null
|
||||
const { activeBuildingId, clearPreview, segmentXform, hitLocal, ghostRotation } =
|
||||
useDormerPlacement({
|
||||
onCommit: (hit, rotation) => {
|
||||
const state = useScene.getState()
|
||||
const dormer = DormerNode.parse({
|
||||
...dormerDefinition.defaults(),
|
||||
name: `Dormer ${nextDormerNumber(state.nodes)}`,
|
||||
roofSegmentId: hit.segment.id,
|
||||
parentId: hit.segment.id,
|
||||
// Anchor at the slope height so the renderer matches the ghost.
|
||||
// The CSG still carves cleanly because it inverts T(position)
|
||||
// when bringing the host into dormer-local.
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
rotation,
|
||||
})
|
||||
state.createNode(dormer, hit.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [dormer.id] })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||
<group position={hitLocal}>
|
||||
<group rotation-y={ghostRotation}>
|
||||
<DormerPreview node={previewNode} />
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={clearPreview}
|
||||
size={[1.8, 1.8, 1.4]}
|
||||
/>
|
||||
{activeBuildingId && segmentXform && hitLocal && (
|
||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||
<group position={hitLocal}>
|
||||
<group rotation-y={ghostRotation}>
|
||||
<DormerPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { consumePlacementDragRelease, triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -58,6 +58,7 @@ export function useDormerPlacement(opts: {
|
||||
onCommit: (hit: DormerPlacementHit, rotation: number) => void
|
||||
}): {
|
||||
activeBuildingId: string | undefined
|
||||
clearPreview: () => void
|
||||
segmentXform: DormerSegmentTransform | null
|
||||
hitLocal: [number, number, number] | null
|
||||
ghostRotation: number
|
||||
@@ -78,6 +79,11 @@ export function useDormerPlacement(opts: {
|
||||
const onCommitRef = useRef(opts.onCommit)
|
||||
onCommitRef.current = opts.onCommit
|
||||
|
||||
const clearPreview = () => {
|
||||
setSegmentXform(null)
|
||||
setHitLocal(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeBuildingId) return
|
||||
|
||||
@@ -99,6 +105,7 @@ export function useDormerPlacement(opts: {
|
||||
const roofDrag = relativeStartRef.current
|
||||
? createRelativeRoofDrag(relativeStartRef.current)
|
||||
: null
|
||||
let committed = false
|
||||
let lastRelativeHit: DormerPlacementHit | null = null
|
||||
|
||||
const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => {
|
||||
@@ -139,15 +146,27 @@ export function useDormerPlacement(opts: {
|
||||
}
|
||||
|
||||
const onClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const hit = roofDrag
|
||||
? (lastRelativeHit ?? resolvePlacementHit(event))
|
||||
: resolvePlacementHit(event)
|
||||
if (!hit) return
|
||||
committed = true
|
||||
onCommitRef.current(hit, ghostRotationRef.current)
|
||||
triggerSFX('sfx:item-place')
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (committed) return
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
const hit = roofDrag ? lastRelativeHit : null
|
||||
if (!hit) return
|
||||
committed = true
|
||||
onCommitRef.current(hit, ghostRotationRef.current)
|
||||
triggerSFX('sfx:item-place')
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'r' && e.key !== 'R') return
|
||||
const target = e.target as HTMLElement | null
|
||||
@@ -166,17 +185,20 @@ export function useDormerPlacement(opts: {
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onClick)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onClick)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
}
|
||||
}, [activeBuildingId])
|
||||
|
||||
return {
|
||||
activeBuildingId: activeBuildingId ?? undefined,
|
||||
clearPreview,
|
||||
segmentXform,
|
||||
hitLocal,
|
||||
ghostRotation,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Vector3 } from 'three'
|
||||
import { computeEaveY } from '../gutter/eave-snap'
|
||||
import { resolveGutterOutletById } from '../gutter/outlet-lookup'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { downspoutDefinition } from './definition'
|
||||
import DownspoutPreview from './preview'
|
||||
import { computeDownspoutRouting, type DownspoutRouting } from './routing'
|
||||
@@ -162,22 +163,30 @@ const DownspoutTool = () => {
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !target) return null
|
||||
|
||||
return (
|
||||
<group position={target.segment.position} rotation-y={target.segment.rotation}>
|
||||
<group
|
||||
position={[target.gutter.position[0], target.segment.eaveY, target.gutter.position[2]]}
|
||||
rotation-y={target.gutter.rotation}
|
||||
>
|
||||
<group position={[target.outlet.x, target.outlet.y, target.outlet.z]}>
|
||||
<DownspoutPreview
|
||||
node={previewNodeWithDefaults(previewNode, target)}
|
||||
routing={target.routing}
|
||||
/>
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={() => setTarget(null)}
|
||||
size={[0.2, 2.5, 0.2]}
|
||||
validTarget="gutter"
|
||||
/>
|
||||
{activeBuildingId && target && (
|
||||
<group position={target.segment.position} rotation-y={target.segment.rotation}>
|
||||
<group
|
||||
position={[target.gutter.position[0], target.segment.eaveY, target.gutter.position[2]]}
|
||||
rotation-y={target.gutter.rotation}
|
||||
>
|
||||
<group position={[target.outlet.x, target.outlet.y, target.outlet.z]}>
|
||||
<DownspoutPreview
|
||||
node={previewNodeWithDefaults(previewNode, target)}
|
||||
routing={target.routing}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
@@ -58,11 +63,22 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
let committed = false
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
lastSnap = null
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
@@ -89,8 +105,10 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const st = useScene.getState()
|
||||
|
||||
@@ -169,16 +187,29 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!lastTarget) return
|
||||
onRoofClick({
|
||||
nativeEvent: event,
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
} as unknown as RoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('roof:move', updatePreview)
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', clearTarget)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', clearTarget)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
|
||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import { eyebrowVentDefinition } from './definition'
|
||||
@@ -117,16 +118,26 @@ const EyebrowVentTool = () => {
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
||||
|
||||
return (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<EyebrowVentPreview node={previewNode} />
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[1.2, 0.4, 0.5]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<EyebrowVentPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
snapFenceDraftPoint,
|
||||
triggerSFX,
|
||||
@@ -227,6 +228,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (wasCommitted) return
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
@@ -264,6 +266,12 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
activatedAtRef.current = 0
|
||||
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [fenceId] })
|
||||
@@ -279,6 +287,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
@@ -303,6 +312,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
|
||||
@@ -466,7 +466,8 @@ export const FenceTool: React.FC = () => {
|
||||
}
|
||||
|
||||
// Align the drafted point onto another object's nearest real anchor and
|
||||
// publish the guide. Alt bypasses. Returns the (possibly snapped) point.
|
||||
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
|
||||
// snapping. Returns the possibly snapped point.
|
||||
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
||||
if (bypass || alignmentCandidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
|
||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||
@@ -71,8 +76,15 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: GutterDragTarget | null = null
|
||||
let committed = false
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
lastSnap = null
|
||||
setTarget(null)
|
||||
}
|
||||
|
||||
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return null
|
||||
@@ -85,7 +97,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const roof = event.node as RoofNode
|
||||
const target = resolveTarget(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
|
||||
// Same snap math as the placement tool — picking-up and putting-
|
||||
@@ -120,8 +135,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = lastTarget ?? resolveTarget(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const { snap } = target
|
||||
const st = useScene.getState()
|
||||
@@ -201,16 +218,29 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!lastTarget) return
|
||||
onRoofClick({
|
||||
nativeEvent: event,
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
} as unknown as RoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('roof:move', updatePreview)
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', clearTarget)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', clearTarget)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { gutterDefinition } from './definition'
|
||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||
@@ -142,19 +143,26 @@ const GutterTool = () => {
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !target) return null
|
||||
|
||||
return (
|
||||
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
||||
<group position={target.segment.position} rotation-y={target.segment.rotation}>
|
||||
<group
|
||||
position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]}
|
||||
rotation-y={target.snap.rotation}
|
||||
>
|
||||
<GutterPreview node={previewNode} />
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={() => setTarget(null)}
|
||||
size={[2, 0.2, 0.25]}
|
||||
/>
|
||||
{activeBuildingId && target && (
|
||||
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
||||
<group position={target.segment.position} rotation-y={target.segment.rotation}>
|
||||
<group
|
||||
position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]}
|
||||
rotation-y={target.snap.rotation}
|
||||
>
|
||||
<GutterPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
createRelativeRoofDrag,
|
||||
@@ -60,8 +65,15 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: RidgeVentDragTarget | null = null
|
||||
let committed = false
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
lastSnap = null
|
||||
setPreviewPos(null)
|
||||
}
|
||||
|
||||
const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return null
|
||||
@@ -75,7 +87,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = resolveTarget(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
@@ -100,8 +115,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = lastTarget ?? resolveTarget(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const st = useScene.getState()
|
||||
|
||||
@@ -180,16 +197,29 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!lastTarget) return
|
||||
onRoofClick({
|
||||
nativeEvent: event,
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
} as unknown as RoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('roof:move', updatePreview)
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', clearTarget)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', clearTarget)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRidgeSnap } from '../shared/ridge-snap'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { ridgeVentDefinition } from './definition'
|
||||
import RidgeVentPreview from './preview'
|
||||
@@ -135,14 +136,30 @@ const RidgeVentTool = () => {
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !previewPos) return null
|
||||
|
||||
return (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<RidgeVentPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
isValidRoofTarget={(event) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
return !!hit && !!resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
|
||||
}}
|
||||
onInvalidTarget={() => setPreviewPos(null)}
|
||||
size={[2, 0.15, 0.35]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<RidgeVentPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
CursorSphere,
|
||||
commitFreshPlacementSubtree,
|
||||
consumePlacementDragRelease,
|
||||
DragBoundingBox,
|
||||
getFloorStackPreviewPosition,
|
||||
resolvePlanarCursorPosition,
|
||||
@@ -359,6 +360,7 @@ export const MoveRoofTool: React.FC<{
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (wasCommitted) return
|
||||
if (!hasMoved) return
|
||||
const [localX, , localZ] = lastLocalPosition
|
||||
|
||||
@@ -394,6 +396,11 @@ export const MoveRoofTool: React.FC<{
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
wasCancelled = true
|
||||
useLiveTransforms.getState().clear(movingNode.id)
|
||||
@@ -454,6 +461,7 @@ export const MoveRoofTool: React.FC<{
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
// Restore segment wrapper visibility (React will re-sync on next render)
|
||||
@@ -485,6 +493,7 @@ export const MoveRoofTool: React.FC<{
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
}
|
||||
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type GutterEvent,
|
||||
type RoofEvent,
|
||||
sceneRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { DragBoundingBox } from '@pascal-app/editor'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Vector3 } from 'three'
|
||||
|
||||
const INVALID_PREVIEW_COLOR = 0xef_44_44
|
||||
type ValidTarget = 'roof' | 'gutter'
|
||||
|
||||
export function RoofAttachmentFallbackPreview({
|
||||
activeBuildingId,
|
||||
isValidRoofTarget,
|
||||
lift = 0,
|
||||
onInvalidTarget,
|
||||
size,
|
||||
validTarget = 'roof',
|
||||
}: {
|
||||
activeBuildingId: string | null | undefined
|
||||
isValidRoofTarget?: (event: RoofEvent) => boolean
|
||||
lift?: number
|
||||
onInvalidTarget?: () => void
|
||||
size: [number, number, number]
|
||||
validTarget?: ValidTarget
|
||||
}) {
|
||||
const [position, setPosition] = useState<[number, number, number] | null>(null)
|
||||
const lastValidTargetEventRef = useRef<unknown>(null)
|
||||
const localPointRef = useRef(new Vector3())
|
||||
const isValidRoofTargetRef = useRef(isValidRoofTarget)
|
||||
const onInvalidTargetRef = useRef(onInvalidTarget)
|
||||
isValidRoofTargetRef.current = isValidRoofTarget
|
||||
onInvalidTargetRef.current = onInvalidTarget
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeBuildingId) {
|
||||
setPosition(null)
|
||||
lastValidTargetEventRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const trackValidHit = (nativeEvent: unknown) => {
|
||||
lastValidTargetEventRef.current = nativeEvent
|
||||
setPosition(null)
|
||||
}
|
||||
const showInvalidAt = (x: number, y: number, z: number) => {
|
||||
setPosition([x, y + lift, z])
|
||||
onInvalidTargetRef.current?.()
|
||||
}
|
||||
const showInvalidAtWorld = (event: RoofEvent) => {
|
||||
const point = localPointRef.current.set(...event.position)
|
||||
const building = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||
if (building) {
|
||||
building.updateWorldMatrix(true, false)
|
||||
building.worldToLocal(point)
|
||||
}
|
||||
showInvalidAt(point.x, 0, point.z)
|
||||
}
|
||||
const onRoofHit = (event: RoofEvent) => {
|
||||
if (isValidRoofTargetRef.current?.(event) === false) {
|
||||
showInvalidAtWorld(event)
|
||||
return
|
||||
}
|
||||
trackValidHit(event.nativeEvent)
|
||||
}
|
||||
const onGutterHit = (event: GutterEvent) => trackValidHit(event.nativeEvent)
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (event.nativeEvent === lastValidTargetEventRef.current) return
|
||||
const [x, y, z] = event.localPosition
|
||||
showInvalidAt(x, y, z)
|
||||
}
|
||||
|
||||
if (validTarget === 'roof') {
|
||||
emitter.on('roof:enter', onRoofHit)
|
||||
emitter.on('roof:move', onRoofHit)
|
||||
} else {
|
||||
emitter.on('gutter:enter', onGutterHit)
|
||||
emitter.on('gutter:move', onGutterHit)
|
||||
}
|
||||
emitter.on('grid:move', onGridMove)
|
||||
|
||||
return () => {
|
||||
if (validTarget === 'roof') {
|
||||
emitter.off('roof:enter', onRoofHit)
|
||||
emitter.off('roof:move', onRoofHit)
|
||||
} else {
|
||||
emitter.off('gutter:enter', onGutterHit)
|
||||
emitter.off('gutter:move', onGutterHit)
|
||||
}
|
||||
emitter.off('grid:move', onGridMove)
|
||||
}
|
||||
}, [activeBuildingId, lift, validTarget])
|
||||
|
||||
if (!(activeBuildingId && position)) return null
|
||||
|
||||
return (
|
||||
<DragBoundingBox
|
||||
color={INVALID_PREVIEW_COLOR}
|
||||
nodeId="roof-attachment-fallback"
|
||||
position={position}
|
||||
size={size}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -88,7 +88,10 @@ const cursorPoint = new Vector3()
|
||||
export function worldToSelectedBuildingLocal(point: Vector3): [number, number, number] {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
|
||||
if (buildingObj) buildingObj.worldToLocal(point)
|
||||
if (buildingObj) {
|
||||
buildingObj.updateWorldMatrix(true, false)
|
||||
buildingObj.worldToLocal(point)
|
||||
}
|
||||
return [point.x, point.y, point.z]
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place shelf' },
|
||||
{ key: 'Shift', label: 'Free place' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
@@ -65,8 +70,14 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
let lastSnapX = 0
|
||||
let lastSnapZ = 0
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
let committed = false
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
setHasHit(false)
|
||||
}
|
||||
|
||||
// Resolve which segment the cursor is over, then derive the same
|
||||
// preview transform stack the placement tool uses (`skylight/tool.tsx`):
|
||||
// analytical surface normal in segment-local frame → outer yaw =
|
||||
@@ -77,7 +88,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
const roof = event.node as RoofNode
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) {
|
||||
setHasHit(false)
|
||||
clearTarget()
|
||||
return false
|
||||
}
|
||||
lastTarget = target
|
||||
@@ -113,10 +124,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const st = useScene.getState()
|
||||
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const finalRotation = original.rotation
|
||||
@@ -206,16 +219,29 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!lastTarget) return
|
||||
onRoofClick({
|
||||
nativeEvent: event,
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
} as unknown as RoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('roof:move', onRoofMove)
|
||||
emitter.on('roof:enter', onRoofEnter)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', clearTarget)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', onRoofMove)
|
||||
emitter.off('roof:enter', onRoofEnter)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', clearTarget)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
|
||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import { skylightDefinition } from './definition'
|
||||
@@ -109,16 +110,26 @@ const SkylightTool = () => {
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
||||
|
||||
return (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<SkylightPreview node={previewNode} />
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[1.2, 0.2, 1]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<SkylightPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Trace slab outline' },
|
||||
{ key: 'Enter', label: 'Finish slab' },
|
||||
{ key: 'Shift', label: 'Free outline' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
consumePlacementDragRelease,
|
||||
getSegmentGridStep,
|
||||
markToolCancelConsumed,
|
||||
resolveAlignmentForActiveBuilding,
|
||||
@@ -214,6 +215,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (wasCommitted) return
|
||||
if (isFloorplanSourcedEvent(event)) return
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
@@ -245,6 +247,12 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
activatedAtRef.current = 0
|
||||
onGridClick({ nativeEvent: event } as unknown as GridEvent)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
// No scene state to roll back — we never wrote anything. Just
|
||||
// restore the mesh visual.
|
||||
@@ -258,6 +266,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
@@ -269,6 +278,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
}
|
||||
}, [exitMoveMode, node.id, node.parentId])
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
@@ -91,11 +97,20 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
let lastSnapX = 0
|
||||
let lastSnapZ = 0
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
let committed = false
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
setHasHit(false)
|
||||
}
|
||||
|
||||
const updateGhost = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
@@ -127,10 +142,12 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const st = useScene.getState()
|
||||
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
|
||||
@@ -227,16 +244,29 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!lastTarget) return
|
||||
onRoofClick({
|
||||
nativeEvent: event,
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
} as unknown as RoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('roof:move', updateGhost)
|
||||
emitter.on('roof:enter', updateGhost)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', clearTarget)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updateGhost)
|
||||
emitter.off('roof:enter', updateGhost)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', clearTarget)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
|
||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import { solarPanelDefinition } from './definition'
|
||||
@@ -130,16 +131,26 @@ const SolarPanelTool = () => {
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
||||
|
||||
return (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<SolarPanelPreview node={previewNode} />
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[1.8, 0.2, 1.2]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<SolarPanelPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place spawn point' },
|
||||
{ key: 'Shift', label: 'Free place' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
type TurbineVentNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
@@ -58,11 +63,22 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
let committed = false
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
lastSnap = null
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
@@ -89,8 +105,10 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const st = useScene.getState()
|
||||
|
||||
@@ -169,16 +187,29 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!lastTarget) return
|
||||
onRoofClick({
|
||||
nativeEvent: event,
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
} as unknown as RoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('roof:move', updatePreview)
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', clearTarget)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', clearTarget)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
|
||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import { turbineVentDefinition } from './definition'
|
||||
@@ -117,16 +118,26 @@ const TurbineVentTool = () => {
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
||||
|
||||
return (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<TurbineVentPreview node={previewNode} />
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[0.5, 0.8, 0.5]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<TurbineVentPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -508,9 +508,13 @@ export const WallTool: React.FC = () => {
|
||||
}
|
||||
|
||||
// Align the drafted point onto another object's nearest real anchor and
|
||||
// publish the guide. Alt bypasses. Returns the (possibly snapped) point.
|
||||
const alignPoint = (point: WallPlanPoint, bypass: boolean): WallPlanPoint => {
|
||||
if (bypass || alignmentCandidates.length === 0) {
|
||||
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
|
||||
// snapping. Returns the possibly snapped point.
|
||||
const alignPoint = (
|
||||
point: WallPlanPoint,
|
||||
options: { applySnap?: boolean; bypass?: boolean },
|
||||
): WallPlanPoint => {
|
||||
if (options.bypass || alignmentCandidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return point
|
||||
}
|
||||
@@ -520,7 +524,9 @@ export const WallTool: React.FC = () => {
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point
|
||||
return ar.snap && options.applySnap !== false
|
||||
? [point[0] + ar.snap.dx, point[1] + ar.snap.dz]
|
||||
: point
|
||||
}
|
||||
|
||||
const stopDrafting = () => {
|
||||
@@ -554,7 +560,10 @@ export const WallTool: React.FC = () => {
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
})
|
||||
gridPosition = alignPoint(snapResult.point, bypassAlign || angleLocked)
|
||||
gridPosition = alignPoint(snapResult.point, {
|
||||
applySnap: !angleLocked,
|
||||
bypass: bypassAlign,
|
||||
})
|
||||
// Stand the magnetic beacon at the endpoint when it locked onto an
|
||||
// existing wall corner / wall point; clear it for plain grid/angle moves.
|
||||
useWallSnapIndicator
|
||||
@@ -635,7 +644,7 @@ export const WallTool: React.FC = () => {
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
}).point,
|
||||
bypassAlign,
|
||||
{ bypass: bypassAlign },
|
||||
)
|
||||
gridPosition = snappedStart
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
@@ -666,7 +675,10 @@ export const WallTool: React.FC = () => {
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
}).point,
|
||||
bypassAlign || angleLocked,
|
||||
{
|
||||
applySnap: !angleLocked,
|
||||
bypass: bypassAlign,
|
||||
},
|
||||
)
|
||||
const dx = snappedEnd[0] - startingPoint.current.x
|
||||
const dz = snappedEnd[1] - startingPoint.current.z
|
||||
|
||||
@@ -197,6 +197,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place window on wall' },
|
||||
{ key: 'Shift', label: 'Free place' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
consumePlacementDragRelease,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
@@ -99,6 +100,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
let currentHostId: string | null = movingWindowNode.parentId
|
||||
let committed = false
|
||||
let dragAnchor: {
|
||||
wallId: string
|
||||
rawX: number
|
||||
@@ -117,6 +119,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
valid: boolean
|
||||
event: WallEvent
|
||||
} | null = null
|
||||
let lastRoofEvent: RoofEvent | null = null
|
||||
|
||||
const markHostDirty = (hostId: string | null) => {
|
||||
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||
@@ -285,29 +288,44 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
applyPreview(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
hideCursor()
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
if (event.node.parentId !== getLevelId()) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
applyPreview(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (committed) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
// Only interact with walls on the current level
|
||||
@@ -315,6 +333,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
committed = true
|
||||
|
||||
let placedId: string
|
||||
|
||||
@@ -387,6 +406,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
if (isNew) return // No original to restore for duplicates
|
||||
// Move mode: restore to original position while off-wall
|
||||
if (currentHostId && currentHostId !== original.parentId) {
|
||||
@@ -430,10 +450,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
|
||||
const onRoofHover = (event: RoofEvent) => {
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
onRoofLeave()
|
||||
return
|
||||
}
|
||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = event
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
if (currentHostId !== target.segment.id) {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
@@ -459,8 +483,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
committed = true
|
||||
const segmentId = target.segment.id
|
||||
|
||||
let placedId: string
|
||||
@@ -531,6 +557,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
if (isNew) return
|
||||
if (currentHostId && currentHostId !== original.parentId) {
|
||||
markHostDirty(currentHostId)
|
||||
@@ -571,6 +598,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (lastTarget) {
|
||||
onWallClick(lastTarget.event)
|
||||
return
|
||||
}
|
||||
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
@@ -580,6 +616,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', onRoofLeave)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||
@@ -617,6 +654,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', onRoofLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
}
|
||||
}, [movingWindowNode, exitMoveMode])
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
isCurvedWall,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
@@ -23,12 +24,13 @@ import {
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
getRoofWallOpeningCursorPose,
|
||||
type RoofWallOpeningTarget,
|
||||
resolveRoofWallOpeningTarget,
|
||||
worldToSelectedBuildingLocal,
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
@@ -41,6 +43,11 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const FALLBACK_WIDTH = 1.5
|
||||
const FALLBACK_HEIGHT = 1.5
|
||||
const FALLBACK_SILL_LIFT = 0.45
|
||||
const roofFallbackPoint = new Vector3()
|
||||
|
||||
/**
|
||||
* Window tool — places WindowNodes on walls and on roof-segment wall
|
||||
* faces (the generated base walls under a roof, including coplanar gable
|
||||
@@ -103,17 +110,48 @@ const WindowTool: React.FC = () => {
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const showFallbackCursor = (event: GridEvent) => {
|
||||
if (draftRef.current) return
|
||||
const [x, y, z] = event.localPosition
|
||||
updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const showWallFallbackCursor = (event: WallEvent) => {
|
||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
const levelId = getLevelId()
|
||||
if (!levelId) return
|
||||
if (!levelId) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== levelId) return
|
||||
if (event.node.parentId !== levelId) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
|
||||
destroyDraft()
|
||||
|
||||
@@ -167,14 +205,22 @@ const WindowTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
if (event.node.parentId !== getLevelId()) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
@@ -395,10 +441,8 @@ const WindowTool: React.FC = () => {
|
||||
if (!target) {
|
||||
// On the roof but not over a placeable wall face (slope, soffit,
|
||||
// or a face the window cannot fit on).
|
||||
if (draftRef.current?.roofSegmentId) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
}
|
||||
destroyDraft()
|
||||
showRoofFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
const { segment, face, position } = target
|
||||
@@ -499,6 +543,7 @@ const WindowTool: React.FC = () => {
|
||||
emitter.on('roof:move', onRoofHover)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', onRoofLeave)
|
||||
emitter.on('grid:move', showFallbackCursor)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
@@ -514,12 +559,13 @@ const WindowTool: React.FC = () => {
|
||||
emitter.off('roof:move', onRoofHover)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', onRoofLeave)
|
||||
emitter.off('grid:move', showFallbackCursor)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cursor geometry: window outline rectangle (width × height × frameDepth)
|
||||
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
|
||||
// Cursor geometry: window outline rectangle.
|
||||
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
||||
boxGeo.dispose()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user