import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { Document, NodeIO } from "@gltf-transform/core"; import { bakeNavigationFromCollisionAssets } from "./recast-bake.mjs"; function square(positions, centerX, y, halfExtent) { const x0 = centerX - halfExtent; const x1 = centerX + halfExtent; const z0 = -halfExtent; const z1 = halfExtent; positions.push( x0, y, z0, x0, y, z1, x1, y, z1, x0, y, z0, x1, y, z1, x1, y, z0, ); } test("real tiled Recast export covers Detour polygon 0 and keeps disconnected islands disconnected", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "origin-recast-topology-")); try { const collisionFile = path.join(directory, "two-islands.glb"); const navigationFile = path.join(directory, "two-islands-navigation.glb"); const reportFile = path.join(directory, "two-islands-recast.json"); const positions = []; square(positions, 0, 0, 10); square(positions, 100, 2, 10); const document = new Document(); const buffer = document.createBuffer("two-islands-buffer"); const scene = document.createScene("two-islands-scene"); document.getRoot().setDefaultScene(scene); const position = document.createAccessor("two-islands-position", buffer) .setType("VEC3") .setArray(new Float32Array(positions)); const primitive = document.createPrimitive().setAttribute("POSITION", position); const mesh = document.createMesh("two-islands-mesh").addPrimitive(primitive); scene.addChild(document.createNode("two-islands-node").setMesh(mesh)); await new NodeIO().write(collisionFile, document); const result = await bakeNavigationFromCollisionAssets({ slug: "recast-topology-fixture", collisionAssets: [{ id: "two-islands", role: "collision", path: collisionFile }], settings: { cellSize: 0.3, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50, tileSize: 512, partition: "monotone", forceTiled: true, retainTileIntermediates: false, }, output: navigationFile, reportFile, includeDetourTopology: true, detourRouteProbes: [{ id: "separated-islands", start: [0, 0.2, 0], end: [100, 2.2, 0], maxStartSnapDistance: 3, maxEndSnapDistance: 3, }], }); assert.equal(result.status, "green"); assert.equal(result.detourTopology.detailTriangleCount, result.geometry.sourceTriangles); assert.equal(result.detourTopology.geometryTriangleCount, result.geometry.triangles); assert.ok(result.detourTopology.polygonCount > 0); assert.ok(result.detourTopology.weakComponents.count >= 2); assert.equal(result.geometry.rejectedOutOfBoundsTriangles, 0); const exported = await new NodeIO().read(navigationFile); const topology = exported.getRoot().getExtras().runewakerNavigationTopology; assert.ok(topology.polygons.some((polygon) => polygon[0] === 0), "stable Detour polygon ID 0 must be retained"); const ranges = topology.polygons .filter((polygon) => polygon[7] > 0) .map((polygon) => [polygon[6], polygon[7]]) .sort((left, right) => left[0] - right[0]); let nextTriangle = 0; for (const [firstTriangle, triangleCount] of ranges) { assert.equal(firstTriangle, nextTriangle, "per-polygon detail ranges must be contiguous after bounds filtering"); nextTriangle += triangleCount; } assert.equal(nextTriangle, result.geometry.triangles, "polygon ranges must cover every exported navigation triangle"); const route = result.detourRouteProbes[0]; assert.equal(route.start.found, true); assert.equal(route.end.found, true); assert.equal(route.reachable, false, "independent source surfaces must not gain an inferred connection"); const subsetFile = path.join(directory, "source-anchored-navigation.glb"); const subset = await bakeNavigationFromCollisionAssets({ slug: "recast-anchor-fixture", collisionAssets: [{ role: "collision", path: collisionFile }], settings: result.settings, includeDetourTopology: true, detourAnchorPositions: [{ id: "second-island", position: [100, 2.2, 0], sourcePosition: [1000, 22, 0], evidence: "fixture source table", maximumDistance: 0.5, required: true }], output: subsetFile, reportFile: path.join(directory, "source-anchored-report.json"), }); assert.equal(subset.detourTopology.weakComponents.count, 1); assert.ok(subset.geometry.bounds[0][0] > 90, "unanchored first island must be excluded"); assert.equal(subset.detourTopology.detailTriangleCount, subset.geometry.triangles); const subsetDocument = await new NodeIO().read(subsetFile); const selected = subsetDocument.getRoot().getExtras().runewakerNavigationTopology; assert.equal(selected.polygons[0][0], 0, "retained nonzero source IDs must compact to zero"); assert.equal(selected.polygons[0][6], 0, "retained detail ranges must start at the selected geometry"); assert.equal(selected.sourceAnchorSelection.anchors[0].retained, true); assert.deepEqual(selected.sourceAnchorSelection.anchors[0].sourcePosition, [1000, 22, 0]); assert.equal(selected.sourceAnchorSelection.anchors[0].evidence, "fixture source table"); assert.equal(selected.sourceAnchorSelection.anchors[0].required, true); await assert.rejects(() => bakeNavigationFromCollisionAssets({ slug: "recast-required-anchor-fixture", collisionAssets: [{ role: "collision", path: collisionFile }], settings: result.settings, includeDetourTopology: true, detourAnchorPositions: [{ id: "missing-entry", position: [500, 20, 0], maximumDistance: 0.5, required: true }], output: path.join(directory, "invalid-navigation.glb"), reportFile: path.join(directory, "invalid-report.json"), }), /required source navigation anchor missing-entry is outside/); } finally { await rm(directory, { recursive: true, force: true }); } });