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;