forked from phenom/healer-man
283 lines
13 KiB
JavaScript
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;
|