@driftengine/nav 4.7.0 → 4.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A navigation mesh: geometry in, convex walkable polygons out, and a straight line across them.
4
4
 
5
- **8,393 bytes gzipped**, measured by `scripts/size-gate.test.mjs` against
5
+ **9,058 bytes gzipped**, measured by `scripts/size-gate.test.mjs` against
6
6
  `scripts/fixtures/size/nav-only.ts`. Optional — nothing in `@driftengine/core` imports it, so a game
7
7
  that does not path pays nothing.
8
8
 
@@ -36,6 +36,7 @@ const field = voxeliseWalkable(geometry, {
36
36
  maxSlope: 45,
37
37
  agentHeight: 2,
38
38
  agentRadius: 0.5,
39
+ maxStep: 1,
39
40
  });
40
41
  const regions = buildRegions(field, { minRegionSpans: 8, maxStep: 1 });
41
42
  const mesh = buildPolyMesh(buildContours(field, regions, 1.3), 6, field);
@@ -53,11 +54,28 @@ it has two components, and an agent that asks for the other side has to be told.
53
54
  - **Steering.** §6.5 of the design reverses the refusal of a navigation _mesh_, which is a function
54
55
  from geometry to a graph. What an agent does with a path stays a game's decision, and
55
56
  `ai/src/entities/context.ts`'s refusal is upheld on exactly that line.
