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