Files
healer-man/scripts/dungeon-pipeline/detour-component-selection.mjs
T
2026-10-07 13:05:04 -04:00

283 lines
13 KiB
JavaScript

const DEFAULT_LIMITS = Object.freeze({
polygons: 50_000,
directedLinks: 100_000,
detailTriangles: 200_000,
surfaceBucketReferences: 2_000_000,
});
function weakComponentData(topology) {
if (!topology || !Array.isArray(topology.polygons) || !Array.isArray(topology.links)) {
throw new Error("Detour component selection requires polygon and link arrays.");
}
const polygonCount = topology.polygons.length;
const parents = new Int32Array(polygonCount);
const sizes = new Uint32Array(polygonCount).fill(1);
const polygonById = new Array(polygonCount);
for (let index = 0; index < polygonCount; index += 1) parents[index] = index;
for (const polygon of topology.polygons) {
const id = polygon[0];
if (!Number.isInteger(id) || id < 0 || id >= polygonCount || polygonById[id]) {
throw new Error(`Detour topology has an invalid or duplicate polygon ID ${id}.`);
}
polygonById[id] = polygon;
}
if (polygonById.some((polygon) => !polygon)) {
throw new Error("Detour polygon IDs must be a contiguous zero-based range.");
}
const rootOf = (value) => {
let root = value;
while (parents[root] !== root) root = parents[root];
while (parents[value] !== value) {
const next = parents[value];
parents[value] = root;
value = next;
}
return root;
};
for (const link of topology.links) {
const [from, to] = link;
if (!Number.isInteger(from) || !Number.isInteger(to)
|| from < 0 || from >= polygonCount || to < 0 || to >= polygonCount) {
throw new Error(`Detour topology link has invalid endpoints ${from} -> ${to}.`);
}
const left = rootOf(from);
const right = rootOf(to);
if (left === right) continue;
if (sizes[left] < sizes[right]) {
parents[left] = right;
sizes[right] += sizes[left];
} else {
parents[right] = left;
sizes[left] += sizes[right];
}
}
for (let id = 0; id < polygonCount; id += 1) parents[id] = rootOf(id);
return { parents, polygonById };
}
function weakComponentSummary(polygonCount, links) {
const { parents } = weakComponentData({
polygons: Array.from({ length: polygonCount }, (_, id) => [id]),
links,
});
const counts = new Map();
for (const component of parents) counts.set(component, (counts.get(component) ?? 0) + 1);
const sorted = [...counts.values()].sort((left, right) => right - left);
return { count: sorted.length, largestPolygonCount: sorted[0] ?? 0, polygonCounts: sorted };
}
function validateAnchor(anchor) {
if (!anchor || typeof anchor.id !== "string" || !anchor.id.trim()) {
throw new Error("Every Detour anchor must have a non-empty ID.");
}
if (!Number.isFinite(anchor.maximumDistance) || anchor.maximumDistance <= 0) {
throw new Error(`Detour anchor ${anchor.id} must provide a positive maximumDistance.`);
}
if (anchor.required !== undefined && typeof anchor.required !== "boolean") {
throw new Error(`Detour anchor ${anchor.id} has an invalid required flag.`);
}
if (anchor.position && (!Array.isArray(anchor.position) || anchor.position.length !== 3
|| !anchor.position.every(Number.isFinite))) {
throw new Error(`Detour anchor ${anchor.id} has an invalid position.`);
}
}
/**
* Keeps only actual weakly connected Detour components that contain a source
* anchor within that anchor's explicit projection limit. Polygon IDs and
* detail triangle ordering are compacted; only original directed Detour links
* whose endpoints survive are copied. No geometric-neighbor links are added.
*/
export function selectDetourAnchorComponents(topology, anchorMappings, {
limits = DEFAULT_LIMITS,
surfaceBucketReferenceCount = null,
} = {}) {
if (!Array.isArray(anchorMappings) || anchorMappings.length === 0) {
throw new Error("Detour component selection requires at least one source anchor.");
}
const activeLimits = { ...DEFAULT_LIMITS, ...limits };
for (const [name, value] of Object.entries(activeLimits)) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`Detour selection limit ${name} must be a positive integer.`);
}
}
const { parents, polygonById } = weakComponentData(topology);
const anchors = anchorMappings.map((anchor) => {
validateAnchor(anchor);
const polygonId = anchor.polygonId;
const distance = anchor.distance;
const mapped = Number.isInteger(polygonId) && polygonId >= 0 && polygonId < polygonById.length;
const accepted = mapped && Number.isFinite(distance) && distance >= 0 && distance <= anchor.maximumDistance;
return {
id: anchor.id,
kind: anchor.kind ?? null,
sourcePosition: anchor.sourcePosition ?? null,
evidence: anchor.evidence ?? null,
required: anchor.required === true,
position: anchor.position ?? null,
sourceSpawnId: anchor.sourceSpawnId ?? null,
sourceFile: anchor.sourceFile ?? null,
maximumDistance: anchor.maximumDistance,
polygonId: mapped ? polygonId : null,
distance: Number.isFinite(distance) && distance >= 0 ? distance : null,
nearestPoint: anchor.nearestPoint ?? null,
componentId: mapped ? parents[polygonId] : null,
retained: accepted,
reason: accepted ? null : mapped
? "nearest-detour-surface-exceeds-source-projection-limit"
: anchor.reason ?? "no-detour-surface-within-source-projection-limit",
};
});
const failedRequiredAnchors = anchors.filter((anchor) => anchor.required && !anchor.retained);
if (failedRequiredAnchors.length) {
throw new Error(
`Required source anchors exceed their Detour projection limits: ${failedRequiredAnchors.map((anchor) => (
`${anchor.id} (${anchor.distance ?? "unresolved"}m > ${anchor.maximumDistance}m)`
)).join(", ")}.`,
);
}
const selectedComponentIds = new Set(
anchors.filter((anchor) => anchor.retained).map((anchor) => anchor.componentId),
);
if (selectedComponentIds.size === 0) {
throw new Error("No source anchors map to Detour surfaces within their explicit projection limits.");
}
const selectedSourcePolygons = topology.polygons.filter(([id]) => selectedComponentIds.has(parents[id]));
const sourceToSelectedPolygonId = new Map();
let nextTriangle = 0;
let polygonFanTriangleCount = 0;
const polygons = selectedSourcePolygons.map((sourcePolygon, polygonId) => {
const [sourceId, tileIndex, tileX, tileY, tileLayer, tilePolygonIndex, , triangleCount, vertexCount] = sourcePolygon;
sourceToSelectedPolygonId.set(sourceId, polygonId);
nextTriangle += triangleCount;
polygonFanTriangleCount += Math.max(0, vertexCount - 2);
return [polygonId, tileIndex, tileX, tileY, tileLayer, tilePolygonIndex, nextTriangle - triangleCount, triangleCount, vertexCount];
});
const links = topology.links
.filter(([from, to]) => sourceToSelectedPolygonId.has(from) && sourceToSelectedPolygonId.has(to))
.map(([from, to, ...details]) => [sourceToSelectedPolygonId.get(from), sourceToSelectedPolygonId.get(to), ...details]);
const selectedIdSet = new Set(sourceToSelectedPolygonId.keys());
const excludedSourcePolygons = topology.polygons.filter(([id]) => !selectedIdSet.has(id));
const excludedSourceLinks = topology.links.filter(([from, to]) => !selectedIdSet.has(from) || !selectedIdSet.has(to));
const selectedComponentRecords = [...selectedComponentIds].map((componentId) => {
const componentPolygons = topology.polygons.filter(([id]) => parents[id] === componentId);
const componentIds = new Set(componentPolygons.map(([id]) => id));
return {
sourceComponentId: componentId,
polygonCount: componentPolygons.length,
detailTriangleCount: componentPolygons.reduce((sum, polygon) => sum + polygon[7], 0),
directedLinkCount: topology.links.filter(([from, to]) => componentIds.has(from) && componentIds.has(to)).length,
anchorIds: anchors.filter((anchor) => anchor.retained && anchor.componentId === componentId).map((anchor) => anchor.id),
};
}).sort((left, right) => right.polygonCount - left.polygonCount);
const outputTopology = {
...topology,
polygonCount: polygons.length,
detailTriangleCount: polygons.reduce((sum, polygon) => sum + polygon[7], 0),
polygonFanTriangleCount,
geometryTriangleCount: polygons.reduce((sum, polygon) => sum + polygon[7], 0),
directedLinkCount: links.length,
tileCount: new Set(polygons.map((polygon) => polygon[1])).size,
weakComponents: weakComponentSummary(polygons.length, links),
polygons,
links,
sourceAnchorSelection: {
schemaVersion: 1,
policy: "retain-complete-detour-weak-components-with-source-anchors-within-explicit-projection-limits",
sourceCoordinatePositionsOnly: true,
noInferredLinks: true,
limits: activeLimits,
anchors,
selected: {
componentCount: selectedComponentIds.size,
polygonCount: polygons.length,
directedLinkCount: links.length,
detailTriangleCount: polygons.reduce((sum, polygon) => sum + polygon[7], 0),
surfaceBucketReferenceCount,
},
selectedComponents: selectedComponentRecords,
excluded: {
weakComponentCount: Math.max(0, topology.weakComponents?.count - selectedComponentIds.size),
polygonCount: excludedSourcePolygons.length,
detailTriangleCount: excludedSourcePolygons.reduce((sum, polygon) => sum + polygon[7], 0),
directedLinkCount: excludedSourceLinks.length,
disposition: "unanchored-components-excluded; not individually proven decorative",
},
},
};
const surfaceReferences = surfaceBucketReferenceCount;
const actualCounts = {
polygons: polygons.length,
directedLinks: links.length,
detailTriangles: outputTopology.detailTriangleCount,
surfaceBucketReferences: surfaceReferences,
};
for (const key of ["polygons", "directedLinks", "detailTriangles"]) {
if (actualCounts[key] > activeLimits[key]) {
throw new Error(`Source-anchored Detour subset exceeds the ${key} budget (${actualCounts[key]} > ${activeLimits[key]}).`);
}
}
if (surfaceReferences !== null && surfaceReferences > activeLimits.surfaceBucketReferences) {
throw new Error(
`Source-anchored Detour subset exceeds the surfaceBucketReferences budget `
+ `(${surfaceReferences} > ${activeLimits.surfaceBucketReferences}).`,
);
}
const retainedAnchorIds = anchors.filter((anchor) => anchor.retained).map((anchor) => anchor.id);
const unmatchedAnchors = anchors.filter((anchor) => !anchor.retained);
return {
topology: outputTopology,
sourceToSelectedPolygonId,
selectedSourcePolygonIds: selectedIdSet,
selectedSourceComponentIds: selectedComponentIds,
summary: {
source: {
polygonCount: topology.polygonCount,
directedLinkCount: topology.directedLinkCount,
detailTriangleCount: topology.detailTriangleCount,
weakComponentCount: topology.weakComponents?.count ?? null,
},
selected: {
componentCount: selectedComponentIds.size,
polygonCount: actualCounts.polygons,
directedLinkCount: actualCounts.directedLinks,
detailTriangleCount: actualCounts.detailTriangles,
surfaceBucketReferenceCount: actualCounts.surfaceBucketReferences,
},
excluded: outputTopology.sourceAnchorSelection.excluded,
limits: activeLimits,
retainedAnchorIds,
unmatchedAnchors,
components: selectedComponentRecords,
},
};
}
export function countSurfaceBucketReferences(positions, indices, bucketSize = 8) {
if (!positions || !indices || positions.length % 3 !== 0 || indices.length % 3 !== 0
|| !Number.isFinite(bucketSize) || bucketSize <= 0) {
throw new Error("Surface bucket counting requires XYZ positions, triangle indices, and positive bucket size.");
}
let references = 0;
for (let offset = 0; offset < indices.length; offset += 3) {
const points = [0, 1, 2].map((corner) => {
const index = indices[offset + corner];
if (!Number.isInteger(index) || index < 0 || index * 3 + 2 >= positions.length) {
throw new Error(`Surface bucket triangle index ${index} is invalid.`);
}
return [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
});
const minCellX = Math.floor(Math.min(...points.map((point) => point[0])) / bucketSize);
const maxCellX = Math.floor(Math.max(...points.map((point) => point[0])) / bucketSize);
const minCellZ = Math.floor(Math.min(...points.map((point) => point[2])) / bucketSize);
const maxCellZ = Math.floor(Math.max(...points.map((point) => point[2])) / bucketSize);
references += (maxCellX - minCellX + 1) * (maxCellZ - minCellZ + 1);
if (!Number.isSafeInteger(references)) throw new Error("Surface bucket reference count is not a safe integer.");
}
return references;
}
export const DETOUR_COMPONENT_SELECTION_LIMITS = DEFAULT_LIMITS;