228 lines
8.2 KiB
TypeScript
228 lines
8.2 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNodeId,
|
|
type DormerNode,
|
|
type RoofNode,
|
|
type RoofSegmentNode,
|
|
sceneRegistry,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { PanelSection, SliderControl } from '@pascal-app/editor'
|
|
import { useMemo } from 'react'
|
|
import { Vector3 } from 'three'
|
|
|
|
/**
|
|
* Position section: X, Z (world-space) and Rotation (world-space)
|
|
* sliders. Owns the read side of the dormer's world transform —
|
|
* confined to a single useMemo so the underlying `updateWorldMatrix`
|
|
* calls don't fire on unrelated re-renders. Also owns the segment-
|
|
* local commits, including the cross-segment reparent case.
|
|
*/
|
|
export function DormerPositionSection({
|
|
node,
|
|
segment,
|
|
roof,
|
|
selectedId,
|
|
previewProp,
|
|
commitProp,
|
|
}: {
|
|
node: DormerNode
|
|
segment: RoofSegmentNode | undefined
|
|
roof: RoofNode | undefined
|
|
selectedId: string
|
|
previewProp: (updates: Partial<DormerNode>) => void
|
|
commitProp: (updates: Partial<DormerNode>) => void
|
|
}) {
|
|
const px = node.position[0]
|
|
const py = node.position[1]
|
|
const pz = node.position[2]
|
|
const nodeRotation = node.rotation
|
|
const segmentId = segment?.id
|
|
const roofChildrenKey = (roof?.children ?? []).join(',')
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: roofChildrenKey is the stable signature of `roof.children`; intentionally omitting `roof` (object identity) in favor of the joined ids.
|
|
const worldXform = useMemo(() => {
|
|
const dormerObj = sceneRegistry.nodes.get(selectedId)
|
|
let worldX = 0
|
|
let worldZ = 0
|
|
let worldRotation = nodeRotation ?? 0
|
|
if (dormerObj) {
|
|
dormerObj.updateWorldMatrix(true, false)
|
|
const localPt = new Vector3(px ?? 0, 0, pz ?? 0)
|
|
const worldPt = localPt.applyMatrix4(dormerObj.matrixWorld)
|
|
worldX = worldPt.x
|
|
worldZ = worldPt.z
|
|
const m = dormerObj.matrixWorld.elements
|
|
worldRotation = Math.atan2(-(m[2] ?? 0), m[0] ?? 1) + (nodeRotation ?? 0)
|
|
}
|
|
|
|
let bounds: { minX: number; maxX: number; minZ: number; maxZ: number } | null = null
|
|
if (roof) {
|
|
const state = useScene.getState()
|
|
let lo_x = Number.POSITIVE_INFINITY
|
|
let hi_x = Number.NEGATIVE_INFINITY
|
|
let lo_z = Number.POSITIVE_INFINITY
|
|
let hi_z = Number.NEGATIVE_INFINITY
|
|
for (const childId of roof.children ?? []) {
|
|
const seg = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
|
|
if (!seg) continue
|
|
const segObj = sceneRegistry.nodes.get(seg.id)
|
|
if (!segObj) continue
|
|
segObj.updateWorldMatrix(true, false)
|
|
const segWorldCenter = new Vector3().applyMatrix4(segObj.matrixWorld)
|
|
const r = Math.hypot(seg.width, seg.depth) / 2
|
|
lo_x = Math.min(lo_x, segWorldCenter.x - r)
|
|
hi_x = Math.max(hi_x, segWorldCenter.x + r)
|
|
lo_z = Math.min(lo_z, segWorldCenter.z - r)
|
|
hi_z = Math.max(hi_z, segWorldCenter.z + r)
|
|
}
|
|
if (Number.isFinite(lo_x)) bounds = { minX: lo_x, maxX: hi_x, minZ: lo_z, maxZ: hi_z }
|
|
}
|
|
return { worldX, worldZ, worldRotation, bounds }
|
|
}, [selectedId, px, py, pz, nodeRotation, segmentId, roofChildrenKey])
|
|
|
|
const worldX_now = worldXform.worldX
|
|
const worldZ_now = worldXform.worldZ
|
|
const worldRotation_now = worldXform.worldRotation
|
|
const worldMinX = worldXform.bounds?.minX ?? worldX_now - 20
|
|
const worldMaxX = worldXform.bounds?.maxX ?? worldX_now + 20
|
|
const worldMinZ = worldXform.bounds?.minZ ?? worldZ_now - 20
|
|
const worldMaxZ = worldXform.bounds?.maxZ ?? worldZ_now + 20
|
|
|
|
const findSegmentForWorldPoint = (
|
|
wx: number,
|
|
wz: number,
|
|
): { segment: RoofSegmentNode; localX: number; localZ: number } | null => {
|
|
const state = useScene.getState()
|
|
const worldPt = new Vector3(wx, 0, wz)
|
|
for (const candidate of Object.values(state.nodes)) {
|
|
if (candidate?.type !== 'roof-segment') continue
|
|
const seg = candidate as RoofSegmentNode
|
|
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, localZ: local.z }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const worldToSegLocal = (
|
|
wx: number,
|
|
wz: number,
|
|
seg: RoofSegmentNode,
|
|
): { localX: number; localZ: number } => {
|
|
const segObj = sceneRegistry.nodes.get(seg.id)
|
|
if (!segObj) return { localX: wx, localZ: wz }
|
|
segObj.updateWorldMatrix(true, false)
|
|
const local = segObj.worldToLocal(new Vector3(wx, 0, wz))
|
|
return { localX: local.x, localZ: local.z }
|
|
}
|
|
|
|
const commitWorldPosition = (newWorldX: number, newWorldZ: number) => {
|
|
if (!segment) return
|
|
const oldWorldRotation = worldRotation_now
|
|
const target = findSegmentForWorldPoint(newWorldX, newWorldZ)
|
|
if (target && target.segment.id !== segment.id) {
|
|
// Moving to a different segment — segment-local Y is meaningless
|
|
// across the change, so reset to 0 and let the new segment's
|
|
// slope-anchored geometry take over.
|
|
const newSegObj = sceneRegistry.nodes.get(target.segment.id)
|
|
let newAncestorWorldY = 0
|
|
if (newSegObj) {
|
|
newSegObj.updateWorldMatrix(true, false)
|
|
const m = newSegObj.matrixWorld.elements
|
|
newAncestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
|
|
}
|
|
const newSegLocalRot = oldWorldRotation - newAncestorWorldY
|
|
commitProp({
|
|
roofSegmentId: target.segment.id,
|
|
parentId: target.segment.id,
|
|
position: [target.localX, 0, target.localZ],
|
|
rotation: newSegLocalRot,
|
|
} as Partial<DormerNode>)
|
|
} else {
|
|
// Same segment — preserve the existing Y so the dormer doesn't
|
|
// snap back to the segment foot when the user only adjusts X/Z.
|
|
const local = worldToSegLocal(newWorldX, newWorldZ, segment)
|
|
commitProp({ position: [local.localX, py ?? 0, local.localZ] })
|
|
}
|
|
}
|
|
|
|
const commitWorldRotation = (newWorldRot: number) => {
|
|
if (!segment) return
|
|
let ancestorWorldY = 0
|
|
const segObj = sceneRegistry.nodes.get(segment.id)
|
|
if (segObj) {
|
|
segObj.updateWorldMatrix(true, false)
|
|
const m = segObj.matrixWorld.elements
|
|
ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
|
|
}
|
|
commitProp({ rotation: newWorldRot - ancestorWorldY })
|
|
}
|
|
|
|
return (
|
|
<PanelSection title="Position">
|
|
<SliderControl
|
|
label="X"
|
|
max={Math.round(worldMaxX * 10) / 10}
|
|
min={Math.round(worldMinX * 10) / 10}
|
|
onChange={(newWorldX) => {
|
|
if (!segment) return
|
|
const local = worldToSegLocal(newWorldX, worldZ_now, segment)
|
|
previewProp({ position: [local.localX, py ?? 0, local.localZ] })
|
|
}}
|
|
onCommit={(newWorldX) => commitWorldPosition(newWorldX, worldZ_now)}
|
|
precision={2}
|
|
restoreOnCommit={false}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round(worldX_now * 100) / 100}
|
|
/>
|
|
<SliderControl
|
|
label="Z"
|
|
max={Math.round(worldMaxZ * 10) / 10}
|
|
min={Math.round(worldMinZ * 10) / 10}
|
|
onChange={(newWorldZ) => {
|
|
if (!segment) return
|
|
const local = worldToSegLocal(worldX_now, newWorldZ, segment)
|
|
previewProp({ position: [local.localX, py ?? 0, local.localZ] })
|
|
}}
|
|
onCommit={(newWorldZ) => commitWorldPosition(worldX_now, newWorldZ)}
|
|
precision={2}
|
|
restoreOnCommit={false}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round(worldZ_now * 100) / 100}
|
|
/>
|
|
<SliderControl
|
|
label="Rotation"
|
|
max={180}
|
|
min={-180}
|
|
onChange={(degrees) => {
|
|
const newWorldRot = (degrees * Math.PI) / 180
|
|
let ancestorWorldY = 0
|
|
if (segment) {
|
|
const segObj = sceneRegistry.nodes.get(segment.id)
|
|
if (segObj) {
|
|
segObj.updateWorldMatrix(true, false)
|
|
const m = segObj.matrixWorld.elements
|
|
ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
|
|
}
|
|
}
|
|
previewProp({ rotation: newWorldRot - ancestorWorldY })
|
|
}}
|
|
onCommit={(degrees) => commitWorldRotation((degrees * Math.PI) / 180)}
|
|
precision={0}
|
|
restoreOnCommit={false}
|
|
step={1}
|
|
unit="°"
|
|
value={Math.round((worldRotation_now * 180) / Math.PI)}
|
|
/>
|
|
</PanelSection>
|
|
)
|
|
}
|