From 4bac1571cd35c9a4f575d0e04099dd9650b53a47 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:48:03 -0400 Subject: [PATCH 1/2] feat-add-navigation-mesh --- .claude/skills/jgengine-world/api.md | 18 +++ packages/core/src/meta/changelog.ts | 1 + packages/core/src/nav/navMesh.test.ts | 31 ++++++ packages/core/src/nav/navMesh.ts | 152 ++++++++++++++++++++++++++ packages/core/src/world.ts | 9 ++ scripts/export-manifest.json | 1 + 6 files changed, 212 insertions(+) create mode 100644 packages/core/src/nav/navMesh.test.ts create mode 100644 packages/core/src/nav/navMesh.ts diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md index 714c00d86..cb568e23b 100644 --- a/.claude/skills/jgengine-world/api.md +++ b/.claude/skills/jgengine-world/api.md @@ -754,6 +754,17 @@ - `slopeStepCost` (function): function slopeStepCost(field: { sampleHeight(x: number, z: number): number }, weight = DEFAULT_SLOPE_STEP_WEIGHT): (from: NavPoint, to: NavPoint) => number — `FindPathOptions.stepCost` factory that penalizes steep terrain: cost is `1 + weight * |Δheight| / horizontalDistance`, so with the default weight a 45° slope roughly doubles the step cost. - `smoothPath` (function): function smoothPath(grid: NavGrid, points: readonly NavPoint[]): NavPoint[] — Remove waypoints the mover can skip because it has clear line-of-sight past them. +## @jgengine/core/nav/navMesh + +- `NavMeshAdjacency` (interface): interface NavMeshAdjacency — Neighbor relationship for one navigation polygon. +- `NavMeshData` (interface): interface NavMeshData — Serializable polygon navigation mesh data. +- `NavMeshLink` (interface): interface NavMeshLink — Explicit traversable connection between two navigation polygons. +- `NavMeshPath` (interface): interface NavMeshPath — Route points and polygons selected through a navigation mesh. +- `buildNavAdjacency` (function): function buildNavAdjacency(mesh: NavMeshData): NavMeshAdjacency[] — Build polygon adjacency from shared edges and explicit off-mesh links. +- `closestPoint` (function): function closestPoint(mesh: NavMeshData, point: Vec3): Vec3 | null — Return the closest point on the mesh surface, or null for an empty mesh. +- `findPath` (function): function findPath(mesh: NavMeshData, from: Vec3, to: Vec3): NavMeshPath | null — A* over polygon centers, followed by deterministic visibility string-pulling. +- `raycastNav` (function): function raycastNav(mesh: NavMeshData, from: Vec3, to: Vec3): boolean — True when the segment remains over walkable polygons. + ## @jgengine/core/nav/pathFollow - `HeightSampler` (interface): interface HeightSampler — ⚠ undocumented @@ -2035,6 +2046,10 @@ - `MusicTheme` (interface): interface MusicTheme — A through-composed, looping music track. `events` need not be sorted; the director schedules them ahead against a fixed anchor so loops are seamless. - `NOCLIP_FLIGHT_TUNING` (const): const NOCLIP_FLIGHT_TUNING: FreeFlightTuning — Preset for noclip — weightless, noclips, yaw-relative with independent vertical. - `NavGrid` (interface): interface NavGrid — ⚠ undocumented +- `NavMeshAdjacency` (interface): interface NavMeshAdjacency — Neighbor relationship for one navigation polygon. +- `NavMeshData` (interface): interface NavMeshData — Serializable polygon navigation mesh data. +- `NavMeshLink` (interface): interface NavMeshLink — Explicit traversable connection between two navigation polygons. +- `NavMeshPath` (interface): interface NavMeshPath — Route points and polygons selected through a navigation mesh. - `NavPoint` (type): type NavPoint = readonly [number, number] — ⚠ undocumented - `NoiseFieldConfig` (interface): interface NoiseFieldConfig — Configuration for {@link noiseField}: seed, amplitude, and fractal noise shaping. - `NoiseVoice` (interface): interface NoiseVoice — A filtered white-noise burst — impacts, whooshes, breath, crackle. Realised from a shared 1s noise buffer at a randomised playback rate and start offset, decaying exponentially to silence at `duration * decay`. @@ -2329,6 +2344,7 @@ - `budgetWarning` (function): function budgetWarning(coverage: ScatterCoverage): string — The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent" budget signal from #1112. - `buildContextMenu` (function): function buildContextMenu(input: BuildContextMenuInput): ContextMenu | null — Assemble a menu from a target's catalog verbs; null when the target lists none. - `buildJunctionSurface` (function): function buildJunctionSurface(junction: { x: number; z: number }, approaches: readonly JunctionApproach[], sampleHeight: (x: number, z: number) => number, options: JunctionGeometryOptions = {}): RoadRibbon — Weld one triangulated junction surface onto the corner vertices its incident ribbons END at (from {@link trimPathAtJunctions}). Corners are grouped by approach (so unequal widths cannot interleave a neighbour between a mouth pair), ordered around the node, and bridged as follows: +- `buildNavAdjacency` (function): function buildNavAdjacency(mesh: NavMeshData): NavMeshAdjacency[] — Build polygon adjacency from shared edges and explicit off-mesh links. - `buildRoadRibbon` (function): function buildRoadRibbon(path: readonly RoadPoint[], width: number, sampleHeight: (x: number, z: number) => number, options: RoadRibbonOptions = {}): RoadRibbon — Triangulate a road centerline into a ground-draped ribbon mesh: the polyline is subdivided, each vertex is offset half a `width` along the local perpendicular, and every vertex sits at `sampleHeight(x, z) + elevation`. Pure geometry — the shell (or any renderer) turns the result into a mesh, and tests can assert on it directly. - `buildTrimmedIntersections` (function): function buildTrimmedIntersections(streets: readonly IntersectionStreet[], junctions: readonly RoadJunctionInput[], sampleHeight: (x: number, z: number) => number, options: JunctionGeometryOptions = {}): TrimmedIntersections — Trim a set of streets against a set of junctions and weld the crossing surfaces in one call — the ergonomic entry the shell/playground consume for meshing. - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`. @@ -2341,6 +2357,7 @@ - `circleFormation` (function): function circleFormation(options: CircleFormationOptions): FormationSlotGenerator — An evenly spaced ring around the destination — a guard cordon, a huddle, or a surround. Slot 0 sits `startAngle` from forward; slots advance evenly around the circle. - `clampToMinimapEdge` (function): function clampToMinimapEdge(point: MinimapPoint, size: number): { x: number; y: number } — Clamp a projected point to the minimap edge, preserving direction (edge markers). - `clearanceZonesFrom` (function): function clearanceZonesFrom(doc: SceneDocumentLike, options: ClearanceOptions = {}): AvoidZone[] — Point-pad clearance **discs** from a document's markers/volumes — the terrain-flatten set (spawns, plots, POIs get a level pad). A marker/volume contributes a disc when it carries `meta.clearance` or its kind is in `kinds`. Paths are *not* included (they render draped, never flattened — see {@link clearanceMasksFrom} for their foliage corridor). Pass `ids`/`kinds` to scope it. +- `closestPoint` (function): function closestPoint(mesh: NavMeshData, point: Vec3): Vec3 | null — Return the closest point on the mesh surface, or null for an empty mesh. - `collectAuthoredTriggers` (function): function collectAuthoredTriggers(document: SceneDocumentLike): AuthoredTrigger[] — Collect every authored trigger on a document's markers and volumes. Pure — no runtime state. Action params use the live {@link registerTriggerAction} registry when present. - `combineGravity` (function): function combineGravity(fields: readonly GravityField[]): GravityField — Adds several gravity sources into one field. - `command` (function): function command(name: string, input?: unknown): PromptCommand — ⚠ undocumented @@ -2519,6 +2536,7 @@ - `quarterTurnsToRotationY` (function): function quarterTurnsToRotationY(quarterTurns: number): number — Maps 0–3 quarter turns onto radians for ghost/commit rotation. - `rain` (function): function rain(config: RainEnvironmentConfig = {}): RainEnvironmentDescriptor — Declares a rainfall weather effect for `environment()` — area, density, speed, wind, and drop width/opacity. - `raiseAlert` (function): function raiseAlert(state: SpawnDirectorState, amount: number): SpawnDirectorState — ⚠ undocumented +- `raycastNav` (function): function raycastNav(mesh: NavMeshData, from: Vec3, to: Vec3): boolean — True when the segment remains over walkable polygons. - `readNamedSockets` (function): function readNamedSockets(root: ModelNode, pattern: RegExp = SOCKET_PATTERN): ModelSocket[] — Depth-first collect every socket-named node's local offset, sorted by descending Y then ascending X so socket indices are stable across loads (top first, left-to-right). Empty when the model tags none — callers then fall back to computed offsets. Pass a custom `pattern` for a bespoke naming convention. - `readScatterPalette` (function): function readScatterPalette(meta: Record | undefined): ScatterPaletteEntry[] — Parses a scatter region's palette from meta: a weighted `palette` array, else a single `item`. - `readScatterRules` (function): function readScatterRules(path: ScenePathLike): ScatterRegionRules | null — The path's scatter rules with defaults filled in; null for non-scatter paths. diff --git a/packages/core/src/meta/changelog.ts b/packages/core/src/meta/changelog.ts index 57ef41162..3db3a9069 100644 --- a/packages/core/src/meta/changelog.ts +++ b/packages/core/src/meta/changelog.ts @@ -18,6 +18,7 @@ export const CHANGELOG: Record = { "`presence.sync` returns `{ pose, lastSeenAt, displaced }` instead of `null`, and `presence.leave` marks the row revoked instead of deleting it (the reaper collects it once idle). A caller that ignored the return value is unaffected.", ], added: [ + "Polygon navigation meshes — `NavMeshData`, adjacency building, polygon A* paths, closest-point, and nav raycasts (`@jgengine/core/nav/navMesh`).", "`createPerception` (`@jgengine/core/sensor/perception`) tracks sight, sound, and damage stimuli per observer, with cone/range/occlusion checks, linearly decaying memory, retuning, and snapshot/restore.", "`syncWorldColliders(backend, ctx)` mirrors static scene-object colliders and the sampled ground heightfield into a physics backend, updating on object spawn, despawn, and pose changes.", "Physics-backed player movement — `defineGame({ physics: { backend } })` installs the backend's `afterMovement` simulation stage and makes the shell/headless walk controller use a capsule for gravity, shapecast collision, slopes, and step-up, while retaining the existing input and impulse feel.", diff --git a/packages/core/src/nav/navMesh.test.ts b/packages/core/src/nav/navMesh.test.ts new file mode 100644 index 000000000..24a5d4abd --- /dev/null +++ b/packages/core/src/nav/navMesh.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { buildNavAdjacency, closestPoint, findPath, raycastNav, type NavMeshData } from "@jgengine/core/nav/navMesh"; + +const lMesh: NavMeshData = { + verts: [0, 0, 0, 2, 0, 0, 2, 0, 1, 1, 0, 1, 1, 0, 3, 0, 0, 3], + polys: [[0, 1, 2, 3], [3, 2, 4, 5]], + links: [], +}; + +describe("navMesh", () => { + test("builds adjacency and routes around an L", () => { + expect(buildNavAdjacency(lMesh).map((entry) => entry.neighbors)).toEqual([[1], [0]]); + const path = findPath(lMesh, [0.5, 0, 0.5], [0.5, 0, 2.5]); + expect(path?.polys).toEqual([0, 1]); + expect(path?.points[0]).toEqual([0.5, 0, 0.5]); + expect(path?.points.at(-1)).toEqual([0.5, 0, 2.5]); + expect(path?.points.length).toBeGreaterThanOrEqual(2); + }); + + test("supports explicit off-mesh links", () => { + const mesh: NavMeshData = { ...lMesh, links: [{ from: 0, to: 1, cost: 0.25 }] }; + expect(buildNavAdjacency(mesh)[0]?.neighbors).toEqual([1]); + expect(findPath(mesh, [0.5, 0, 0.5], [0.5, 0, 2.5])?.polys).toEqual([0, 1]); + }); + + test("finds surface points and raycasts walkable space", () => { + expect(closestPoint(lMesh, [1, 3, 0.5])).toEqual([1, 0, 0.5]); + expect(raycastNav(lMesh, [0.5, 0, 0.5], [1.5, 0, 0.5])).toBe(true); + expect(raycastNav(lMesh, [0.5, 0, 0.5], [3, 0, 2])).toBe(false); + }); +}); diff --git a/packages/core/src/nav/navMesh.ts b/packages/core/src/nav/navMesh.ts new file mode 100644 index 000000000..aad5a44d1 --- /dev/null +++ b/packages/core/src/nav/navMesh.ts @@ -0,0 +1,152 @@ +import type { Vec3 } from "../world/geometry"; + +/** Explicit traversable connection between two navigation polygons. */ +export interface NavMeshLink { + from: number; + to: number; + cost?: number; +} + +/** Serializable polygon navigation mesh data. */ +export interface NavMeshData { + /** Flat xyz vertex coordinates. Polygon indices refer to triples in this array. */ + verts: number[]; + polys: number[][]; + links: NavMeshLink[]; + areas?: number[]; +} + +/** Neighbor relationship for one navigation polygon. */ +export interface NavMeshAdjacency { + neighbors: number[]; + cost: number; +} + +/** Route points and polygons selected through a navigation mesh. */ +export interface NavMeshPath { + points: Vec3[]; + polys: number[]; +} + +function vertex(mesh: NavMeshData, index: number): Vec3 { + return [mesh.verts[index * 3] ?? 0, mesh.verts[index * 3 + 1] ?? 0, mesh.verts[index * 3 + 2] ?? 0]; +} + +function center(mesh: NavMeshData, poly: readonly number[]): Vec3 { + const result: [number, number, number] = [0, 0, 0]; + for (const index of poly) { + const point = vertex(mesh, index); + result[0] += point[0]; result[1] += point[1]; result[2] += point[2]; + } + const scale = poly.length > 0 ? 1 / poly.length : 0; + return [result[0] * scale, result[1] * scale, result[2] * scale]; +} + +function distance(a: Vec3, b: Vec3): number { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); +} + +/** Build polygon adjacency from shared edges and explicit off-mesh links. */ +export function buildNavAdjacency(mesh: NavMeshData): NavMeshAdjacency[] { + const edges = new Map(); + mesh.polys.forEach((poly, polyIndex) => { + for (let i = 0; i < poly.length; i += 1) { + const a = poly[i]!; + const b = poly[(i + 1) % poly.length]!; + const key = a < b ? `${a}:${b}` : `${b}:${a}`; + const owners = edges.get(key) ?? []; + owners.push(polyIndex); + edges.set(key, owners); + } + }); + const result: NavMeshAdjacency[] = mesh.polys.map(() => ({ neighbors: [], cost: 1 })); + for (const owners of edges.values()) { + for (const a of owners) for (const b of owners) { + if (a !== b && !result[a]!.neighbors.includes(b)) result[a]!.neighbors.push(b); + } + } + for (const link of mesh.links ?? []) { + if (!result[link.from] || !result[link.to]) continue; + if (!result[link.from]!.neighbors.includes(link.to)) result[link.from]!.neighbors.push(link.to); + } + return result; +} + +function pointInPoly(mesh: NavMeshData, poly: readonly number[], point: Vec3): boolean { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = vertex(mesh, poly[i]!); const b = vertex(mesh, poly[j]!); + const cross = (point[0] - a[0]) * (b[2] - a[2]) - (point[2] - a[2]) * (b[0] - a[0]); + const dot = (point[0] - a[0]) * (point[0] - b[0]) + (point[2] - a[2]) * (point[2] - b[2]); + if (Math.abs(cross) < 1e-8 && dot <= 1e-8) return true; + if ((a[2] > point[2]) !== (b[2] > point[2]) && point[0] < (b[0] - a[0]) * (point[2] - a[2]) / (b[2] - a[2]) + a[0]) inside = !inside; + } + return inside; +} + +function closestOnSegment(point: Vec3, a: Vec3, b: Vec3): Vec3 { + const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2]; + const len = dx * dx + dy * dy + dz * dz; + const t = len > 0 ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy + (point[2] - a[2]) * dz) / len)) : 0; + return [a[0] + dx * t, a[1] + dy * t, a[2] + dz * t]; +} + +/** Return the closest point on the mesh surface, or null for an empty mesh. */ +export function closestPoint(mesh: NavMeshData, point: Vec3): Vec3 | null { + let best: Vec3 | null = null; let bestDistance = Number.POSITIVE_INFINITY; + for (const poly of mesh.polys) { + if (pointInPoly(mesh, poly, point)) return [point[0], center(mesh, poly)[1], point[2]]; + for (let i = 0; i < poly.length; i += 1) { + const candidate = closestOnSegment(point, vertex(mesh, poly[i]!), vertex(mesh, poly[(i + 1) % poly.length]!)); + const d = distance(point, candidate); + if (d < bestDistance) { bestDistance = d; best = candidate; } + } + } + return best; +} + +function polyAt(mesh: NavMeshData, point: Vec3): number | null { + for (let i = 0; i < mesh.polys.length; i += 1) if (pointInPoly(mesh, mesh.polys[i]!, point)) return i; + const nearest = closestPoint(mesh, point); + if (!nearest) return null; + let best = 0; let score = Number.POSITIVE_INFINITY; + mesh.polys.forEach((poly, i) => { const d = distance(nearest, center(mesh, poly)); if (d < score) { score = d; best = i; } }); + return best; +} + +/** A* over polygon centers, followed by deterministic visibility string-pulling. */ +export function findPath(mesh: NavMeshData, from: Vec3, to: Vec3): NavMeshPath | null { + if (mesh.polys.length === 0) return null; + const start = polyAt(mesh, from), goal = polyAt(mesh, to); + if (start === null || goal === null) return null; + const adjacency = buildNavAdjacency(mesh); + const g = new Map([[start, 0]]); const came = new Map(); const open = [start]; + while (open.length) { + open.sort((a, b) => (g.get(a)! + distance(center(mesh, mesh.polys[a]!), center(mesh, mesh.polys[goal]!))) - (g.get(b)! + distance(center(mesh, mesh.polys[b]!), center(mesh, mesh.polys[goal]!)))); + const current = open.shift()!; + if (current === goal) break; + for (const next of adjacency[current]!.neighbors) { + const link = mesh.links.find((candidate) => candidate.from === current && candidate.to === next); + const cost = link?.cost ?? distance(center(mesh, mesh.polys[current]!), center(mesh, mesh.polys[next]!)); + const nextG = g.get(current)! + Math.max(0, cost); + if (nextG < (g.get(next) ?? Number.POSITIVE_INFINITY)) { g.set(next, nextG); came.set(next, current); if (!open.includes(next)) open.push(next); } + } + } + if (start !== goal && !came.has(goal)) return null; + const polys = [goal]; while (polys[0] !== start) polys.unshift(came.get(polys[0]!)!); + const anchors = [from, ...polys.slice(1, -1).map((i) => center(mesh, mesh.polys[i]!)), to]; + const points: Vec3[] = [anchors[0]!]; let anchor = 0; + for (let i = 2; i < anchors.length; i += 1) { + const steps = 24; let visible = true; + for (let step = 1; step < steps; step += 1) { const t = step / steps; const p: Vec3 = [anchors[anchor]![0] + (anchors[i]![0] - anchors[anchor]![0]) * t, anchors[anchor]![1] + (anchors[i]![1] - anchors[anchor]![1]) * t, anchors[anchor]![2] + (anchors[i]![2] - anchors[anchor]![2]) * t]; if (!mesh.polys.some((poly) => pointInPoly(mesh, poly, p))) { visible = false; break; } } + if (!visible) { points.push(anchors[i - 1]!); anchor = i - 1; } + } + points.push(anchors[anchors.length - 1]!); + return { points, polys }; +} + +/** True when the segment remains over walkable polygons. */ +export function raycastNav(mesh: NavMeshData, from: Vec3, to: Vec3): boolean { + for (let i = 0; i <= 32; i += 1) { const t = i / 32; const point: Vec3 = [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t, from[2] + (to[2] - from[2]) * t]; if (!mesh.polys.some((poly) => pointInPoly(mesh, poly, point))) return false; } + return true; +} diff --git a/packages/core/src/world.ts b/packages/core/src/world.ts index a8e0e0c5c..ebb7e78ba 100644 --- a/packages/core/src/world.ts +++ b/packages/core/src/world.ts @@ -183,6 +183,15 @@ export { steerYaw } from "./movement/steering"; export { constrainToNavGrid } from "./nav/navConstrain"; export { populateNavGridFromEnvironment } from "./nav/navFromEnvironment"; export { createNavGrid, findPath, slopeStepCost, type NavGrid, type NavPoint } from "./nav/navGrid"; +export { + buildNavAdjacency, + closestPoint, + raycastNav, + type NavMeshAdjacency, + type NavMeshData, + type NavMeshLink, + type NavMeshPath, +} from "./nav/navMesh"; export { advancePathFollow, createPathFollow, diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 0fccdc60d..70e779509 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -253,6 +253,7 @@ "./nav/navConstrain", "./nav/navFromEnvironment", "./nav/navGrid", + "./nav/navMesh", "./nav/pathFollow", "./nav/railGraph", "./nav/timetable", From 30f4522497c8fffe2a84a2e195e37207ef054a1e Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:55:10 -0400 Subject: [PATCH 2/2] chore-add-nav-mesh-changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index df2824974..26cbc76a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ between (`--json` for structured output). - `syncWorldColliders` now keeps terrain and static object colliders synchronized with a physics backend. - Core decision graphs provide deterministic selector, sequence, condition, action, and utility runtimes with snapshot/restore. - Core behavior descriptors can run registered decision graphs at the interest-scheduler cadence. +- Core navigation meshes provide deterministic polygon routing, off-mesh links, closest-point queries, and nav raycasts. - WS sessions now issue resume tickets and retain disconnected memberships for a 15-second grace window, allowing reconnects to rejoin without replacing player state. - Shell entity sprites can play atlas-backed sprite clips while preserving raw texture sprites. - Tilemap layers can render atlas-backed textured tiles as one instanced quad mesh with parallax.