165 lines
5.5 KiB
TypeScript
165 lines
5.5 KiB
TypeScript
import {
|
|
type AnyNodeId,
|
|
getRoofSegmentWallFaces,
|
|
getScaledDimensions,
|
|
type ItemNode,
|
|
type RoofNode,
|
|
type RoofSegmentNode,
|
|
type RoofSegmentWallFace,
|
|
type RoofWallFaceId,
|
|
sceneRegistry,
|
|
segmentPointToRoofWallFace,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import * as THREE from 'three'
|
|
|
|
const worldPoint = new THREE.Vector3()
|
|
const worldNormal = new THREE.Vector3()
|
|
const localPoint = new THREE.Vector3()
|
|
const localNormal = new THREE.Vector3()
|
|
const inverseMatrix = new THREE.Matrix4()
|
|
|
|
export type RoofWallHit = {
|
|
segment: RoofSegmentNode
|
|
face: RoofSegmentWallFace
|
|
/** Face coords of the hit (u along the face, v above the segment base). */
|
|
u: number
|
|
v: number
|
|
}
|
|
|
|
/** Pointer hits more than this far off the wall plane are not wall hits. */
|
|
const PLANE_TOLERANCE = 0.06
|
|
/** Reject faces whose normal disagrees with the hit normal (slope / soffit). */
|
|
const NORMAL_ALIGNMENT = 0.7
|
|
/** A wall face is vertical; slope faces on low pitches have |ny| ≫ 0. */
|
|
const MAX_NORMAL_Y = 0.4
|
|
|
|
/**
|
|
* Resolve a pointer hit on a roof to one of its segments' vertical wall
|
|
* faces (base walls under the roof + the coplanar gable/shed/gambrel end
|
|
* faces). Counterpart of `resolveRoofSegmentHit`, which resolves to the
|
|
* sloped top surface instead.
|
|
*
|
|
* `normal` must be the raw `NodeEvent.normal` (hit-object-local) together
|
|
* with the `object` it came from — roof events can originate from the
|
|
* merged-roof mesh (roof-local frame) or a painted segment mesh
|
|
* (segment-local frame), so the normal is normalised through world space
|
|
* here instead of trusting the event frame.
|
|
*
|
|
* Lives in `@pascal-app/editor` because both the kind-owned door/window
|
|
* tools (in `@pascal-app/nodes`, which depends on editor) and the item
|
|
* placement coordinator (in editor itself) consume it.
|
|
*/
|
|
export function resolveRoofWallHit(
|
|
roof: RoofNode,
|
|
position: [number, number, number],
|
|
normal: [number, number, number] | undefined,
|
|
object: THREE.Object3D | undefined,
|
|
): RoofWallHit | null {
|
|
if (!normal || !object) return null
|
|
|
|
worldPoint.set(position[0], position[1], position[2])
|
|
worldNormal.set(normal[0], normal[1], normal[2])
|
|
object.updateWorldMatrix(true, false)
|
|
worldNormal.transformDirection(object.matrixWorld)
|
|
|
|
const state = useScene.getState()
|
|
let best: { hit: RoofWallHit; score: number } | null = null
|
|
|
|
for (const childId of roof.children ?? []) {
|
|
const segment = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
|
|
if (segment?.type !== 'roof-segment') continue
|
|
const segObj = sceneRegistry.nodes.get(segment.id)
|
|
if (!segObj) continue
|
|
segObj.updateWorldMatrix(true, false)
|
|
|
|
localPoint.copy(worldPoint)
|
|
segObj.worldToLocal(localPoint)
|
|
inverseMatrix.copy(segObj.matrixWorld).invert()
|
|
localNormal.copy(worldNormal).transformDirection(inverseMatrix)
|
|
|
|
if (Math.abs(localNormal.y) > MAX_NORMAL_Y) continue
|
|
|
|
for (const face of getRoofSegmentWallFaces(segment)) {
|
|
const alignment =
|
|
localNormal.x * face.normal[0] +
|
|
localNormal.y * face.normal[1] +
|
|
localNormal.z * face.normal[2]
|
|
if (alignment < NORMAL_ALIGNMENT) continue
|
|
|
|
const { u, v, dist } = segmentPointToRoofWallFace(segment, face.id, [
|
|
localPoint.x,
|
|
localPoint.y,
|
|
localPoint.z,
|
|
])
|
|
if (Math.abs(dist) > PLANE_TOLERANCE) continue
|
|
if (u < -PLANE_TOLERANCE || u > face.length + PLANE_TOLERANCE) continue
|
|
if (v < -PLANE_TOLERANCE) continue
|
|
|
|
const score = Math.abs(dist)
|
|
if (!best || score < best.score) {
|
|
best = { hit: { segment, face, u, v }, score }
|
|
}
|
|
}
|
|
}
|
|
|
|
return best?.hit ?? null
|
|
}
|
|
|
|
/**
|
|
* Overlap guard for nodes sharing a roof-segment wall face — the
|
|
* roof-host analogue of `hasWallChildOverlap`. Hosted children store
|
|
* FACE-LOCAL coords + an explicit `roofFace`, so siblings compare
|
|
* directly: doors/windows are center-anchored in v, wall items
|
|
* bottom-anchored.
|
|
*/
|
|
export function hasRoofFaceChildOverlap(
|
|
segment: RoofSegmentNode,
|
|
faceId: RoofWallFaceId,
|
|
u: number,
|
|
v: number,
|
|
width: number,
|
|
height: number,
|
|
ignoreId?: string,
|
|
): boolean {
|
|
const nodes = useScene.getState().nodes
|
|
const newLeft = u - width / 2
|
|
const newRight = u + width / 2
|
|
const newBottom = v - height / 2
|
|
const newTop = v + height / 2
|
|
|
|
for (const childId of segment.children ?? []) {
|
|
if (childId === ignoreId) continue
|
|
const child = nodes[childId as AnyNodeId]
|
|
if (!child) continue
|
|
if ((child as { roofFace?: RoofWallFaceId }).roofFace !== faceId) continue
|
|
const position = (child as { position?: [number, number, number] }).position
|
|
if (!position) continue
|
|
|
|
let childW: number
|
|
let childBottom: number
|
|
let childTop: number
|
|
if (child.type === 'door' || child.type === 'window') {
|
|
const opening = child as { width: number; height: number }
|
|
childW = opening.width
|
|
childBottom = position[1] - opening.height / 2
|
|
childTop = position[1] + opening.height / 2
|
|
} else if (child.type === 'item') {
|
|
const item = child as ItemNode
|
|
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
|
|
const [w, h] = getScaledDimensions(item)
|
|
childW = w
|
|
// Items anchor position[1] at their bottom edge.
|
|
childBottom = position[1]
|
|
childTop = position[1] + h
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
const xOverlap = newLeft < position[0] + childW / 2 && newRight > position[0] - childW / 2
|
|
const yOverlap = newBottom < childTop && newTop > childBottom
|
|
if (xOverlap && yOverlap) return true
|
|
}
|
|
return false
|
|
}
|