94 lines
3.0 KiB
TypeScript
94 lines
3.0 KiB
TypeScript
import {
|
||
type AnyNodeId,
|
||
getRoofSegmentSurfaceY,
|
||
type RoofNode,
|
||
type RoofSegmentNode,
|
||
sceneRegistry,
|
||
useScene,
|
||
} from '@pascal-app/core'
|
||
import * as THREE from 'three'
|
||
|
||
const worldPoint = new THREE.Vector3()
|
||
|
||
export type RoofSegmentHit = {
|
||
segment: RoofSegmentNode
|
||
localX: number
|
||
localY: number
|
||
localZ: number
|
||
}
|
||
|
||
/**
|
||
* Resolve which roof-segment the user clicked. Used by every placement
|
||
* tool that drops a new node onto a roof (box-vent, ridge-vent,
|
||
* chimney, solar-panel, skylight, dormer).
|
||
*
|
||
* The hip/gable case puts every slope at the same roof origin and
|
||
* differs them only by `rotation-y`. After `worldToLocal`, the hit
|
||
* point's (x, z) lies inside *every* segment's axis-aligned half-
|
||
* extents, so a naive first-match returns the wrong slope (typically
|
||
* segments[0]). We instead score each candidate by
|
||
* `|localY − getRoofSegmentSurfaceY(localX, localZ)|` and pick the
|
||
* smallest — the slope the user actually clicked is the one whose
|
||
* sloped surface passes through the hit point.
|
||
*
|
||
* - Overhang is included in the bbox filter because the visible
|
||
* merged-roof mesh extends past `width/2` by the overhang on each
|
||
* side; without it, clicks on the eave bands produced `null`.
|
||
*
|
||
* - Fallback: if no segment passes the bbox filter (clicked beyond
|
||
* every outer overhang, or registry is stale), return the first
|
||
* segment with the click projected into its frame — matches the
|
||
* legacy "always commit somewhere" behaviour.
|
||
*
|
||
* Returns null only if the roof has zero registered segments.
|
||
*/
|
||
export function resolveRoofSegmentHit(
|
||
roof: RoofNode,
|
||
wx: number,
|
||
wy: number,
|
||
wz: number,
|
||
): RoofSegmentHit | null {
|
||
worldPoint.set(wx, wy, wz)
|
||
const state = useScene.getState()
|
||
let firstSegment: { seg: RoofSegmentNode; segObj: THREE.Object3D } | null = null
|
||
let best: { hit: RoofSegmentHit; score: number } | null = null
|
||
|
||
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(worldPoint.clone())
|
||
|
||
if (!firstSegment) firstSegment = { seg, segObj }
|
||
|
||
const overhang = seg.overhang ?? 0
|
||
const halfW = seg.width / 2 + overhang
|
||
const halfD = seg.depth / 2 + overhang
|
||
if (Math.abs(local.x) <= halfW && Math.abs(local.z) <= halfD) {
|
||
const surfaceY = getRoofSegmentSurfaceY(seg, local.x, local.z)
|
||
const score = Math.abs(local.y - surfaceY)
|
||
if (!best || score < best.score) {
|
||
best = {
|
||
hit: { segment: seg, localX: local.x, localY: local.y, localZ: local.z },
|
||
score,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (best) return best.hit
|
||
|
||
if (firstSegment) {
|
||
const local = firstSegment.segObj.worldToLocal(worldPoint.clone())
|
||
return {
|
||
segment: firstSegment.seg,
|
||
localX: local.x,
|
||
localY: local.y,
|
||
localZ: local.z,
|
||
}
|
||
}
|
||
return null
|
||
}
|