Fix relative move drag offsets
This commit is contained in:
@@ -40,6 +40,7 @@ export function MoveElevatorTool({
|
||||
const onCommittedRef = useRef(onCommitted)
|
||||
const historyPausedRef = useRef(false)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const previewPositionRef = useRef<ElevatorNode['position']>([
|
||||
movingNode.position[0],
|
||||
movingNode.position[1],
|
||||
@@ -73,6 +74,8 @@ export function MoveElevatorTool({
|
||||
}
|
||||
|
||||
pauseHistory()
|
||||
dragAnchorRef.current = null
|
||||
previousGridPosRef.current = null
|
||||
const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id
|
||||
|
||||
const meta =
|
||||
@@ -128,8 +131,12 @@ export function MoveElevatorTool({
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const rawX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const rawZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const anchor = dragAnchorRef.current ?? [rawX, rawZ]
|
||||
dragAnchorRef.current = anchor
|
||||
const gridX = movingNode.position[0] + (rawX - anchor[0])
|
||||
const gridZ = movingNode.position[2] + (rawZ - anchor[1])
|
||||
const supportY = resolveElevatorSupportY({
|
||||
buildingId: supportBuildingId,
|
||||
preferredLevelId: supportLevelId,
|
||||
@@ -151,15 +158,7 @@ export function MoveElevatorTool({
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const supportY = resolveElevatorSupportY({
|
||||
buildingId: supportBuildingId,
|
||||
preferredLevelId: supportLevelId,
|
||||
x: gridX,
|
||||
z: gridZ,
|
||||
})
|
||||
const nextPosition: ElevatorNode['position'] = [gridX, supportY, gridZ]
|
||||
const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
|
||||
|
||||
wasCommitted = true
|
||||
clearPreview()
|
||||
|
||||
@@ -195,6 +195,8 @@ export interface PlacementCoordinatorConfig {
|
||||
initialState?: PlacementState
|
||||
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
||||
defaultScale?: [number, number, number]
|
||||
/** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */
|
||||
preserveFloorDragOffset?: boolean
|
||||
}
|
||||
|
||||
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
||||
@@ -405,6 +407,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// building-local, matching the draft's grid position and the guide
|
||||
// layer's frame.
|
||||
let alignmentCandidates: AlignmentAnchor[] | null = null
|
||||
let floorDragAnchor: [number, number] | null = null
|
||||
|
||||
// Reset placement state
|
||||
placementState.current = configRef.current.initialState ?? {
|
||||
@@ -526,6 +529,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
// ---- Init draft ----
|
||||
configRef.current.initDraft(gridPosition.current)
|
||||
const preserveFloorDragOffset =
|
||||
configRef.current.preserveFloorDragOffset === true &&
|
||||
placementState.current.surface === 'floor' &&
|
||||
!asset.attachTo
|
||||
const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null
|
||||
|
||||
// Sync cursor to the draft mesh's world position and rotation
|
||||
if (draftNode.current) {
|
||||
@@ -649,9 +657,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
detachItemSurfaceToFloor(event as unknown as ItemEvent)
|
||||
}
|
||||
|
||||
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
||||
const floorEvent =
|
||||
relativeFloorStart !== null
|
||||
? (() => {
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
const anchor = floorDragAnchor ?? [rawX, rawZ]
|
||||
floorDragAnchor = anchor
|
||||
return {
|
||||
...event,
|
||||
localPosition: [
|
||||
relativeFloorStart.x + (rawX - anchor[0]),
|
||||
event.localPosition[1],
|
||||
relativeFloorStart.z + (rawZ - anchor[1]),
|
||||
] as [number, number, number],
|
||||
}
|
||||
})()
|
||||
: event
|
||||
|
||||
lastRawPos.current.set(
|
||||
floorEvent.localPosition[0],
|
||||
floorEvent.localPosition[1],
|
||||
floorEvent.localPosition[2],
|
||||
)
|
||||
if (!cursorGroupRef.current) return
|
||||
const result = floorStrategy.move(getContext(), event)
|
||||
const result = floorStrategy.move(getContext(), floorEvent)
|
||||
if (!result) return
|
||||
|
||||
// Figma-style alignment snap layered on top of the floor strategy's
|
||||
@@ -663,7 +693,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const draft = draftNode.current
|
||||
let alignX = 0
|
||||
let alignZ = 0
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
const bypassAlign = floorEvent.nativeEvent?.altKey === true
|
||||
if (!bypassAlign && draft) {
|
||||
alignmentCandidates ??= collectAlignmentAnchors(
|
||||
useScene.getState().nodes,
|
||||
|
||||
@@ -125,6 +125,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
* commit position consistent with the visible cursor.
|
||||
*/
|
||||
const lastCursorRef = useRef<[number, number, number]>(originalPosition)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
/**
|
||||
* Becomes true on the first `grid:move` after this move arms. Commits are
|
||||
* ignored until then so a click that *armed* this move (e.g. the trailing
|
||||
@@ -166,6 +167,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
previousSnapRef.current = null
|
||||
dragAnchorRef.current = null
|
||||
hasMovedRef.current = false
|
||||
rotationRef.current = originalRotationY
|
||||
shiftRef.current = false
|
||||
@@ -267,8 +269,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
)
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
let x = snapToGridStep(event.localPosition[0])
|
||||
let z = snapToGridStep(event.localPosition[2])
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
const anchor = dragAnchorRef.current ?? [rawX, rawZ]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
let x = originalPosition[0] + snapToGridStep(rawX - anchor[0])
|
||||
let z = originalPosition[2] + snapToGridStep(rawZ - anchor[1])
|
||||
|
||||
// Figma-style alignment snap layered on top of grid snap: when the
|
||||
// moving item's edge lines up (on X or Z) with another item's edge,
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
type BoxVentNode,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import BoxVentPreview from './preview'
|
||||
|
||||
@@ -55,48 +57,39 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||
if (ventObj) ventObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
const v = new THREE.Vector3(wx, wy, wz)
|
||||
buildingObj.worldToLocal(v)
|
||||
return [v.x, v.y, v.z]
|
||||
}
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const wx = event.position[0]
|
||||
const wy = event.position[1]
|
||||
const wz = event.position[2]
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
const sz = Math.round(target.localZ * 20) / 20
|
||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||
if (!hit) return
|
||||
|
||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||
setPreviewPos(
|
||||
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||
target.localX,
|
||||
target.localY,
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const st = useScene.getState()
|
||||
|
||||
// Reparent if the cursor landed on a different segment than the
|
||||
@@ -124,7 +117,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: targetSegmentId,
|
||||
parentId: targetSegmentId,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
rotation: original.rotation,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
|
||||
@@ -17,6 +17,7 @@ const Y_AXIS = new THREE.Vector3(0, 1, 0)
|
||||
|
||||
export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
|
||||
// Stable refs so the effect never needs node in its dependency array
|
||||
const nodeIdRef = useRef(node.id)
|
||||
@@ -29,9 +30,8 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0)
|
||||
|
||||
// Local-space offset from the building's origin to its bbox center. The
|
||||
// floating drag button anchors at the bbox center, so we pin that point to
|
||||
// the cursor during the drag — otherwise the raw origin (often nowhere near
|
||||
// the visual center) would snap to the cursor and the building would jump.
|
||||
// move preview preserves the first pointer-to-center delta, then uses this
|
||||
// offset to write the origin while keeping rotation around the visual center.
|
||||
const centerOffsetLocalRef = useRef<THREE.Vector3>(new THREE.Vector3())
|
||||
|
||||
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
||||
@@ -66,8 +66,15 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
const offsetWork = new THREE.Vector3()
|
||||
const offsetAt = (rotationY: number) =>
|
||||
offsetWork.copy(centerOffsetLocalRef.current).applyAxisAngle(Y_AXIS, rotationY)
|
||||
const originalCenterOffset = offsetAt(originalRotationRef.current).clone()
|
||||
const originalCenter: [number, number] = [
|
||||
originalPosition[0] + originalCenterOffset.x,
|
||||
originalPosition[2] + originalCenterOffset.z,
|
||||
]
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
dragAnchorRef.current = null
|
||||
previousGridPosRef.current = null
|
||||
|
||||
// Publish the building's current pose to useLiveTransforms so the
|
||||
// floor-plan (and any other live consumers) can follow per-frame
|
||||
@@ -114,8 +121,12 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const rawX = Math.round(event.position[0] * 2) / 2
|
||||
const rawZ = Math.round(event.position[2] * 2) / 2
|
||||
const anchor = dragAnchorRef.current ?? [rawX, rawZ]
|
||||
dragAnchorRef.current = anchor
|
||||
const gridX = originalCenter[0] + (rawX - anchor[0])
|
||||
const gridZ = originalCenter[1] + (rawZ - anchor[1])
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
@@ -138,8 +149,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter
|
||||
|
||||
wasCommitted = true
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ChimneyNode as ChimneyNodeSchema,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
@@ -15,7 +14,7 @@ import { triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { createRelativeRoofDrag, type RelativeRoofDragTarget } from '../shared/relative-roof-drag'
|
||||
import ChimneyPreview from './preview'
|
||||
|
||||
const tmpMatrix = new THREE.Matrix4()
|
||||
@@ -84,38 +83,36 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const wx = event.position[0]
|
||||
const wy = event.position[1]
|
||||
const wz = event.position[2]
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
const roofDrag = createRelativeRoofDrag({
|
||||
position: [...node.position] as [number, number, number],
|
||||
roofSegmentId: node.roofSegmentId,
|
||||
})
|
||||
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
const sz = Math.round(target.localZ * 20) / 20
|
||||
const prev = lastSnapRef.current
|
||||
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||
if (!hit) return
|
||||
|
||||
const xform = computeSegmentXform(hit.segment.id)
|
||||
const xform = computeSegmentXform(target.segment.id)
|
||||
if (!xform) return
|
||||
setSegmentXform(xform)
|
||||
setHitLocal([hit.localX, hit.localY, hit.localZ])
|
||||
setPreviewSegment(hit.segment)
|
||||
setHitLocal([target.localX, target.localY, target.localZ])
|
||||
setPreviewSegment(target.segment)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onClick = (event: RoofEvent) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
const state = useScene.getState()
|
||||
|
||||
// Strip the `isNew` flag — only used to mark a duplicate clone
|
||||
@@ -135,23 +132,23 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
const committed = ChimneyNodeSchema.parse({
|
||||
...node,
|
||||
id: undefined as never,
|
||||
roofSegmentId: hit.segment.id,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
roofSegmentId: target.segment.id,
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
metadata: cleanedMeta,
|
||||
})
|
||||
state.createNode(committed, hit.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
state.createNode(committed, target.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(target.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [committed.id] })
|
||||
} else {
|
||||
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
|
||||
state.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: hit.segment.id,
|
||||
parentId: hit.segment.id,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
roofSegmentId: target.segment.id,
|
||||
parentId: target.segment.id,
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
metadata: cleanedMeta,
|
||||
})
|
||||
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(target.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [node.id] })
|
||||
}
|
||||
setMovingNode(null)
|
||||
|
||||
@@ -70,6 +70,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
let rotationY = node.rotation
|
||||
// Latest previewed position, so an R/T press can re-apply at the spot.
|
||||
let lastPosition: [number, number, number] = node.position
|
||||
let dragAnchor: [number, number] | null = null
|
||||
const meta =
|
||||
typeof node.metadata === 'object' && node.metadata !== null
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
@@ -111,8 +112,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
hasMoved = true
|
||||
let x = snapToGridStep(event.localPosition[0])
|
||||
let z = snapToGridStep(event.localPosition[2])
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
dragAnchor ??= [rawX, rawZ]
|
||||
let x = node.position[0] + snapToGridStep(rawX - dragAnchor[0])
|
||||
let z = node.position[2] + snapToGridStep(rawZ - dragAnchor[1])
|
||||
|
||||
// Figma-style alignment snap on top of grid snap; Alt bypasses. The
|
||||
// guide connects to the candidate's nearest real anchor (resolver
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
type CupolaNode,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import CupolaPreview from './preview'
|
||||
|
||||
@@ -53,48 +55,39 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
const cupolaObj = sceneRegistry.nodes.get(node.id)
|
||||
if (cupolaObj) cupolaObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
const v = new THREE.Vector3(wx, wy, wz)
|
||||
buildingObj.worldToLocal(v)
|
||||
return [v.x, v.y, v.z]
|
||||
}
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const wx = event.position[0]
|
||||
const wy = event.position[1]
|
||||
const wz = event.position[2]
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
const sz = Math.round(target.localZ * 20) / 20
|
||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||
if (!hit) return
|
||||
|
||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||
setPreviewPos(
|
||||
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||
target.localX,
|
||||
target.localY,
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const st = useScene.getState()
|
||||
|
||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
@@ -118,7 +111,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: targetSegmentId,
|
||||
parentId: targetSegmentId,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
rotation: original.rotation,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
|
||||
@@ -66,6 +66,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
|
||||
let currentWallId: string | null = movingDoorNode.parentId
|
||||
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
||||
let lastTarget: {
|
||||
wallNode: WallEvent['node']
|
||||
wallId: string
|
||||
side: DoorNode['side']
|
||||
itemRotation: number
|
||||
cursorRotation: number
|
||||
clampedX: number
|
||||
clampedY: number
|
||||
valid: boolean
|
||||
event: WallEvent
|
||||
} | null = null
|
||||
|
||||
const markWallDirty = (wallId: string | null) => {
|
||||
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||
@@ -131,7 +143,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
const resolveMoveTarget = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
hideCursor()
|
||||
@@ -141,9 +153,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
|
||||
const rawLocalX = event.localPosition[0]
|
||||
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
||||
dragAnchor = {
|
||||
wallId: event.node.id,
|
||||
rawX: rawLocalX,
|
||||
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
|
||||
}
|
||||
}
|
||||
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
rawLocalX: targetLocalX,
|
||||
width: movingDoorNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
@@ -155,24 +176,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
const prevWallId = currentWallId
|
||||
currentWallId = event.node.id
|
||||
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
useLiveTransforms.getState().set(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: itemRotation,
|
||||
})
|
||||
|
||||
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
||||
markWallDirtyThrottled(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
@@ -182,17 +185,62 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
movingDoorNode.id,
|
||||
)
|
||||
|
||||
return {
|
||||
wallNode: event.node,
|
||||
wallId: event.node.id,
|
||||
side,
|
||||
itemRotation,
|
||||
cursorRotation,
|
||||
clampedX,
|
||||
clampedY,
|
||||
valid,
|
||||
event,
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
||||
if (currentWallId !== target.wallId) {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [target.clampedX, target.clampedY, 0],
|
||||
rotation: [0, target.itemRotation, 0],
|
||||
side: target.side,
|
||||
parentId: target.wallId,
|
||||
wallId: target.wallId,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = target.wallId
|
||||
} else {
|
||||
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
|
||||
if (doorMesh) {
|
||||
doorMesh.position.set(target.clampedX, target.clampedY, 0)
|
||||
doorMesh.rotation.set(0, target.itemRotation, 0)
|
||||
doorMesh.updateMatrixWorld(true)
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().set(movingDoorNode.id, {
|
||||
position: [target.clampedX, target.clampedY, 0],
|
||||
rotation: target.itemRotation,
|
||||
})
|
||||
markWallDirtyThrottled(target.wallId)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
target.wallNode,
|
||||
target.clampedX,
|
||||
target.clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
getSlabElevation(target.event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
target.cursorRotation,
|
||||
target.valid,
|
||||
)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
applyPreview(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
@@ -204,69 +252,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: movingDoorNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
if (currentWallId !== event.node.id) {
|
||||
// Wall changed mid-move: must updateNode to reparent
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = event.node.id
|
||||
} else {
|
||||
// Same wall: update Three.js mesh directly to avoid store churn
|
||||
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
||||
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
|
||||
if (doorMesh) {
|
||||
doorMesh.position.set(clampedX, clampedY, 0)
|
||||
doorMesh.rotation.set(0, itemRotation, 0)
|
||||
doorMesh.updateMatrixWorld(true)
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().set(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: itemRotation,
|
||||
})
|
||||
markWallDirtyThrottled(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
applyPreview(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
@@ -275,31 +264,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
if (isCurvedWall(event.node)) return
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const { side, itemRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: movingDoorNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
if (!valid) return
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
|
||||
let placedId: string
|
||||
|
||||
@@ -311,13 +277,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
delete cloned.id
|
||||
const node = DoorNode.parse({
|
||||
...cloned,
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
position: [target.clampedX, target.clampedY, 0],
|
||||
rotation: [0, target.itemRotation, 0],
|
||||
side: target.side,
|
||||
wallId: target.wallId,
|
||||
parentId: target.wallId,
|
||||
})
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
||||
placedId = node.id
|
||||
} else {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
@@ -331,21 +297,21 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
position: [target.clampedX, target.clampedY, 0],
|
||||
rotation: [0, target.itemRotation, 0],
|
||||
side: target.side,
|
||||
parentId: target.wallId,
|
||||
wallId: target.wallId,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
if (original.parentId && original.parentId !== event.node.id) {
|
||||
if (original.parentId && original.parentId !== target.wallId) {
|
||||
markWallDirty(original.parentId)
|
||||
}
|
||||
placedId = movingDoorNode.id
|
||||
}
|
||||
|
||||
markWallDirty(event.node.id)
|
||||
markWallDirty(target.wallId)
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
@@ -359,6 +325,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
const onWallLeave = () => {
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
if (isNew) return
|
||||
if (currentWallId && currentWallId !== original.parentId) {
|
||||
markWallDirty(currentWallId)
|
||||
|
||||
@@ -51,6 +51,7 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
|
||||
const originalRotation = node.rotation ?? 0
|
||||
const originalMetadata = node.metadata
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes.
|
||||
useEffect(() => {
|
||||
if (!isNew) {
|
||||
useScene.getState().updateNode(node.id as AnyNodeId, {
|
||||
@@ -71,11 +72,14 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
|
||||
})
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes.
|
||||
}, [node.id, isNew])
|
||||
|
||||
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
|
||||
initialRotation: originalRotation,
|
||||
relativeStart: {
|
||||
position: [...node.position] as [number, number, number],
|
||||
roofSegmentId: node.roofSegmentId,
|
||||
},
|
||||
onCommit: (hit, rotation) => {
|
||||
const state = useScene.getState()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry'
|
||||
|
||||
@@ -50,6 +51,10 @@ export type DormerPlacementHit = {
|
||||
*/
|
||||
export function useDormerPlacement(opts: {
|
||||
initialRotation?: number
|
||||
relativeStart?: {
|
||||
position: [number, number, number]
|
||||
roofSegmentId?: string
|
||||
}
|
||||
onCommit: (hit: DormerPlacementHit, rotation: number) => void
|
||||
}): {
|
||||
activeBuildingId: string | undefined
|
||||
@@ -66,6 +71,7 @@ export function useDormerPlacement(opts: {
|
||||
// Mirror of `ghostRotation` so the click handler (registered once
|
||||
// inside useEffect) can read the latest value at commit time.
|
||||
const ghostRotationRef = useRef(opts.initialRotation ?? 0)
|
||||
const relativeStartRef = useRef(opts.relativeStart)
|
||||
// Latest commit callback, captured via ref so the useEffect doesn't
|
||||
// need it in its dep list (we don't want to re-register listeners
|
||||
// every time the parent rerenders).
|
||||
@@ -90,9 +96,23 @@ export function useDormerPlacement(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
const roofDrag = relativeStartRef.current
|
||||
? createRelativeRoofDrag(relativeStartRef.current)
|
||||
: null
|
||||
let lastRelativeHit: DormerPlacementHit | null = null
|
||||
|
||||
const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => {
|
||||
if (roofDrag) return roofDrag.resolve(event)
|
||||
return resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const wx = event.position[0]
|
||||
const wy = event.position[1]
|
||||
const wz = event.position[2]
|
||||
|
||||
const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M
|
||||
@@ -103,8 +123,9 @@ export function useDormerPlacement(opts: {
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||
const hit = resolvePlacementHit(event)
|
||||
if (!hit) return
|
||||
if (roofDrag) lastRelativeHit = hit
|
||||
const xform = computeSegmentXform(hit.segment.id)
|
||||
if (!xform) return
|
||||
setSegmentXform(xform)
|
||||
@@ -118,12 +139,9 @@ export function useDormerPlacement(opts: {
|
||||
}
|
||||
|
||||
const onClick = (event: RoofEvent) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
const hit = roofDrag
|
||||
? (lastRelativeHit ?? resolvePlacementHit(event))
|
||||
: resolvePlacementHit(event)
|
||||
if (!hit) return
|
||||
onCommitRef.current(hit, ghostRotationRef.current)
|
||||
triggerSFX('sfx:item-place')
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
type EyebrowVentNode,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import EyebrowVentPreview from './preview'
|
||||
|
||||
@@ -54,48 +56,39 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||
if (ventObj) ventObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
const v = new THREE.Vector3(wx, wy, wz)
|
||||
buildingObj.worldToLocal(v)
|
||||
return [v.x, v.y, v.z]
|
||||
}
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const wx = event.position[0]
|
||||
const wy = event.position[1]
|
||||
const wz = event.position[2]
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
const sz = Math.round(target.localZ * 20) / 20
|
||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||
if (!hit) return
|
||||
|
||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||
setPreviewPos(
|
||||
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||
target.localX,
|
||||
target.localY,
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const st = useScene.getState()
|
||||
|
||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
@@ -119,7 +112,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: targetSegmentId,
|
||||
parentId: targetSegmentId,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
rotation: original.rotation,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
|
||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||
import GutterPreview from './preview'
|
||||
|
||||
@@ -22,6 +22,11 @@ type PreviewTarget = {
|
||||
snap: EaveSnap
|
||||
}
|
||||
|
||||
type GutterDragTarget = {
|
||||
segment: RoofSegmentNode
|
||||
snap: EaveSnap
|
||||
}
|
||||
|
||||
/**
|
||||
* Gutter move tool. Mirrors the ridge-vent move flow — ghost follows
|
||||
* the cursor over any roof segment, click commits the new position +
|
||||
@@ -65,23 +70,30 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
if (gutterObj) gutterObj.visible = false
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: GutterDragTarget | null = null
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return null
|
||||
return {
|
||||
segment: target.segment,
|
||||
snap: resolveEaveSnap(target.segment, target.localX, target.localZ),
|
||||
}
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const roof = event.node as RoofNode
|
||||
const hit = resolveRoofSegmentHit(
|
||||
roof,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const target = resolveTarget(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
|
||||
// Same snap math as the placement tool — picking-up and putting-
|
||||
// down round-trip identically. roofType-aware: hip/flat picks
|
||||
// ±X or ±Z based on which slope the cursor is on; shed always
|
||||
// snaps to its low (+Z) eave; gable / gambrel / mansard / dutch
|
||||
// stay on ±Z.
|
||||
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
|
||||
const { snap } = target
|
||||
|
||||
const sx = Math.round(snap.eaveX * 20) / 20
|
||||
const sz = Math.round(snap.eaveZ * 20) / 20
|
||||
@@ -96,8 +108,8 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
rotation: roof.rotation ?? 0,
|
||||
},
|
||||
segment: {
|
||||
position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number],
|
||||
rotation: hit.segment.rotation ?? 0,
|
||||
position: (target.segment.position ?? [0, 0, 0]) as [number, number, number],
|
||||
rotation: target.segment.rotation ?? 0,
|
||||
},
|
||||
snap,
|
||||
})
|
||||
@@ -105,15 +117,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
|
||||
const target = lastTarget ?? resolveTarget(event)
|
||||
if (!target) return
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const { snap } = target
|
||||
const st = useScene.getState()
|
||||
|
||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
|
||||
@@ -88,6 +88,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) {
|
||||
: getInitialState(node),
|
||||
// Preserve the original item's scale so Y-position calculations use the correct height.
|
||||
defaultScale: isNew ? node.scale : undefined,
|
||||
preserveFloorDragOffset: true,
|
||||
initDraft: (gridPosition) => {
|
||||
if (isNew) {
|
||||
// Duplicate: floor items get a draft immediately; wall/ceiling
|
||||
|
||||
@@ -5,18 +5,25 @@ import {
|
||||
emitter,
|
||||
type RidgeVentNode,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getSurfaceY } from '../shared/roof-surface'
|
||||
import RidgeVentPreview from './preview'
|
||||
|
||||
type RidgeVentDragTarget = Pick<RelativeRoofDragTarget, 'segment' | 'localX'> & {
|
||||
localY: number
|
||||
localZ: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Ridge-vent move tool. Mirrors the box-vent move flow — ghost follows
|
||||
* the cursor over any roof segment, click commits the new position +
|
||||
@@ -51,46 +58,48 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||
if (ventObj) ventObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
const v = new THREE.Vector3(wx, wy, wz)
|
||||
buildingObj.worldToLocal(v)
|
||||
return [v.x, v.y, v.z]
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: RidgeVentDragTarget | null = null
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return null
|
||||
return {
|
||||
segment: target.segment,
|
||||
localX: target.localX,
|
||||
localY: getSurfaceY(target.localX, 0, target.segment),
|
||||
localZ: 0,
|
||||
}
|
||||
}
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const wx = event.position[0]
|
||||
const wy = event.position[1]
|
||||
const wz = event.position[2]
|
||||
const target = resolveTarget(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
const sz = Math.round(target.localZ * 20) / 20
|
||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||
if (!hit) return
|
||||
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||
setPreviewPos(
|
||||
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||
target.localX,
|
||||
target.localY,
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||
const target = lastTarget ?? resolveTarget(event)
|
||||
if (!target) return
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const st = useScene.getState()
|
||||
|
||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
@@ -114,7 +123,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: targetSegmentId,
|
||||
parentId: targetSegmentId,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
rotation: original.rotation,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
|
||||
@@ -41,6 +41,7 @@ export const MoveRoofTool: React.FC<{
|
||||
}, [])
|
||||
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
|
||||
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
||||
const obj = sceneRegistry.nodes.get(movingNode.id)
|
||||
@@ -78,6 +79,8 @@ export const MoveRoofTool: React.FC<{
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
dragAnchorRef.current = null
|
||||
previousGridPosRef.current = null
|
||||
|
||||
const meta =
|
||||
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
|
||||
@@ -255,6 +258,24 @@ export const MoveRoofTool: React.FC<{
|
||||
return [buildingLocalX, buildingLocalZ]
|
||||
}
|
||||
|
||||
const localPositionToToolLocal = (
|
||||
position: [number, number, number],
|
||||
): [number, number, number] => {
|
||||
if (
|
||||
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
||||
movingNode.parentId
|
||||
) {
|
||||
const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
|
||||
if (parentObj) {
|
||||
const point = parentObj.localToWorld(new THREE.Vector3(...position))
|
||||
if (buildingObj) buildingObj.worldToLocal(point)
|
||||
return [point.x, point.y, point.z]
|
||||
}
|
||||
}
|
||||
|
||||
return position
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const y = event.position[1]
|
||||
|
||||
@@ -263,29 +284,40 @@ export const MoveRoofTool: React.FC<{
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
})
|
||||
// Layer alignment snap on top (top-level stair/roof). Recompute the
|
||||
// world point from the aligned building-local point so it stays correct
|
||||
// under building rotation.
|
||||
const [lx, lz] = alignLocalPoint(
|
||||
const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
|
||||
const [rawLocalX, rawLocalZ] = computeLocal(
|
||||
rawGridX,
|
||||
rawGridZ,
|
||||
y,
|
||||
snappedLocal[0],
|
||||
snappedLocal[1],
|
||||
event.nativeEvent?.altKey === true,
|
||||
)
|
||||
const [gridX, , gridZ] = localToWorldPoint([lx, lz], y)
|
||||
const anchor = dragAnchorRef.current ?? [rawLocalX, rawLocalZ]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
let localX = movingNode.position[0] + (rawLocalX - anchor[0])
|
||||
let localZ = movingNode.position[2] + (rawLocalZ - anchor[1])
|
||||
|
||||
if (alignTopLevel) {
|
||||
const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true)
|
||||
localX = aligned[0]
|
||||
localZ = aligned[1]
|
||||
}
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
|
||||
previousGridPosRef.current = [gridX, gridZ]
|
||||
previousGridPosRef.current = [localX, localZ]
|
||||
|
||||
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
||||
lastLocalPosition = [localX, movingNode.position[1], localZ]
|
||||
const previewPosition = getPreviewPosition(lastLocalPosition)
|
||||
setCursorWorldPos(isFloorPlaced ? previewPosition : [lx, event.localPosition[1], lz])
|
||||
setCursorWorldPos(
|
||||
isFloorPlaced ? previewPosition : localPositionToToolLocal(lastLocalPosition),
|
||||
)
|
||||
|
||||
// Directly update the Three.js mesh — no store update during drag
|
||||
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
||||
@@ -302,26 +334,13 @@ export const MoveRoofTool: React.FC<{
|
||||
// Floor-placed parents (stairs) stay in their committed local frame;
|
||||
// the lifted Y remains presentation-only in the 3D view.
|
||||
useLiveTransforms.getState().set(movingNode.id, {
|
||||
position: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ],
|
||||
position: lastLocalPosition,
|
||||
rotation: pendingRotation,
|
||||
})
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const y = event.position[1]
|
||||
const snappedLocal = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
})
|
||||
const [lx, lz] = alignLocalPoint(
|
||||
snappedLocal[0],
|
||||
snappedLocal[1],
|
||||
event.nativeEvent?.altKey === true,
|
||||
)
|
||||
const [gridX, , gridZ] = localToWorldPoint([lx, lz], y)
|
||||
|
||||
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
||||
const [localX, , localZ] = lastLocalPosition
|
||||
|
||||
useAlignmentGuides.getState().clear()
|
||||
wasCommitted = true
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import * as THREE from 'three'
|
||||
import { type RoofSegmentHit, resolveRoofSegmentHit } from './roof-segment-hit'
|
||||
import { getSurfaceY } from './roof-surface'
|
||||
|
||||
export type RelativeRoofDragTarget = {
|
||||
segment: RoofSegmentNode
|
||||
localX: number
|
||||
localY: number
|
||||
localZ: number
|
||||
hit: RoofSegmentHit
|
||||
}
|
||||
|
||||
type RelativeRoofDragState = {
|
||||
segmentId: string
|
||||
anchor: [number, number]
|
||||
start: [number, number, number]
|
||||
current: [number, number, number]
|
||||
surfaceOffsetY: number
|
||||
}
|
||||
|
||||
export function roofSegmentLocalToBuildingLocal(
|
||||
segmentId: string,
|
||||
position: [number, number, number],
|
||||
): [number, number, number] {
|
||||
const segmentObj = sceneRegistry.nodes.get(segmentId as AnyNodeId)
|
||||
if (!segmentObj) return position
|
||||
|
||||
const point = segmentObj.localToWorld(new THREE.Vector3(...position))
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (buildingObj) buildingObj.worldToLocal(point)
|
||||
return [point.x, point.y, point.z]
|
||||
}
|
||||
|
||||
export function createRelativeRoofDrag(original: {
|
||||
position: [number, number, number]
|
||||
roofSegmentId?: string
|
||||
}): {
|
||||
resolve: (event: RoofEvent) => RelativeRoofDragTarget | null
|
||||
} {
|
||||
let state: RelativeRoofDragState | null = null
|
||||
|
||||
const getPositionInSegment = (
|
||||
position: [number, number, number],
|
||||
fromSegmentId: string | undefined,
|
||||
segment: RoofSegmentNode,
|
||||
): [number, number, number] => {
|
||||
if (fromSegmentId === segment.id) return position
|
||||
|
||||
const fromSegmentObj = fromSegmentId
|
||||
? sceneRegistry.nodes.get(fromSegmentId as AnyNodeId)
|
||||
: null
|
||||
const targetSegmentObj = sceneRegistry.nodes.get(segment.id as AnyNodeId)
|
||||
if (!(fromSegmentObj && targetSegmentObj)) return position
|
||||
|
||||
const point = fromSegmentObj.localToWorld(new THREE.Vector3(...position))
|
||||
targetSegmentObj.worldToLocal(point)
|
||||
return [point.x, point.y, point.z]
|
||||
}
|
||||
|
||||
const getStartPositionForSegment = (
|
||||
segment: RoofSegmentNode,
|
||||
previousState: RelativeRoofDragState | null,
|
||||
): [number, number, number] => {
|
||||
if (previousState) {
|
||||
return getPositionInSegment(previousState.current, previousState.segmentId, segment)
|
||||
}
|
||||
|
||||
if (original.roofSegmentId === segment.id) return original.position
|
||||
|
||||
return getPositionInSegment(original.position, original.roofSegmentId, segment)
|
||||
}
|
||||
|
||||
return {
|
||||
resolve(event) {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return null
|
||||
|
||||
if (!state || state.segmentId !== hit.segment.id) {
|
||||
const start = getStartPositionForSegment(hit.segment, state)
|
||||
state = {
|
||||
segmentId: hit.segment.id,
|
||||
anchor: [hit.localX, hit.localZ],
|
||||
start,
|
||||
current: start,
|
||||
surfaceOffsetY: start[1] - getSurfaceY(start[0], start[2], hit.segment),
|
||||
}
|
||||
}
|
||||
|
||||
const localX = state.start[0] + (hit.localX - state.anchor[0])
|
||||
const localZ = state.start[2] + (hit.localZ - state.anchor[1])
|
||||
const localY = getSurfaceY(localX, localZ, hit.segment) + state.surfaceOffsetY
|
||||
state.current = [localX, localY, localZ]
|
||||
return {
|
||||
segment: hit.segment,
|
||||
localX,
|
||||
localY,
|
||||
localZ,
|
||||
hit,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,35 +11,16 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { 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'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import SkylightPreview from './preview'
|
||||
|
||||
function resolveSegmentFromWorldPoint(
|
||||
roof: RoofNode,
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
worldZ: number,
|
||||
state: ReturnType<typeof useScene.getState>,
|
||||
): { segment: RoofSegmentNode; localX: number; localY: number; localZ: number } | null {
|
||||
const worldPt = new THREE.Vector3(worldX, worldY, worldZ)
|
||||
for (const childId of roof.children ?? []) {
|
||||
const seg = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
|
||||
if (seg?.type !== 'roof-segment') continue
|
||||
const segObj = sceneRegistry.nodes.get(seg.id)
|
||||
if (!segObj) continue
|
||||
segObj.updateWorldMatrix(true, false)
|
||||
const local = segObj.worldToLocal(worldPt.clone())
|
||||
if (Math.abs(local.x) <= seg.width / 2 && Math.abs(local.z) <= seg.depth / 2) {
|
||||
return { segment: seg, localX: local.x, localY: local.y, localZ: local.z }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
@@ -81,19 +62,10 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
const skylightObj = sceneRegistry.nodes.get(node.id)
|
||||
if (skylightObj) skylightObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (buildingObj) {
|
||||
const v = new THREE.Vector3(wx, wy, wz)
|
||||
buildingObj.worldToLocal(v)
|
||||
return [v.x, v.y, v.z]
|
||||
}
|
||||
return [wx, wy, wz]
|
||||
}
|
||||
|
||||
let lastSnapX = 0
|
||||
let lastSnapZ = 0
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
// Resolve which segment the cursor is over, then derive the same
|
||||
// preview transform stack the placement tool uses (`skylight/tool.tsx`):
|
||||
@@ -103,20 +75,22 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
// same via its `if (!hit) return` guard.
|
||||
const updateFromHit = (event: RoofEvent) => {
|
||||
const roof = event.node as RoofNode
|
||||
const hit = resolveRoofSegmentHit(
|
||||
roof,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) {
|
||||
setHasHit(false)
|
||||
return false
|
||||
}
|
||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
lastTarget = target
|
||||
const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((roof.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2]))
|
||||
setPreviewYaw((roof.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||
setPreviewPos(
|
||||
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||
target.localX,
|
||||
target.localY,
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
setHasHit(true)
|
||||
return true
|
||||
}
|
||||
@@ -139,19 +113,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
const roof = event.node as RoofNode
|
||||
const st = useScene.getState()
|
||||
|
||||
const hit = resolveSegmentFromWorldPoint(
|
||||
roof,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
st,
|
||||
)
|
||||
if (!hit) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
|
||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const finalRotation = original.rotation
|
||||
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
@@ -166,7 +133,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: targetSegmentId,
|
||||
parentId: targetSegmentId,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
rotation: finalRotation,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
|
||||
@@ -4,17 +4,19 @@ import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
type SolarPanelNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
|
||||
// MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding
|
||||
@@ -86,27 +88,18 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
const panelObj = sceneRegistry.nodes.get(node.id)
|
||||
if (panelObj) panelObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (buildingObj) {
|
||||
const v = new THREE.Vector3(wx, wy, wz)
|
||||
buildingObj.worldToLocal(v)
|
||||
return [v.x, v.y, v.z]
|
||||
}
|
||||
return [wx, wy, wz]
|
||||
}
|
||||
|
||||
let lastSnapX = 0
|
||||
let lastSnapZ = 0
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const updateGhost = (event: RoofEvent) => {
|
||||
const wx = event.position[0]
|
||||
const wy = event.position[1]
|
||||
const wz = event.position[2]
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
const sz = Math.round(target.localZ * 20) / 20
|
||||
if (sx !== lastSnapX || sz !== lastSnapZ) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapX = sx
|
||||
@@ -119,35 +112,32 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
// because analytical normals are computed in segment-local space
|
||||
// and the yaw is applied explicitly, avoiding any world-vs-local
|
||||
// normal mismatch.
|
||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||
if (!hit) return
|
||||
|
||||
const segLocalNormal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(segLocalNormal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||
setPreviewPos(
|
||||
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||
target.localX,
|
||||
target.localY,
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
setHasHit(true)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
const roof = event.node as RoofNode
|
||||
const st = useScene.getState()
|
||||
|
||||
const hit = resolveRoofSegmentHit(
|
||||
roof,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
|
||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
|
||||
// Compute segment-local normal for the committed node so the
|
||||
// renderer's surfaceQuat + outer segment.rotation compose to
|
||||
// the same world orientation the ghost showed.
|
||||
const segLocalNormal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
position: original.position,
|
||||
@@ -161,7 +151,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: targetSegmentId,
|
||||
parentId: targetSegmentId,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
rotation: original.rotation,
|
||||
// Segment-local normal — must stay consistent with getAnalyticalNormal
|
||||
// semantics so the renderer's surfaceQuat is in the correct frame.
|
||||
|
||||
@@ -4,17 +4,19 @@ import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
type TurbineVentNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import TurbineVentPreview from './preview'
|
||||
|
||||
@@ -54,48 +56,39 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||
if (ventObj) ventObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
const v = new THREE.Vector3(wx, wy, wz)
|
||||
buildingObj.worldToLocal(v)
|
||||
return [v.x, v.y, v.z]
|
||||
}
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
const roofDrag = createRelativeRoofDrag(original)
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const wx = event.position[0]
|
||||
const wy = event.position[1]
|
||||
const wz = event.position[2]
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
const sz = Math.round(target.localZ * 20) / 20
|
||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||
if (!hit) return
|
||||
|
||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||
setPreviewPos(
|
||||
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||
target.localX,
|
||||
target.localY,
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (!target) return
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
const st = useScene.getState()
|
||||
|
||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
@@ -119,7 +112,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: targetSegmentId,
|
||||
parentId: targetSegmentId,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
rotation: original.rotation,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
|
||||
@@ -86,6 +86,24 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
let currentWallId: string | null = movingWindowNode.parentId
|
||||
let dragAnchor: {
|
||||
wallId: string
|
||||
rawX: number
|
||||
rawY: number
|
||||
startX: number
|
||||
startY: number
|
||||
} | null = null
|
||||
let lastTarget: {
|
||||
wallNode: WallEvent['node']
|
||||
wallId: string
|
||||
side: WindowNode['side']
|
||||
itemRotation: number
|
||||
cursorRotation: number
|
||||
clampedX: number
|
||||
clampedY: number
|
||||
valid: boolean
|
||||
event: WallEvent
|
||||
} | null = null
|
||||
|
||||
const markWallDirty = (wallId: string | null) => {
|
||||
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||
@@ -140,7 +158,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
const resolveMoveTarget = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
hideCursor()
|
||||
@@ -153,40 +171,35 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const rawLocalX = event.localPosition[0]
|
||||
const rawLocalY = event.localPosition[1]
|
||||
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
||||
dragAnchor = {
|
||||
wallId: event.node.id,
|
||||
rawX: rawLocalX,
|
||||
rawY: rawLocalY,
|
||||
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
|
||||
startY:
|
||||
event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY),
|
||||
}
|
||||
}
|
||||
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
||||
const targetLocalY = snapToHalf(dragAnchor.startY + (rawLocalY - dragAnchor.rawY))
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
rawLocalX: targetLocalX,
|
||||
width: movingWindowNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
targetLocalY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
const prevWallId = currentWallId
|
||||
currentWallId = event.node.id
|
||||
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
useLiveTransforms.getState().set(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: itemRotation,
|
||||
})
|
||||
|
||||
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
||||
markWallDirtyThrottled(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
@@ -196,17 +209,62 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
return {
|
||||
wallNode: event.node,
|
||||
wallId: event.node.id,
|
||||
side,
|
||||
itemRotation,
|
||||
cursorRotation,
|
||||
clampedX,
|
||||
clampedY,
|
||||
valid,
|
||||
event,
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
||||
if (currentWallId !== target.wallId) {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [target.clampedX, target.clampedY, 0],
|
||||
rotation: [0, target.itemRotation, 0],
|
||||
side: target.side,
|
||||
parentId: target.wallId,
|
||||
wallId: target.wallId,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = target.wallId
|
||||
} else {
|
||||
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
||||
if (windowMesh) {
|
||||
windowMesh.position.set(target.clampedX, target.clampedY, 0)
|
||||
windowMesh.rotation.set(0, target.itemRotation, 0)
|
||||
windowMesh.updateMatrixWorld(true)
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().set(movingWindowNode.id, {
|
||||
position: [target.clampedX, target.clampedY, 0],
|
||||
rotation: target.itemRotation,
|
||||
})
|
||||
markWallDirtyThrottled(target.wallId)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
target.wallNode,
|
||||
target.clampedX,
|
||||
target.clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
getSlabElevation(target.event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
target.cursorRotation,
|
||||
target.valid,
|
||||
)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
applyPreview(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
@@ -219,73 +277,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: movingWindowNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
if (currentWallId !== event.node.id) {
|
||||
// Wall changed mid-move: must updateNode to reparent
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = event.node.id
|
||||
} else {
|
||||
// Same wall: update Three.js mesh directly to avoid store churn
|
||||
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
||||
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
||||
if (windowMesh) {
|
||||
windowMesh.position.set(clampedX, clampedY, 0)
|
||||
windowMesh.rotation.set(0, itemRotation, 0)
|
||||
windowMesh.updateMatrixWorld(true)
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().set(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: itemRotation,
|
||||
})
|
||||
markWallDirtyThrottled(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) return
|
||||
lastTarget = target
|
||||
applyPreview(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
@@ -295,34 +290,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: movingWindowNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
if (!valid) return
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
|
||||
let placedId: string
|
||||
|
||||
@@ -341,13 +310,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
|
||||
const node = WindowNode.parse({
|
||||
...cloned,
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
position: [target.clampedX, target.clampedY, 0],
|
||||
rotation: [0, target.itemRotation, 0],
|
||||
side: target.side,
|
||||
wallId: target.wallId,
|
||||
parentId: target.wallId,
|
||||
})
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
||||
placedId = node.id
|
||||
} else {
|
||||
// Move mode: restore original (clean baseline) + resume + updateNode
|
||||
@@ -363,21 +332,21 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
position: [target.clampedX, target.clampedY, 0],
|
||||
rotation: [0, target.itemRotation, 0],
|
||||
side: target.side,
|
||||
parentId: target.wallId,
|
||||
wallId: target.wallId,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
if (original.parentId && original.parentId !== event.node.id) {
|
||||
if (original.parentId && original.parentId !== target.wallId) {
|
||||
markWallDirty(original.parentId)
|
||||
}
|
||||
placedId = movingWindowNode.id
|
||||
}
|
||||
|
||||
markWallDirty(event.node.id)
|
||||
markWallDirty(target.wallId)
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
@@ -391,6 +360,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const onWallLeave = () => {
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
if (isNew) return // No original to restore for duplicates
|
||||
// Move mode: restore to original position while off-wall
|
||||
if (currentWallId && currentWallId !== original.parentId) {
|
||||
|
||||
Reference in New Issue
Block a user