56
- - **Holes.** `buildContours` takes a region's outer loop and drops any inner one, so a room with a
57
- free-standing pillar becomes a room and an agent walks through the pillar. There is a test that
58
- pins that behaviour rather than a comment that hopes about it. The fix is a bridge edge joining
59
- the inner loop to the outer; until it is built, leave a gap between a pillar and the floor so the
60
- watershed splits around it.
61
- - **Optimal paths.** The funnel gives the shortest path through the corridor A* chose, and the
62
- corridor comes from polygon centres. On the wall in `query.test.ts` that is 10.75 against an ideal
63
- 9.81 — about a tenth, from the region split putting a portal corner a cell below the wall's foot.
57
+ - **A closed box's inside.** A column inside a box meets the floor, the box's bottom and its top,
58
+ and nothing there says the space between is solid short of trusting every mesh's winding. So the
59
+ floor a closed box encloses stays walkable: an island inside the box's walls that no route from
60
+ outside reaches, but `nearestPoly` can still snap a point beside the box onto it. A test pins it.
61
+ - **More than one layer of polygons.** The voxel field keeps both floors under a bridge, and the
62
+ regions keep them apart, but a contour follows the lowest walkable span in each column, so where
63
+ two floors overlap only the lower one becomes polygons.
64
+
65
+ ## Walls, pillars and the shortest way round
66
+
67
+ - **A wall is a solid.** A face too steep to stand on is clipped to every cell it crosses and
68
+ stands in that column as a solid, so a building on the ground blocks, and so does a wall with no
69
+ thickness and no top. Erosion judges a neighbour at the span's own height, so the agent keeps its
70
+ radius from a wall even when the wall's top is walkable, and from a ledge higher than `maxStep`.
71
+ Give the voxeliser the same `maxStep` as `buildRegions`: the polygons are flat, so two edges that
72
+ touch are a portal whatever their heights, and erosion is what keeps a floor and a ledge apart.
73
+ - **A shared border is one line.** A room the watershed splits is several regions, and a portal is
74
+ where two polygons' edges overlap. The points where a border changes what it faces are held in
75
+ every outline through them, and each stretch between is simplified the same way from both sides,
76
+ so the regions meet edge to edge at any deviation.
77
+ - **A pillar is a hole.** A region's inner loops are bridged into its outline, so a path goes round
78
+ a free-standing pillar and no polygon claims the ground under it.
79
+ - **A path is the shortest way round.** The search runs over portals, the overlaps between
80
+ neighbouring polygons' edges, so the corridor it hands the funnel holds the shortest path: 9.81
81
+ on the wall in `query.test.ts`, where a search over polygon centres gives 10.75.
package/dist/contour.js CHANGED
@@ -40,7 +40,7 @@ function regionAtColumn(field, regions, x, z) {
40
40
  * are coincident.
41
41
  */
42
42
  export function buildContours(field, regions, maxDeviation) {
43
- const out = [];
43
+ const traced = [];
44
44
  for (let region = 0; region < regions.count; region += 1) {
45
45
  const edges = [];
46
46
  for (let z = 0; z < field.depth; z += 1) {
@@ -78,10 +78,67 @@ export function buildContours(field, regions, maxDeviation) {
78
78
  * fixes it exactly.
79
79
  */
80
80
  const bridged = bridgeHoles(loops);
81
- const raw = Int32Array.from(bridged.loop);
82
- out.push({ region, points: simplify(raw, maxDeviation, bridged.anchors), raw });
81
+ traced.push({ region, raw: Int32Array.from(bridged.loop), bridges: bridged.anchors });
82
+ }
83
+ /*
84
+ * **A border two regions share has to come out of simplification the same from both sides.** A
85
+ * portal is where two polygons' edges overlap, and each region's outline was simplified on its
86
+ * own: a jagged border between two regions came out as two different lines, which overlap
87
+ * nowhere, and a room the watershed had split was islands with no route between them. On a 16 by
88
+ * 10 room with a pillar, simplified to 1.3, there were two regions and no path across.
89
+ *
90
+ * So the points where a border changes what it faces are held in every loop that passes through
91
+ * them, which makes each stretch between two held points one border with the same two ends from
92
+ * both sides; a bridge's ends are held everywhere for the same reason, since the region on the
93
+ * far side of a hole meets them too. Each stretch is simplified in one canonical direction, so
94
+ * both sides take the same points. And the halving that keeps a loop from crossing itself is
95
+ * applied to every region together, because a shared stretch simplified at two deviations is two
96
+ * lines again. One awkward region makes every outline finer, which costs vertices and nothing
97
+ * else.
98
+ */
99
+ const bridgeEnds = new Set();
100
+ for (const { raw, bridges } of traced) {
101
+ for (const at of bridges)
102
+ bridgeEnds.add(`${raw[at * 2]},${raw[at * 2 + 1]}`);
83
103
  }
84
- return out;
104
+ const held = traced.map(({ raw }) => {
105
+ const indices = [];
106
+ for (let at = 0; at < raw.length / 2; at += 1) {
107
+ const x = raw[at * 2];
108
+ const z = raw[at * 2 + 1];
109
+ if (bridgeEnds.has(`${x},${z}`) || isJunction(field, regions, x, z))
110
+ indices.push(at);
111
+ }
112
+ return indices;
113
+ });
114
+ for (let deviation = maxDeviation; deviation > 1e-4; deviation /= 2) {
115
+ const simplified = traced.map(({ raw }, at) => simplifyOnce(raw, deviation, held[at] ?? []));
116
+ if (simplified.some(contourSelfIntersects))
117
+ continue;
118
+ return traced.map(({ region, raw }, at) => ({
119
+ region,
120
+ points: simplified[at],
121
+ raw,
122
+ }));
123
+ }
124
+ return traced.map(({ region, raw }) => ({ region, points: raw, raw }));
125
+ }
126
+ /**
127
+ * Whether three or more different things meet at a cell corner: regions, or regions and nothing.
128
+ *
129
+ * Where a region's boundary stops facing one thing and starts facing another, the corner has the
130
+ * region and both of those around it, and that is the only way three can meet. So this is exactly
131
+ * the set of points where some loop through the corner changes what it borders, decided from the
132
+ * field alone, which is what lets every loop through it agree.
133
+ */
134
+ function isJunction(field, regions, x, z) {
135
+ const around = [
136
+ regionAtColumn(field, regions, x - 1, z - 1),
137
+ regionAtColumn(field, regions, x, z - 1),
138
+ regionAtColumn(field, regions, x - 1, z),
139
+ regionAtColumn(field, regions, x, z),
140
+ ];
141
+ return new Set(around).size >= 3;
85
142
  }
86
143
  /**
87
144
  * Follow the directed edges into closed loops.
@@ -391,36 +448,27 @@ function segmentsCross(ax, az, bx, bz, cx, cz, dx, dz) {
391
448
  return d1 !== d2 && d3 !== d4 && d1 !== 0 && d2 !== 0 && d3 !== 0 && d4 !== 0;
392
449
  }
393
450
  /**
394
- * Douglas–Peucker around a closed loop, halving the deviation until the result is simple.
451
+ * Douglas–Peucker around a closed loop, holding `held` and simplifying the stretches between.
395
452
  *
396
- * The raw boundary is a walk along cell edges and cannot cross itself, so the loop terminates at
397
- * something valid however hostile the shape. The halving is the honest version of "this usually
398
- * works": it costs a few extra passes on a shape that needs them and nothing at all on one that
399
- * does not.
453
+ * `buildContours` halves the deviation until no loop crosses itself. The raw boundary is a walk
454
+ * along cell edges and cannot cross itself, so that terminates at something valid however hostile
455
+ * the shape: it costs a few extra passes on a shape that needs them and nothing on one that does
456
+ * not.
400
457
  */
401
- function simplify(raw, maxDeviation, anchors = []) {
402
- for (let deviation = maxDeviation; deviation > 1e-4; deviation /= 2) {
403
- const candidate = simplifyOnce(raw, deviation, anchors);
404
- if (!contourSelfIntersects(candidate))
405
- return candidate;
406
- }
407
- return raw;
408
- }
409
- function simplifyOnce(raw, maxDeviation, anchors) {
458
+ function simplifyOnce(raw, maxDeviation, held) {
410
459
  const count = raw.length / 2;
411
460
  if (count < 4)
412
461
  return raw;
413
462
  const keep = new Uint8Array(count);
414
- const bridged = anchors.length >= 2;
415
463
  let fixed;
416
- if (bridged) {
464
+ if (held.length > 0) {
417
465
  /*
418
- * **A bridged loop's anchors are the bridge, and they are not negotiable.** Every chain between
419
- * two of them is an ordinary open polyline Douglas–Peucker may do as it likes with; the anchors
420
- * are what keep the two traversals of each bridge coincident, which is the whole of what makes
421
- * the polygon weakly simple rather than self-overlapping.
466
+ * **Held points are a bridge or a change of neighbour, and they are not negotiable.** Every
467
+ * stretch between two of them is an open polyline Douglas–Peucker may do as it likes with; the
468
+ * held points are what keep the two traversals of each bridge coincident, and each shared
469
+ * border the same from both of its regions.
422
470
  */
423
- fixed = [...anchors].sort((a, b) => a - b);
471
+ fixed = [...held];
424
472
  }
425
473
  else {
426
474
  /* Two anchors, so the loop becomes two open chains: the first point and the one furthest. */
@@ -440,12 +488,12 @@ function simplifyOnce(raw, maxDeviation, anchors) {
440
488
  keep[at] = 1;
441
489
  for (let at = 0; at < fixed.length; at += 1) {
442
490
  const from = fixed[at];
443
- const to = at + 1 < fixed.length ? fixed[at + 1] : count;
444
- chain(raw, from, to, maxDeviation, keep);
491
+ const to = at + 1 < fixed.length ? fixed[at + 1] : fixed[0] + count;
492
+ stretch(raw, from, to, maxDeviation, keep);
445
493
  }
446
494
  /*
447
- * **Only a *bridge* anchor is protected from the collinear pass, never the arbitrary pair.** The
448
- * two anchors of an unbridged loop are wherever the trace happened to start and whatever was
495
+ * **Only a *held* point is protected from the collinear pass, never the arbitrary pair.** The two
496
+ * anchors of a loop with nothing held are wherever the trace happened to start and whatever was
449
497
  * furthest from it, so one is routinely a point in the middle of a straight edge — and
450
498
  * `dropCollinear` exists precisely to remove that, which is what keeps an L six-cornered.
451
499
  */
@@ -454,7 +502,7 @@ function simplifyOnce(raw, maxDeviation, anchors) {
454
502
  for (let at = 0; at < count; at += 1) {
455
503
  if (keep[at] !== 1)
456
504
  continue;
457
- if (bridged && fixed.includes(at))
505
+ if (held.length > 0 && fixed.includes(at))
458
506
  protectedOut.push(out.length / 2);
459
507
  out.push(raw[at * 2], raw[at * 2 + 1]);
460
508
  }
@@ -493,16 +541,50 @@ function dropCollinear(points, protect = []) {
493
541
  }
494
542
  return out.length >= 6 ? Int32Array.from(out) : points;
495
543
  }
496
- /** Mark the points of `raw[from..to]` that have to stay. `to` may be one past the end, meaning wrap. */
497
- function chain(raw, from, to, maxDeviation, keep) {
544
+ /**
545
+ * Mark the points of `raw` from `from` to `to` that have to stay. `to` may pass the end, which
546
+ * wraps; `to` equal to `from` plus the loop's length is the whole loop from one held point.
547
+ *
548
+ * **Walked in one canonical direction, whichever way the loop runs**, so a border two regions
549
+ * share, which each traces the other way round, keeps the same points from both sides: the
550
+ * stretch runs from its lesser end to its greater, comparing x then z, and a stretch that starts
551
+ * and ends at one point runs toward the lesser of its two neighbours.
552
+ */
553
+ function stretch(raw, from, to, maxDeviation, keep) {
498
554
  const count = raw.length / 2;
499
- const endIndex = to % count;
500
555
  if (to - from < 2)
501
556
  return;
557
+ const order = [];
558
+ for (let at = from; at <= to; at += 1)
559
+ order.push(at % count);
560
+ const last = order.length - 1;
561
+ const first = order[0];
562
+ const end = order[last];
563
+ const sameEnds = raw[first * 2] === raw[end * 2] && raw[first * 2 + 1] === raw[end * 2 + 1];
564
+ const backwards = sameEnds
565
+ ? lesser(raw, order[last - 1], order[1])
566
+ : lesser(raw, end, first);
567
+ if (backwards)
568
+ order.reverse();
569
+ douglasPeucker(raw, order, 0, last, maxDeviation, keep);
570
+ }
571
+ /** Whether point `a` comes before point `b`, by x and then by z. */
572
+ function lesser(raw, a, b) {
573
+ const ax = raw[a * 2];
574
+ const bx = raw[b * 2];
575
+ return ax < bx || (ax === bx && raw[a * 2 + 1] < raw[b * 2 + 1]);
576
+ }
577
+ /** Keep the furthest point of `order` between `lo` and `hi` while it is further than the bound. */
578
+ function douglasPeucker(raw, order, lo, hi, maxDeviation, keep) {
579
+ if (hi - lo < 2)
580
+ return;
581
+ const a = order[lo];
582
+ const b = order[hi];
502
583
  let worst = -1;
503
584
  let worstAt = -1;
504
- for (let at = from + 1; at < to; at += 1) {
505
- const d = distanceToSegment(raw[at * 2], raw[at * 2 + 1], raw[from * 2], raw[from * 2 + 1], raw[endIndex * 2], raw[endIndex * 2 + 1]);
585
+ for (let at = lo + 1; at < hi; at += 1) {
586
+ const point = order[at];
587
+ const d = distanceToSegment(raw[point * 2], raw[point * 2 + 1], raw[a * 2], raw[a * 2 + 1], raw[b * 2], raw[b * 2 + 1]);
506
588
  if (d > worst) {
507
589
  worst = d;
508
590
  worstAt = at;
@@ -510,7 +592,7 @@ function chain(raw, from, to, maxDeviation, keep) {
510
592
  }
511
593
  if (worst <= maxDeviation || worstAt < 0)
512
594
  return;
513
- keep[worstAt] = 1;
514
- chain(raw, from, worstAt, maxDeviation, keep);
515
- chain(raw, worstAt, to, maxDeviation, keep);
595
+ keep[order[worstAt]] = 1;
596
+ douglasPeucker(raw, order, lo, worstAt, maxDeviation, keep);
597
+ douglasPeucker(raw, order, worstAt, hi, maxDeviation, keep);
516
598
  }
@@ -34,6 +34,16 @@ export interface VoxeliseSettings {
34
34
  readonly agentHeight: number;
35
35
  /** How far from an edge an agent's centre must stay. */
36
36
  readonly agentRadius: number;
37
+ /**
38
+ * How many cells of height an agent can step across, the number `buildRegions` takes; 1 when
39
+ * absent. A neighbour further above or below than this is an edge, and erosion keeps the agent's
40
+ * radius from it.
41
+ *
42
+ * **Here as well as there because the polygons are flat.** Two regions whose edges touch are
43
+ * joined by a portal whatever their heights, so a floor and a ledge above it must be kept apart
44
+ * by erosion, or a route goes over the ledge.
45
+ */
46
+ readonly maxStep?: number;
37
47
  }
38
48
  /**
39
49
  * Walkable surfaces as spans, in columns on a horizontal grid.
package/dist/voxelise.js CHANGED
@@ -59,6 +59,10 @@ export function spanWalkable(field, x, z, at) {
59
59
  return false;
60
60
  return field.spans[base + 2] === 1;
61
61
  }
62
+ /** Scratch for clipping a triangle to one cell: four planes leave at most seven vertices. */
63
+ const CLIP_IN = new Float64Array(8 * 3);
64
+ const CLIP_OUT = new Float64Array(8 * 3);
65
+ const HEIGHTS = new Float64Array(2);
62
66
  /**
63
67
  * Rasterise, then decide.
64
68
  *
@@ -116,18 +120,89 @@ export function voxeliseWalkable(geometry, settings) {
116
120
  const hiZ = Math.min(depth - 1, Math.floor((Math.max(az, bz, cz) - minZ) / settings.cellSize));
117
121
  for (let gz = loZ; gz <= hiZ; gz += 1) {
118
122
  for (let gx = loX; gx <= hiX; gx += 1) {
119
- const px = minX + (gx + 0.5) * settings.cellSize;
120
- const pz = minZ + (gz + 0.5) * settings.cellSize;
121
- const height = heightAt(ax, ay, az, bx, by, bz, cx, cy, cz, px, pz);
122
- if (height === null)
123
+ if (walkable) {
124
+ const px = minX + (gx + 0.5) * settings.cellSize;
125
+ const pz = minZ + (gz + 0.5) * settings.cellSize;
126
+ const height = heightAt(ax, ay, az, bx, by, bz, cx, cy, cz, px, pz);
127
+ if (height === null)
128
+ continue;
129
+ const floor = Math.floor((height - minY) / settings.cellHeight);
130
+ columns[gx + gz * width]?.push({ bottom: floor, top: floor, walkable });
123
131
  continue;
124
- const floor = Math.floor((height - minY) / settings.cellHeight);
125
- columns[gx + gz * width]?.push({ floor, walkable });
132
+ }
133
+ /*
134
+ * **A face too steep to stand on is a solid, over every cell it crosses.** Sampled at the
135
+ * cell's centre like a floor, a wall is never met at all: it is vertical, so it covers no
136
+ * area seen from above, and a building on the ground read as a roof over open floor that a
137
+ * route went straight through. So the face is clipped to the cell's square, and the heights
138
+ * it spans there stand in the column as one solid, whose top is not walkable.
139
+ */
140
+ const x0 = minX + gx * settings.cellSize;
141
+ const z0 = minZ + gz * settings.cellSize;
142
+ CLIP_IN.set([ax, ay, az, bx, by, bz, cx, cy, cz]);
143
+ if (!clipToCell(x0, x0 + settings.cellSize, z0, z0 + settings.cellSize, HEIGHTS)) {
144
+ continue;
145
+ }
146
+ columns[gx + gz * width]?.push({
147
+ bottom: Math.floor((HEIGHTS[0] - minY) / settings.cellHeight),
148
+ top: Math.floor((HEIGHTS[1] - minY) / settings.cellHeight),
149
+ walkable: false,
150
+ });
126
151
  }
127
152
  }
128
153
  }
129
154
  return pack(columns, width, depth, origin, settings);
130
155
  }
156
+ /**
157
+ * The lowest and highest heights of the triangle in `CLIP_IN` within one cell's square, written to
158
+ * `out`; false where the triangle misses the cell.
159
+ *
160
+ * Sutherland–Hodgman against the square's four sides. Inclusive, so a wall lying exactly on a cell
161
+ * boundary still lands in the cell the bounding box chose for it.
162
+ */
163
+ function clipToCell(x0, x1, z0, z1, out) {
164
+ let count = 3;
165
+ count = clipSide(CLIP_IN, count, CLIP_OUT, 0, x0, 1);
166
+ count = clipSide(CLIP_OUT, count, CLIP_IN, 0, x1, -1);
167
+ count = clipSide(CLIP_IN, count, CLIP_OUT, 2, z0, 1);
168
+ count = clipSide(CLIP_OUT, count, CLIP_IN, 2, z1, -1);
169
+ if (count === 0)
170
+ return false;
171
+ let low = Infinity;
172
+ let high = -Infinity;
173
+ for (let at = 0; at < count; at += 1) {
174
+ const y = CLIP_IN[at * 3 + 1];
175
+ low = Math.min(low, y);
176
+ high = Math.max(high, y);
177
+ }
178
+ out[0] = low;
179
+ out[1] = high;
180
+ return true;
181
+ }
182
+ /** Keep the part of a polygon on one side of `axis = bound`: above it for `side` 1, below for -1. */
183
+ function clipSide(from, count, to, axis, bound, side) {
184
+ let written = 0;
185
+ for (let at = 0; at < count; at += 1) {
186
+ const next = (at + 1) % count;
187
+ const here = (from[at * 3 + axis] - bound) * side;
188
+ const there = (from[next * 3 + axis] - bound) * side;
189
+ if (here >= 0) {
190
+ to[written * 3] = from[at * 3];
191
+ to[written * 3 + 1] = from[at * 3 + 1];
192
+ to[written * 3 + 2] = from[at * 3 + 2];
193
+ written += 1;
194
+ }
195
+ if (here >= 0 !== there >= 0) {
196
+ const t = here / (here - there);
197
+ for (let k = 0; k < 3; k += 1) {
198
+ const a = from[at * 3 + k];
199
+ to[written * 3 + k] = a + (from[next * 3 + k] - a) * t;
200
+ }
201
+ written += 1;
202
+ }
203
+ }
204
+ return written;
205
+ }
131
206
  /**
132
207
  * The triangle's height at (px, pz), or null where the point is outside it.
133
208
  *
@@ -157,20 +232,34 @@ function pack(columns, width, depth, origin, settings) {
157
232
  const headroom = Math.ceil(settings.agentHeight / settings.cellHeight);
158
233
  const merged = [];
159
234
  for (const samples of columns) {
160
- samples.sort((a, b) => a.floor - b.floor || Number(a.walkable) - Number(b.walkable));
235
+ samples.sort((a, b) => a.bottom - b.bottom || a.top - b.top || Number(a.walkable) - Number(b.walkable));
161
236
  const spans = [];
237
+ /* Where each span's solid starts, which is the ceiling of the span below it. */
238
+ const bottoms = [];
162
239
  for (const sample of samples) {
163
240
  const last = spans.length - SPAN_STRIDE;
164
- /* Samples within one cell of each other are one surface, not two. */
165
- if (last >= 0 && sample.floor - spans[last] <= 1) {
166
- spans[last + 2] = spans[last + 2] | (sample.walkable ? 1 : 0);
241
+ const top = spans[last];
242
+ /*
243
+ * A sample starting within one cell of a span's top is part of that solid, not a new one.
244
+ * The span stands on the highest top, and is walkable if anything ending within a cell of
245
+ * that top is: a wall rising past a floor takes the floor over, and a floor's two triangles
246
+ * at one height agree.
247
+ */
248
+ if (last >= 0 && sample.bottom - top <= 1) {
249
+ const flag = sample.walkable ? 1 : 0;
250
+ if (sample.top > top + 1)
251
+ spans[last + 2] = flag;
252
+ else if (sample.top >= top - 1)
253
+ spans[last + 2] = spans[last + 2] | flag;
254
+ spans[last] = Math.max(top, sample.top);
167
255
  continue;
168
256
  }
169
- spans.push(sample.floor, Number.MAX_SAFE_INTEGER, sample.walkable ? 1 : 0);
257
+ spans.push(sample.top, Number.MAX_SAFE_INTEGER, sample.walkable ? 1 : 0);
258
+ bottoms.push(sample.bottom);
170
259
  }
171
- /* A span's ceiling is the next span's floor. The topmost has open sky. */
172
- for (let at = 0; at + SPAN_STRIDE < spans.length; at += SPAN_STRIDE) {
173
- spans[at + 1] = spans[at + SPAN_STRIDE];
260
+ /* A span's ceiling is where the next solid starts. The topmost has open sky. */
261
+ for (let at = 0, next = 1; at + SPAN_STRIDE < spans.length; at += SPAN_STRIDE, next += 1) {
262
+ spans[at + 1] = bottoms[next];
174
263
  if (spans[at + 1] - spans[at] < headroom)
175
264
  spans[at + 2] = 0;
176
265
  }
@@ -209,28 +298,43 @@ function pack(columns, width, depth, origin, settings) {
209
298
  * doing it as that many single-cell passes keeps the whole thing integer arithmetic with no
210
299
  * distance to round. **Copied per pass**, because eroding in place erodes into what the same pass
211
300
  * just cleared and eats the mesh from its edges inward.
301
+ *
302
+ * **A neighbour is judged at this span's height, not anywhere in its column.** Asked whether a
303
+ * neighbouring column had any walkable span at all, a wall answered yes, with the roof on top of
304
+ * it, and the floor beside the wall kept no margin. A neighbour counts as open when it has a
305
+ * walkable span within `maxStep` of this one, a step the agent can take. Further than that is a
306
+ * wall, a ledge or a drop, and the agent keeps its radius from it.
212
307
  */
213
308
  function erode(columns, width, depth, settings) {
214
309
  const steps = Math.ceil(settings.agentRadius / settings.cellSize);
310
+ const reach = settings.maxStep ?? 1;
215
311
  for (let pass = 0; pass < steps; pass += 1) {
216
312
  const before = columns.map((column) => [...column]);
217
- const open = (gx, gz) => {
313
+ const open = (gx, gz, floor) => {
218
314
  if (gx < 0 || gz < 0 || gx >= width || gz >= depth)
219
315
  return false;
220
316
  const column = before[gx + gz * width];
221
317
  for (let at = 0; at < column.length; at += SPAN_STRIDE) {
222
- if (column[at + 2] === 1)
318
+ if (column[at + 2] === 1 && Math.abs(column[at] - floor) <= reach)
223
319
  return true;
224
320
  }
225
321
  return false;
226
322
  };
227
323
  for (let gz = 0; gz < depth; gz += 1) {
228
324
  for (let gx = 0; gx < width; gx += 1) {
229
- if (open(gx - 1, gz) && open(gx + 1, gz) && open(gx, gz - 1) && open(gx, gz + 1))
230
- continue;
231
325
  const column = columns[gx + gz * width];
232
- for (let at = 0; at < column.length; at += SPAN_STRIDE)
326
+ for (let at = 0; at < column.length; at += SPAN_STRIDE) {
327
+ if (column[at + 2] !== 1)
328
+ continue;
329
+ const floor = column[at];
330
+ if (open(gx - 1, gz, floor) &&
331
+ open(gx + 1, gz, floor) &&
332
+ open(gx, gz - 1, floor) &&
333
+ open(gx, gz + 1, floor)) {
334
+ continue;
335
+ }
233
336
  column[at + 2] = 0;
337
+ }
234
338
  }
235
339
  }
236
340
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/nav",
3
- "version": "4.7.0",
3
+ "version": "4.7.2",
4
4
  "description": "A navigation mesh: geometry to walkable convex polygons, and a straight line across them",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -53,6 +53,6 @@
53
53
  "prepack": "node ../../scripts/build.mjs @driftengine/nav"
54
54
  },
55
55
  "dependencies": {
56
- "@driftengine/core": "4.7.0"
56
+ "@driftengine/core": "4.7.2"
57
57
  }
58
58
  }
package/src/contour.ts CHANGED
@@ -69,7 +69,7 @@ export function buildContours(
69
69
  regions: RegionField,
70
70
  maxDeviation: number,
71
71
  ): Contour[] {
72
- const out: Contour[] = [];
72
+ const traced: { region: number; raw: Int32Array; bridges: number[] }[] = [];
73
73
  for (let region = 0; region < regions.count; region += 1) {
74
74
  const edges: Edge[] = [];
75
75
  for (let z = 0; z < field.depth; z += 1) {
@@ -105,10 +105,67 @@ export function buildContours(
105
105
  * fixes it exactly.
106
106
  */
107
107
  const bridged = bridgeHoles(loops);
108
- const raw = Int32Array.from(bridged.loop);
109
- out.push({ region, points: simplify(raw, maxDeviation, bridged.anchors), raw });
108
+ traced.push({ region, raw: Int32Array.from(bridged.loop), bridges: bridged.anchors });
110
109
  }
111
- return out;
110
+
111
+ /*
112
+ * **A border two regions share has to come out of simplification the same from both sides.** A
113
+ * portal is where two polygons' edges overlap, and each region's outline was simplified on its
114
+ * own: a jagged border between two regions came out as two different lines, which overlap
115
+ * nowhere, and a room the watershed had split was islands with no route between them. On a 16 by
116
+ * 10 room with a pillar, simplified to 1.3, there were two regions and no path across.
117
+ *
118
+ * So the points where a border changes what it faces are held in every loop that passes through
119
+ * them, which makes each stretch between two held points one border with the same two ends from
120
+ * both sides; a bridge's ends are held everywhere for the same reason, since the region on the
121
+ * far side of a hole meets them too. Each stretch is simplified in one canonical direction, so
122
+ * both sides take the same points. And the halving that keeps a loop from crossing itself is
123
+ * applied to every region together, because a shared stretch simplified at two deviations is two
124
+ * lines again. One awkward region makes every outline finer, which costs vertices and nothing
125
+ * else.
126
+ */
127
+ const bridgeEnds = new Set<string>();
128
+ for (const { raw, bridges } of traced) {
129
+ for (const at of bridges) bridgeEnds.add(`${raw[at * 2]},${raw[at * 2 + 1]}`);
130
+ }
131
+ const held = traced.map(({ raw }) => {
132
+ const indices: number[] = [];
133
+ for (let at = 0; at < raw.length / 2; at += 1) {
134
+ const x = raw[at * 2] as number;
135
+ const z = raw[at * 2 + 1] as number;
136
+ if (bridgeEnds.has(`${x},${z}`) || isJunction(field, regions, x, z)) indices.push(at);
137
+ }
138
+ return indices;
139
+ });
140
+
141
+ for (let deviation = maxDeviation; deviation > 1e-4; deviation /= 2) {
142
+ const simplified = traced.map(({ raw }, at) => simplifyOnce(raw, deviation, held[at] ?? []));
143
+ if (simplified.some(contourSelfIntersects)) continue;
144
+ return traced.map(({ region, raw }, at) => ({
145
+ region,
146
+ points: simplified[at] as Int32Array,
147
+ raw,
148
+ }));
149
+ }
150
+ return traced.map(({ region, raw }) => ({ region, points: raw, raw }));
151
+ }
152
+
153
+ /**
154
+ * Whether three or more different things meet at a cell corner: regions, or regions and nothing.
155
+ *
156
+ * Where a region's boundary stops facing one thing and starts facing another, the corner has the
157
+ * region and both of those around it, and that is the only way three can meet. So this is exactly
158
+ * the set of points where some loop through the corner changes what it borders, decided from the
159
+ * field alone, which is what lets every loop through it agree.
160
+ */
161
+ function isJunction(field: VoxelField, regions: RegionField, x: number, z: number): boolean {
162
+ const around = [
163
+ regionAtColumn(field, regions, x - 1, z - 1),
164
+ regionAtColumn(field, regions, x, z - 1),
165
+ regionAtColumn(field, regions, x - 1, z),
166
+ regionAtColumn(field, regions, x, z),
167
+ ];
168
+ return new Set(around).size >= 3;
112
169
  }
113
170
 
114
171
  /** One side of one cell, pointing so the region stays on the same hand throughout. */
@@ -495,44 +552,27 @@ function segmentsCross(
495
552
  }
496
553
 
497
554
  /**
498
- * Douglas–Peucker around a closed loop, halving the deviation until the result is simple.
555
+ * Douglas–Peucker around a closed loop, holding `held` and simplifying the stretches between.
499
556
  *
500
- * The raw boundary is a walk along cell edges and cannot cross itself, so the loop terminates at
501
- * something valid however hostile the shape. The halving is the honest version of "this usually
502
- * works": it costs a few extra passes on a shape that needs them and nothing at all on one that
503
- * does not.
557
+ * `buildContours` halves the deviation until no loop crosses itself. The raw boundary is a walk
558
+ * along cell edges and cannot cross itself, so that terminates at something valid however hostile
559
+ * the shape: it costs a few extra passes on a shape that needs them and nothing on one that does
560
+ * not.
504
561
  */
505
- function simplify(
506
- raw: Int32Array,
507
- maxDeviation: number,
508
- anchors: readonly number[] = [],
509
- ): Int32Array {
510
- for (let deviation = maxDeviation; deviation > 1e-4; deviation /= 2) {
511
- const candidate = simplifyOnce(raw, deviation, anchors);
512
- if (!contourSelfIntersects(candidate)) return candidate;
513
- }
514
- return raw;
515
- }
516
-
517
- function simplifyOnce(
518
- raw: Int32Array,
519
- maxDeviation: number,
520
- anchors: readonly number[],
521
- ): Int32Array {
562
+ function simplifyOnce(raw: Int32Array, maxDeviation: number, held: readonly number[]): Int32Array {
522
563
  const count = raw.length / 2;
523
564
  if (count < 4) return raw;
524
565
 
525
566
  const keep = new Uint8Array(count);
526
- const bridged = anchors.length >= 2;
527
567
  let fixed: number[];
528
- if (bridged) {
568
+ if (held.length > 0) {
529
569
  /*
530
- * **A bridged loop's anchors are the bridge, and they are not negotiable.** Every chain between
531
- * two of them is an ordinary open polyline Douglas–Peucker may do as it likes with; the anchors
532
- * are what keep the two traversals of each bridge coincident, which is the whole of what makes
533
- * the polygon weakly simple rather than self-overlapping.
570
+ * **Held points are a bridge or a change of neighbour, and they are not negotiable.** Every
571
+ * stretch between two of them is an open polyline Douglas–Peucker may do as it likes with; the
572
+ * held points are what keep the two traversals of each bridge coincident, and each shared
573
+ * border the same from both of its regions.
534
574
  */
535
- fixed = [...anchors].sort((a, b) => a - b);
575
+ fixed = [...held];
536
576
  } else {
537
577
  /* Two anchors, so the loop becomes two open chains: the first point and the one furthest. */
538
578
  let far = 0;
@@ -554,13 +594,13 @@ function simplifyOnce(
554
594
  for (const at of fixed) keep[at] = 1;
555
595
  for (let at = 0; at < fixed.length; at += 1) {
556
596
  const from = fixed[at] as number;
557
- const to = at + 1 < fixed.length ? (fixed[at + 1] as number) : count;
558
- chain(raw, from, to, maxDeviation, keep);
597
+ const to = at + 1 < fixed.length ? (fixed[at + 1] as number) : (fixed[0] as number) + count;
598
+ stretch(raw, from, to, maxDeviation, keep);
559
599
  }
560
600
 
561
601
  /*
562
- * **Only a *bridge* anchor is protected from the collinear pass, never the arbitrary pair.** The
563
- * two anchors of an unbridged loop are wherever the trace happened to start and whatever was
602
+ * **Only a *held* point is protected from the collinear pass, never the arbitrary pair.** The two
603
+ * anchors of a loop with nothing held are wherever the trace happened to start and whatever was
564
604
  * furthest from it, so one is routinely a point in the middle of a straight edge — and
565
605
  * `dropCollinear` exists precisely to remove that, which is what keeps an L six-cornered.
566
606
  */
@@ -568,7 +608,7 @@ function simplifyOnce(
568
608
  const protectedOut: number[] = [];
569
609
  for (let at = 0; at < count; at += 1) {
570
610
  if (keep[at] !== 1) continue;
571
- if (bridged && fixed.includes(at)) protectedOut.push(out.length / 2);
611
+ if (held.length > 0 && fixed.includes(at)) protectedOut.push(out.length / 2);
572
612
  out.push(raw[at * 2] as number, raw[at * 2 + 1] as number);
573
613
  }
574
614
  return dropCollinear(Int32Array.from(out), protectedOut);
@@ -607,8 +647,16 @@ function dropCollinear(points: Int32Array, protect: readonly number[] = []): Int
607
647
  return out.length >= 6 ? Int32Array.from(out) : points;
608
648
  }
609
649
 
610
- /** Mark the points of `raw[from..to]` that have to stay. `to` may be one past the end, meaning wrap. */
611
- function chain(
650
+ /**
651
+ * Mark the points of `raw` from `from` to `to` that have to stay. `to` may pass the end, which
652
+ * wraps; `to` equal to `from` plus the loop's length is the whole loop from one held point.
653
+ *
654
+ * **Walked in one canonical direction, whichever way the loop runs**, so a border two regions
655
+ * share, which each traces the other way round, keeps the same points from both sides: the
656
+ * stretch runs from its lesser end to its greater, comparing x then z, and a stretch that starts
657
+ * and ends at one point runs toward the lesser of its two neighbours.
658
+ */
659
+ function stretch(
612
660
  raw: Int32Array,
613
661
  from: number,
614
662
  to: number,
@@ -616,19 +664,50 @@ function chain(
616
664
  keep: Uint8Array,
617
665
  ): void {
618
666
  const count = raw.length / 2;
619
- const endIndex = to % count;
620
667
  if (to - from < 2) return;
668
+ const order: number[] = [];
669
+ for (let at = from; at <= to; at += 1) order.push(at % count);
670
+ const last = order.length - 1;
671
+ const first = order[0] as number;
672
+ const end = order[last] as number;
673
+ const sameEnds = raw[first * 2] === raw[end * 2] && raw[first * 2 + 1] === raw[end * 2 + 1];
674
+ const backwards = sameEnds
675
+ ? lesser(raw, order[last - 1] as number, order[1] as number)
676
+ : lesser(raw, end, first);
677
+ if (backwards) order.reverse();
678
+ douglasPeucker(raw, order, 0, last, maxDeviation, keep);
679
+ }
680
+
681
+ /** Whether point `a` comes before point `b`, by x and then by z. */
682
+ function lesser(raw: Int32Array, a: number, b: number): boolean {
683
+ const ax = raw[a * 2] as number;
684
+ const bx = raw[b * 2] as number;
685
+ return ax < bx || (ax === bx && (raw[a * 2 + 1] as number) < (raw[b * 2 + 1] as number));
686
+ }
621
687
 
688
+ /** Keep the furthest point of `order` between `lo` and `hi` while it is further than the bound. */
689
+ function douglasPeucker(
690
+ raw: Int32Array,
691
+ order: readonly number[],
692
+ lo: number,
693
+ hi: number,
694
+ maxDeviation: number,
695
+ keep: Uint8Array,
696
+ ): void {
697
+ if (hi - lo < 2) return;
698
+ const a = order[lo] as number;
699
+ const b = order[hi] as number;
622
700
  let worst = -1;
623
701
  let worstAt = -1;
624
- for (let at = from + 1; at < to; at += 1) {
702
+ for (let at = lo + 1; at < hi; at += 1) {
703
+ const point = order[at] as number;
625
704
  const d = distanceToSegment(
626
- raw[at * 2] as number,
627
- raw[at * 2 + 1] as number,
628
- raw[from * 2] as number,
629
- raw[from * 2 + 1] as number,
630
- raw[endIndex * 2] as number,
631
- raw[endIndex * 2 + 1] as number,
705
+ raw[point * 2] as number,
706
+ raw[point * 2 + 1] as number,
707
+ raw[a * 2] as number,
708
+ raw[a * 2 + 1] as number,
709
+ raw[b * 2] as number,
710
+ raw[b * 2 + 1] as number,
632
711
  );
633
712
  if (d > worst) {
634
713
  worst = d;
@@ -636,7 +715,7 @@ function chain(
636
715
  }
637
716
  }
638
717
  if (worst <= maxDeviation || worstAt < 0) return;
639
- keep[worstAt] = 1;
640
- chain(raw, from, worstAt, maxDeviation, keep);
641
- chain(raw, worstAt, to, maxDeviation, keep);
718
+ keep[order[worstAt] as number] = 1;
719
+ douglasPeucker(raw, order, lo, worstAt, maxDeviation, keep);
720
+ douglasPeucker(raw, order, worstAt, hi, maxDeviation, keep);
642
721
  }
package/src/voxelise.ts CHANGED
@@ -36,6 +36,16 @@ export interface VoxeliseSettings {
36
36
  readonly agentHeight: number;
37
37
  /** How far from an edge an agent's centre must stay. */
38
38
  readonly agentRadius: number;
39
+ /**
40
+ * How many cells of height an agent can step across, the number `buildRegions` takes; 1 when
41
+ * absent. A neighbour further above or below than this is an edge, and erosion keeps the agent's
42
+ * radius from it.
43
+ *
44
+ * **Here as well as there because the polygons are flat.** Two regions whose edges touch are
45
+ * joined by a portal whatever their heights, so a floor and a ledge above it must be kept apart
46
+ * by erosion, or a route goes over the ledge.
47
+ */
48
+ readonly maxStep?: number;
39
49
  }
40
50
 
41
51
  /** Words per span: floor cell, ceiling cell, walkable. */
@@ -103,12 +113,21 @@ export function spanWalkable(field: VoxelField, x: number, z: number, at: number
103
113
  return field.spans[base + 2] === 1;
104
114
  }
105
115
 
106
- /** One rasterised sample before the columns are packed. */
116
+ /**
117
+ * One rasterised sample before the columns are packed: a solid from `bottom` to `top`, in cells,
118
+ * and whether its top can be stood on. A floor is a solid of no height.
119
+ */
107
120
  interface Sample {
108
- floor: number;
121
+ bottom: number;
122
+ top: number;
109
123
  walkable: boolean;
110
124
  }
111
125
 
126
+ /** Scratch for clipping a triangle to one cell: four planes leave at most seven vertices. */
127
+ const CLIP_IN = new Float64Array(8 * 3);
128
+ const CLIP_OUT = new Float64Array(8 * 3);
129
+ const HEIGHTS = new Float64Array(2);
130
+
112
131
  /**
113
132
  * Rasterise, then decide.
114
133
  *
@@ -172,12 +191,33 @@ export function voxeliseWalkable(geometry: NavGeometry, settings: VoxeliseSettin
172
191
 
173
192
  for (let gz = loZ; gz <= hiZ; gz += 1) {
174
193
  for (let gx = loX; gx <= hiX; gx += 1) {
175
- const px = minX + (gx + 0.5) * settings.cellSize;
176
- const pz = minZ + (gz + 0.5) * settings.cellSize;
177
- const height = heightAt(ax, ay, az, bx, by, bz, cx, cy, cz, px, pz);
178
- if (height === null) continue;
179
- const floor = Math.floor((height - minY) / settings.cellHeight);
180
- columns[gx + gz * width]?.push({ floor, walkable });
194
+ if (walkable) {
195
+ const px = minX + (gx + 0.5) * settings.cellSize;
196
+ const pz = minZ + (gz + 0.5) * settings.cellSize;
197
+ const height = heightAt(ax, ay, az, bx, by, bz, cx, cy, cz, px, pz);
198
+ if (height === null) continue;
199
+ const floor = Math.floor((height - minY) / settings.cellHeight);
200
+ columns[gx + gz * width]?.push({ bottom: floor, top: floor, walkable });
201
+ continue;
202
+ }
203
+ /*
204
+ * **A face too steep to stand on is a solid, over every cell it crosses.** Sampled at the
205
+ * cell's centre like a floor, a wall is never met at all: it is vertical, so it covers no
206
+ * area seen from above, and a building on the ground read as a roof over open floor that a
207
+ * route went straight through. So the face is clipped to the cell's square, and the heights
208
+ * it spans there stand in the column as one solid, whose top is not walkable.
209
+ */
210
+ const x0 = minX + gx * settings.cellSize;
211
+ const z0 = minZ + gz * settings.cellSize;
212
+ CLIP_IN.set([ax, ay, az, bx, by, bz, cx, cy, cz]);
213
+ if (!clipToCell(x0, x0 + settings.cellSize, z0, z0 + settings.cellSize, HEIGHTS)) {
214
+ continue;
215
+ }
216
+ columns[gx + gz * width]?.push({
217
+ bottom: Math.floor(((HEIGHTS[0] as number) - minY) / settings.cellHeight),
218
+ top: Math.floor(((HEIGHTS[1] as number) - minY) / settings.cellHeight),
219
+ walkable: false,
220
+ });
181
221
  }
182
222
  }
183
223
  }
@@ -185,6 +225,64 @@ export function voxeliseWalkable(geometry: NavGeometry, settings: VoxeliseSettin
185
225
  return pack(columns, width, depth, origin, settings);
186
226
  }
187
227
 
228
+ /**
229
+ * The lowest and highest heights of the triangle in `CLIP_IN` within one cell's square, written to
230
+ * `out`; false where the triangle misses the cell.
231
+ *
232
+ * Sutherland–Hodgman against the square's four sides. Inclusive, so a wall lying exactly on a cell
233
+ * boundary still lands in the cell the bounding box chose for it.
234
+ */
235
+ function clipToCell(x0: number, x1: number, z0: number, z1: number, out: Float64Array): boolean {
236
+ let count = 3;
237
+ count = clipSide(CLIP_IN, count, CLIP_OUT, 0, x0, 1);
238
+ count = clipSide(CLIP_OUT, count, CLIP_IN, 0, x1, -1);
239
+ count = clipSide(CLIP_IN, count, CLIP_OUT, 2, z0, 1);
240
+ count = clipSide(CLIP_OUT, count, CLIP_IN, 2, z1, -1);
241
+ if (count === 0) return false;
242
+ let low = Infinity;
243
+ let high = -Infinity;
244
+ for (let at = 0; at < count; at += 1) {
245
+ const y = CLIP_IN[at * 3 + 1] as number;
246
+ low = Math.min(low, y);
247
+ high = Math.max(high, y);
248
+ }
249
+ out[0] = low;
250
+ out[1] = high;
251
+ return true;
252
+ }
253
+
254
+ /** Keep the part of a polygon on one side of `axis = bound`: above it for `side` 1, below for -1. */
255
+ function clipSide(
256
+ from: Float64Array,
257
+ count: number,
258
+ to: Float64Array,
259
+ axis: number,
260
+ bound: number,
261
+ side: number,
262
+ ): number {
263
+ let written = 0;
264
+ for (let at = 0; at < count; at += 1) {
265
+ const next = (at + 1) % count;
266
+ const here = ((from[at * 3 + axis] as number) - bound) * side;
267
+ const there = ((from[next * 3 + axis] as number) - bound) * side;
268
+ if (here >= 0) {
269
+ to[written * 3] = from[at * 3] as number;
270
+ to[written * 3 + 1] = from[at * 3 + 1] as number;
271
+ to[written * 3 + 2] = from[at * 3 + 2] as number;
272
+ written += 1;
273
+ }
274
+ if (here >= 0 !== there >= 0) {
275
+ const t = here / (here - there);
276
+ for (let k = 0; k < 3; k += 1) {
277
+ const a = from[at * 3 + k] as number;
278
+ to[written * 3 + k] = a + ((from[next * 3 + k] as number) - a) * t;
279
+ }
280
+ written += 1;
281
+ }
282
+ }
283
+ return written;
284
+ }
285
+
188
286
  /**
189
287
  * The triangle's height at (px, pz), or null where the point is outside it.
190
288
  *
@@ -232,20 +330,34 @@ function pack(
232
330
  const merged: number[][] = [];
233
331
 
234
332
  for (const samples of columns) {
235
- samples.sort((a, b) => a.floor - b.floor || Number(a.walkable) - Number(b.walkable));
333
+ samples.sort(
334
+ (a, b) => a.bottom - b.bottom || a.top - b.top || Number(a.walkable) - Number(b.walkable),
335
+ );
236
336
  const spans: number[] = [];
337
+ /* Where each span's solid starts, which is the ceiling of the span below it. */
338
+ const bottoms: number[] = [];
237
339
  for (const sample of samples) {
238
340
  const last = spans.length - SPAN_STRIDE;
239
- /* Samples within one cell of each other are one surface, not two. */
240
- if (last >= 0 && sample.floor - (spans[last] as number) <= 1) {
241
- spans[last + 2] = (spans[last + 2] as number) | (sample.walkable ? 1 : 0);
341
+ const top = spans[last] as number;
342
+ /*
343
+ * A sample starting within one cell of a span's top is part of that solid, not a new one.
344
+ * The span stands on the highest top, and is walkable if anything ending within a cell of
345
+ * that top is: a wall rising past a floor takes the floor over, and a floor's two triangles
346
+ * at one height agree.
347
+ */
348
+ if (last >= 0 && sample.bottom - top <= 1) {
349
+ const flag = sample.walkable ? 1 : 0;
350
+ if (sample.top > top + 1) spans[last + 2] = flag;
351
+ else if (sample.top >= top - 1) spans[last + 2] = (spans[last + 2] as number) | flag;
352
+ spans[last] = Math.max(top, sample.top);
242
353
  continue;
243
354
  }
244
- spans.push(sample.floor, Number.MAX_SAFE_INTEGER, sample.walkable ? 1 : 0);
355
+ spans.push(sample.top, Number.MAX_SAFE_INTEGER, sample.walkable ? 1 : 0);
356
+ bottoms.push(sample.bottom);
245
357
  }
246
- /* A span's ceiling is the next span's floor. The topmost has open sky. */
247
- for (let at = 0; at + SPAN_STRIDE < spans.length; at += SPAN_STRIDE) {
248
- spans[at + 1] = spans[at + SPAN_STRIDE] as number;
358
+ /* A span's ceiling is where the next solid starts. The topmost has open sky. */
359
+ for (let at = 0, next = 1; at + SPAN_STRIDE < spans.length; at += SPAN_STRIDE, next += 1) {
360
+ spans[at + 1] = bottoms[next] as number;
249
361
  if ((spans[at + 1] as number) - (spans[at] as number) < headroom) spans[at + 2] = 0;
250
362
  }
251
363
  merged.push(spans);
@@ -288,6 +400,12 @@ function pack(
288
400
  * doing it as that many single-cell passes keeps the whole thing integer arithmetic with no
289
401
  * distance to round. **Copied per pass**, because eroding in place erodes into what the same pass
290
402
  * just cleared and eats the mesh from its edges inward.
403
+ *
404
+ * **A neighbour is judged at this span's height, not anywhere in its column.** Asked whether a
405
+ * neighbouring column had any walkable span at all, a wall answered yes, with the roof on top of
406
+ * it, and the floor beside the wall kept no margin. A neighbour counts as open when it has a
407
+ * walkable span within `maxStep` of this one, a step the agent can take. Further than that is a
408
+ * wall, a ledge or a drop, and the agent keeps its radius from it.
291
409
  */
292
410
  function erode(
293
411
  columns: number[][],
@@ -296,22 +414,34 @@ function erode(
296
414
  settings: VoxeliseSettings,
297
415
  ): void {
298
416
  const steps = Math.ceil(settings.agentRadius / settings.cellSize);
417
+ const reach = settings.maxStep ?? 1;
299
418
  for (let pass = 0; pass < steps; pass += 1) {
300
419
  const before = columns.map((column) => [...column]);
301
- const open = (gx: number, gz: number): boolean => {
420
+ const open = (gx: number, gz: number, floor: number): boolean => {
302
421
  if (gx < 0 || gz < 0 || gx >= width || gz >= depth) return false;
303
422
  const column = before[gx + gz * width] as number[];
304
423
  for (let at = 0; at < column.length; at += SPAN_STRIDE) {
305
- if (column[at + 2] === 1) return true;
424
+ if (column[at + 2] === 1 && Math.abs((column[at] as number) - floor) <= reach) return true;
306
425
  }
307
426
  return false;
308
427
  };
309
428
 
310
429
  for (let gz = 0; gz < depth; gz += 1) {
311
430
  for (let gx = 0; gx < width; gx += 1) {
312
- if (open(gx - 1, gz) && open(gx + 1, gz) && open(gx, gz - 1) && open(gx, gz + 1)) continue;
313
431
  const column = columns[gx + gz * width] as number[];
314
- for (let at = 0; at < column.length; at += SPAN_STRIDE) column[at + 2] = 0;
432
+ for (let at = 0; at < column.length; at += SPAN_STRIDE) {
433
+ if (column[at + 2] !== 1) continue;
434
+ const floor = column[at] as number;
435
+ if (
436
+ open(gx - 1, gz, floor) &&
437
+ open(gx + 1, gz, floor) &&
438
+ open(gx, gz - 1, floor) &&
439
+ open(gx, gz + 1, floor)
440
+ ) {
441
+ continue;
442
+ }
443
+ column[at + 2] = 0;
444
+ }
315
445
  }
316
446
  }
317
447
  }