170 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Scene bounds in the X/Z plane.
*
* Used by the auto-frame hook to fit the camera onto a freshly loaded scene
* (see `../hooks/use-auto-frame`). The hook subscribes to the core scene
* store and, when `nodes` transitions from empty → non-empty, fires a
* `camera-controls:fit-scene` event on the core event bus carrying the
* computed bounds.
*
* This module contains no rendering code: it only walks the flat-dict node
* tree and derives an axis-aligned bounding box on the XZ (plan) plane.
*/
import type { AnyNode } from '@pascal-app/core/schema'
export type SceneBoundsXZ = {
/** Min [x, z] in world units (meters). */
min: [number, number]
/** Max [x, z] in world units (meters). */
max: [number, number]
/** Center [x, z] = (min + max) / 2. */
center: [number, number]
/** Size [w, d] = max - min. */
size: [number, number]
}
// A very small guard against degenerate bounds (e.g. a single wall of zero length).
const MIN_BOUNDS_EXTENT = 0.0001
function extendPoint(
acc: { minX: number; minZ: number; maxX: number; maxZ: number; hasPoint: boolean },
x: unknown,
z: unknown,
): void {
if (typeof x !== 'number' || typeof z !== 'number') return
if (!(Number.isFinite(x) && Number.isFinite(z))) return
if (x < acc.minX) acc.minX = x
if (x > acc.maxX) acc.maxX = x
if (z < acc.minZ) acc.minZ = z
if (z > acc.maxZ) acc.maxZ = z
acc.hasPoint = true
}
/**
* Compute the axis-aligned XZ bounds of a scene.
*
* Walks every node and extracts 2D footprint points from the fields most
* nodes carry:
* - `start`/`end` → wall and fence endpoints in level coordinates.
* - `polygon` → zone, slab, site-boundary polygons.
* - `position` → building/item/door/window position; uses [x, z] only.
*
* Site-node polygons are intentionally excluded when they are the default
* 30×30 bootstrap polygon — otherwise a brand-new empty scene would frame
* an empty square around the origin. We still include site polygons that
* look intentional (> 4 points, or any point outside the ±15 m default).
*
* Returns `null` if no usable geometry was found.
*/
export function computeSceneBoundsXZ(
nodes: AnyNode[] | Record<string, AnyNode>,
): SceneBoundsXZ | null {
const list: AnyNode[] = Array.isArray(nodes) ? nodes : Object.values(nodes)
if (list.length === 0) return null
const acc = {
minX: Number.POSITIVE_INFINITY,
minZ: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxZ: Number.NEGATIVE_INFINITY,
hasPoint: false,
}
for (const node of list) {
if (!node || typeof node !== 'object') continue
const anyNode = node as unknown as Record<string, unknown>
// Wall / fence endpoints in level coordinates.
const start = anyNode.start as unknown
const end = anyNode.end as unknown
if (Array.isArray(start) && start.length >= 2) extendPoint(acc, start[0], start[1])
if (Array.isArray(end) && end.length >= 2) extendPoint(acc, end[0], end[1])
// Zone / slab polygons (and explicit polygon-shaped site boundaries).
const polygon = anyNode.polygon as unknown
if (Array.isArray(polygon)) {
// Zones/slabs expose a plain array of [x,z] tuples. Site nodes nest the
// points under `polygon.points` (a discriminated PropertyLineData shape).
for (const point of polygon) {
if (Array.isArray(point) && point.length >= 2) {
extendPoint(acc, point[0], point[1])
}
}
} else if (
polygon &&
typeof polygon === 'object' &&
Array.isArray((polygon as { points?: unknown }).points)
) {
// Site nodes only: skip the default bootstrap square so a blank scene
// isn't auto-framed around an empty ±15 m box. Include any other site
// polygon (more than 4 points, or any coordinate beyond the default).
const points = (polygon as { points: unknown[] }).points
if (node.type === 'site' && isDefaultSitePolygon(points)) {
// Skip — default bootstrap polygon.
} else {
for (const point of points) {
if (Array.isArray(point) && point.length >= 2) {
extendPoint(acc, point[0], point[1])
}
}
}
}
// Position on the XZ plane (3D position = [x, y, z]).
const position = anyNode.position as unknown
if (Array.isArray(position) && position.length >= 3) {
extendPoint(acc, position[0], position[2])
}
}
if (!acc.hasPoint) return null
// Ensure a minimum extent so a single-point scene still yields a box.
let minX = acc.minX
let minZ = acc.minZ
let maxX = acc.maxX
let maxZ = acc.maxZ
if (maxX - minX < MIN_BOUNDS_EXTENT) {
const cx = (minX + maxX) / 2
minX = cx - MIN_BOUNDS_EXTENT / 2
maxX = cx + MIN_BOUNDS_EXTENT / 2
}
if (maxZ - minZ < MIN_BOUNDS_EXTENT) {
const cz = (minZ + maxZ) / 2
minZ = cz - MIN_BOUNDS_EXTENT / 2
maxZ = cz + MIN_BOUNDS_EXTENT / 2
}
const centerX = (minX + maxX) / 2
const centerZ = (minZ + maxZ) / 2
return {
min: [minX, minZ],
max: [maxX, maxZ],
center: [centerX, centerZ],
size: [maxX - minX, maxZ - minZ],
}
}
/**
* Matches the `SiteNode` bootstrap polygon defined in
* `packages/core/src/schema/nodes/site.ts` (a 30×30 square at the origin).
* We ignore it so the default scene doesn't "auto-frame" onto an empty box.
*/
function isDefaultSitePolygon(points: unknown[]): boolean {
if (points.length !== 4) return false
const expected: [number, number][] = [
[-15, -15],
[15, -15],
[15, 15],
[-15, 15],
]
for (let i = 0; i < 4; i++) {
const p = points[i]
const e = expected[i]!
if (!Array.isArray(p) || p.length < 2) return false
if (p[0] !== e[0] || p[1] !== e[1]) return false
}
return true
}