@driftengine/nav 4.0.0
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/LICENSE +202 -0
- package/NOTICE +29 -0
- package/README.md +63 -0
- package/dist/contour.d.ts +52 -0
- package/dist/contour.js +516 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +31 -0
- package/dist/polymesh.d.ts +55 -0
- package/dist/polymesh.js +234 -0
- package/dist/portalGraph.d.ts +36 -0
- package/dist/portalGraph.js +285 -0
- package/dist/query.d.ts +59 -0
- package/dist/query.js +336 -0
- package/dist/regions.d.ts +36 -0
- package/dist/regions.js +244 -0
- package/dist/testField.d.ts +18 -0
- package/dist/testField.js +73 -0
- package/dist/voxelise.d.ts +71 -0
- package/dist/voxelise.js +237 -0
- package/package.json +58 -0
- package/src/contour.ts +642 -0
- package/src/index.ts +43 -0
- package/src/polymesh.ts +301 -0
- package/src/portalGraph.ts +359 -0
- package/src/query.ts +369 -0
- package/src/regions.ts +308 -0
- package/src/testField.ts +97 -0
- package/src/voxelise.ts +318 -0
package/src/testField.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A voxel field written as a picture.
|
|
3
|
+
*
|
|
4
|
+
* Building one from `voxeliseWalkable` means expressing "two rooms joined by a corridor one cell
|
|
5
|
+
* wide" as geometry and hoping the rasteriser agrees. Written as a picture it is the thing being
|
|
6
|
+
* tested, exactly, and a reader can see the corridor.
|
|
7
|
+
*
|
|
8
|
+
* `.` is walkable ground, `#` is nothing at all, and a digit is walkable ground that many cells up.
|
|
9
|
+
*
|
|
10
|
+
* **Shipped rather than excluded**, and `scripts/packages.test.mjs` is why: a file a package tracks
|
|
11
|
+
* has to reach a consumer, or the repository is carrying something nobody can use. Keeping it out
|
|
12
|
+
* of the barrel keeps it out of the public surface, and a consumer testing its own integration can
|
|
13
|
+
* reach it by path — which is a better answer than deleting a fixture builder that works.
|
|
14
|
+
*/
|
|
15
|
+
import type { VoxelField } from './voxelise.ts';
|
|
16
|
+
|
|
17
|
+
const SPAN_STRIDE = 3;
|
|
18
|
+
|
|
19
|
+
export function fieldFromMap(rows: readonly string[]): VoxelField {
|
|
20
|
+
const depth = rows.length;
|
|
21
|
+
const width = depth === 0 ? 0 : (rows[0] as string).length;
|
|
22
|
+
const columns: number[][] = [];
|
|
23
|
+
|
|
24
|
+
for (let z = 0; z < depth; z += 1) {
|
|
25
|
+
const row = rows[z] as string;
|
|
26
|
+
for (let x = 0; x < width; x += 1) {
|
|
27
|
+
const ch = row[x] ?? '#';
|
|
28
|
+
if (ch === '#') {
|
|
29
|
+
columns.push([]);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const floor = ch === '.' ? 0 : Number.parseInt(ch, 10);
|
|
33
|
+
columns.push([floor, Number.MAX_SAFE_INTEGER, 1]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const columnStart = new Uint32Array(width * depth + 1);
|
|
38
|
+
let total = 0;
|
|
39
|
+
for (let at = 0; at < columns.length; at += 1) {
|
|
40
|
+
columnStart[at] = total;
|
|
41
|
+
total += (columns[at] as number[]).length;
|
|
42
|
+
}
|
|
43
|
+
columnStart[width * depth] = total;
|
|
44
|
+
|
|
45
|
+
const spans = new Int32Array(total);
|
|
46
|
+
let write = 0;
|
|
47
|
+
for (const column of columns) {
|
|
48
|
+
for (const value of column) {
|
|
49
|
+
spans[write] = value;
|
|
50
|
+
write += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
width,
|
|
56
|
+
depth,
|
|
57
|
+
cellSize: 1,
|
|
58
|
+
cellHeight: 1,
|
|
59
|
+
origin: new Float64Array(3),
|
|
60
|
+
columnStart,
|
|
61
|
+
spans,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Two spans stacked in one column, for the overhang cases. */
|
|
66
|
+
export function stackedField(rows: readonly string[], upper: number): VoxelField {
|
|
67
|
+
const base = fieldFromMap(rows);
|
|
68
|
+
const columns: number[][] = [];
|
|
69
|
+
for (let at = 0; at < base.width * base.depth; at += 1) {
|
|
70
|
+
const from = base.columnStart[at] as number;
|
|
71
|
+
const to = base.columnStart[at + 1] as number;
|
|
72
|
+
const column: number[] = [];
|
|
73
|
+
for (let i = from; i < to; i += SPAN_STRIDE) {
|
|
74
|
+
column.push(base.spans[i] as number, upper, 1);
|
|
75
|
+
column.push(upper, Number.MAX_SAFE_INTEGER, 1);
|
|
76
|
+
}
|
|
77
|
+
columns.push(column);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const columnStart = new Uint32Array(base.width * base.depth + 1);
|
|
81
|
+
let total = 0;
|
|
82
|
+
for (let at = 0; at < columns.length; at += 1) {
|
|
83
|
+
columnStart[at] = total;
|
|
84
|
+
total += (columns[at] as number[]).length;
|
|
85
|
+
}
|
|
86
|
+
columnStart[base.width * base.depth] = total;
|
|
87
|
+
|
|
88
|
+
const spans = new Int32Array(total);
|
|
89
|
+
let write = 0;
|
|
90
|
+
for (const column of columns) {
|
|
91
|
+
for (const value of column) {
|
|
92
|
+
spans[write] = value;
|
|
93
|
+
write += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { ...base, columnStart, spans };
|
|
97
|
+
}
|
package/src/voxelise.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry to a field of where an agent of a given size could stand.
|
|
3
|
+
*
|
|
4
|
+
* **Why the refusal reopens, and it is the module's own words.** `core/src/nav/navGraph.ts` says in
|
|
5
|
+
* its header that it is a graph rather than a mesh, that this is a decision rather than a first
|
|
6
|
+
* step, and that *"if a world's walkable space genuinely is a region, this is the wrong structure
|
|
7
|
+
* and a mesh is the row that is still open."* A streamed open world is that world. The module named
|
|
8
|
+
* its own trigger; this pulls it. The graph is not replaced — it stays right for roads, corridors
|
|
9
|
+
* and docking lanes, which is what consumers kept arriving with.
|
|
10
|
+
*
|
|
11
|
+
* **A voxel field and not a heightfield, and an overhang is the whole reason.** A bridge over a
|
|
12
|
+
* path is two walkable surfaces at one horizontal position. A heightfield holds one of them, so an
|
|
13
|
+
* agent under the bridge is either standing on the bridge or standing in the void — and which of
|
|
14
|
+
* those it is depends on which triangle happened to be rasterised last.
|
|
15
|
+
*
|
|
16
|
+
* **The agent's size is applied here rather than at query time.** Eroding at query time means every
|
|
17
|
+
* query pays for it and every query can get it wrong. Eroding here means the mesh *is* the space
|
|
18
|
+
* that agent can occupy, which is what a navigation mesh is for.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface NavGeometry {
|
|
22
|
+
/** xyz per vertex. */
|
|
23
|
+
readonly positions: Float32Array;
|
|
24
|
+
/** Three per triangle. */
|
|
25
|
+
readonly indices: Uint32Array;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface VoxeliseSettings {
|
|
29
|
+
/** Horizontal voxel size, world units. */
|
|
30
|
+
readonly cellSize: number;
|
|
31
|
+
/** Vertical voxel size, world units. */
|
|
32
|
+
readonly cellHeight: number;
|
|
33
|
+
/** The steepest surface an agent can stand on, in degrees. */
|
|
34
|
+
readonly maxSlope: number;
|
|
35
|
+
/** How much headroom an agent needs above the floor. */
|
|
36
|
+
readonly agentHeight: number;
|
|
37
|
+
/** How far from an edge an agent's centre must stay. */
|
|
38
|
+
readonly agentRadius: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Words per span: floor cell, ceiling cell, walkable. */
|
|
42
|
+
const SPAN_STRIDE = 3;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Walkable surfaces as spans, in columns on a horizontal grid.
|
|
46
|
+
*
|
|
47
|
+
* Structure of arrays and CSR columns, matching `NavGraph` in core: a span is three integers and a
|
|
48
|
+
* column is a slice, so building a region out of this walks memory in order and allocates nothing
|
|
49
|
+
* per span.
|
|
50
|
+
*/
|
|
51
|
+
export interface VoxelField {
|
|
52
|
+
readonly width: number;
|
|
53
|
+
readonly depth: number;
|
|
54
|
+
readonly cellSize: number;
|
|
55
|
+
readonly cellHeight: number;
|
|
56
|
+
/** World position of cell (0, 0)'s minimum corner: x, y, z. */
|
|
57
|
+
readonly origin: Float64Array;
|
|
58
|
+
/** Column `x + z * width`'s spans are `[columnStart[i], columnStart[i + 1])`. */
|
|
59
|
+
readonly columnStart: Uint32Array;
|
|
60
|
+
/** Three words per span. See `SPAN_STRIDE`. */
|
|
61
|
+
readonly spans: Int32Array;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const EMPTY: VoxelField = {
|
|
65
|
+
width: 0,
|
|
66
|
+
depth: 0,
|
|
67
|
+
cellSize: 0,
|
|
68
|
+
cellHeight: 0,
|
|
69
|
+
origin: new Float64Array(3),
|
|
70
|
+
columnStart: new Uint32Array(1),
|
|
71
|
+
spans: new Int32Array(0),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The column index for a world position, or `-1` outside the field. */
|
|
75
|
+
export function columnAt(field: VoxelField, x: number, z: number): number {
|
|
76
|
+
if (field.width === 0) return -1;
|
|
77
|
+
const cx = Math.floor((x - (field.origin[0] as number)) / field.cellSize);
|
|
78
|
+
const cz = Math.floor((z - (field.origin[2] as number)) / field.cellSize);
|
|
79
|
+
if (cx < 0 || cz < 0 || cx >= field.width || cz >= field.depth) return -1;
|
|
80
|
+
return cx + cz * field.width;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function spanCount(field: VoxelField, x: number, z: number): number {
|
|
84
|
+
if (x < 0 || z < 0 || x >= field.width || z >= field.depth) return 0;
|
|
85
|
+
const column = x + z * field.width;
|
|
86
|
+
return (
|
|
87
|
+
((field.columnStart[column + 1] as number) - (field.columnStart[column] as number)) /
|
|
88
|
+
SPAN_STRIDE
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The world height of a span's floor, by column index and span number. */
|
|
93
|
+
export function spanFloor(field: VoxelField, column: number, at: number): number {
|
|
94
|
+
const base = (field.columnStart[column] as number) + at * SPAN_STRIDE;
|
|
95
|
+
return (field.origin[1] as number) + (field.spans[base] as number) * field.cellHeight;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function spanWalkable(field: VoxelField, x: number, z: number, at: number): boolean {
|
|
99
|
+
if (x < 0 || z < 0 || x >= field.width || z >= field.depth) return false;
|
|
100
|
+
const column = x + z * field.width;
|
|
101
|
+
const base = (field.columnStart[column] as number) + at * SPAN_STRIDE;
|
|
102
|
+
if (base + 2 >= (field.columnStart[column + 1] as number) + SPAN_STRIDE) return false;
|
|
103
|
+
return field.spans[base + 2] === 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One rasterised sample before the columns are packed. */
|
|
107
|
+
interface Sample {
|
|
108
|
+
floor: number;
|
|
109
|
+
walkable: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Rasterise, then decide.
|
|
114
|
+
*
|
|
115
|
+
* **Three passes and not one**, because each answers a question the previous one could not: what is
|
|
116
|
+
* solid, what has headroom, and what an agent's width reaches. Slope is decided per triangle at
|
|
117
|
+
* rasterisation because that is the only place the triangle's normal is known; headroom needs the
|
|
118
|
+
* whole column; and erosion needs the neighbours' answers, so it cannot run until they exist.
|
|
119
|
+
*/
|
|
120
|
+
export function voxeliseWalkable(geometry: NavGeometry, settings: VoxeliseSettings): VoxelField {
|
|
121
|
+
const triangles = geometry.indices.length / 3;
|
|
122
|
+
if (triangles === 0 || settings.cellSize <= 0 || settings.cellHeight <= 0) return EMPTY;
|
|
123
|
+
|
|
124
|
+
let minX = Infinity;
|
|
125
|
+
let minY = Infinity;
|
|
126
|
+
let minZ = Infinity;
|
|
127
|
+
let maxX = -Infinity;
|
|
128
|
+
let maxZ = -Infinity;
|
|
129
|
+
for (let at = 0; at < geometry.positions.length; at += 3) {
|
|
130
|
+
minX = Math.min(minX, geometry.positions[at] as number);
|
|
131
|
+
minY = Math.min(minY, geometry.positions[at + 1] as number);
|
|
132
|
+
minZ = Math.min(minZ, geometry.positions[at + 2] as number);
|
|
133
|
+
maxX = Math.max(maxX, geometry.positions[at] as number);
|
|
134
|
+
maxZ = Math.max(maxZ, geometry.positions[at + 2] as number);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const width = Math.max(1, Math.ceil((maxX - minX) / settings.cellSize) + 1);
|
|
138
|
+
const depth = Math.max(1, Math.ceil((maxZ - minZ) / settings.cellSize) + 1);
|
|
139
|
+
const origin = new Float64Array([minX, minY, minZ]);
|
|
140
|
+
const columns: Sample[][] = Array.from({ length: width * depth }, () => []);
|
|
141
|
+
|
|
142
|
+
// determinism: build-time — the slope limit is read once per bake
|
|
143
|
+
const cosLimit = Math.cos((settings.maxSlope * Math.PI) / 180);
|
|
144
|
+
|
|
145
|
+
for (let tri = 0; tri < triangles; tri += 1) {
|
|
146
|
+
const ia = (geometry.indices[tri * 3] as number) * 3;
|
|
147
|
+
const ib = (geometry.indices[tri * 3 + 1] as number) * 3;
|
|
148
|
+
const ic = (geometry.indices[tri * 3 + 2] as number) * 3;
|
|
149
|
+
|
|
150
|
+
const ax = geometry.positions[ia] as number;
|
|
151
|
+
const ay = geometry.positions[ia + 1] as number;
|
|
152
|
+
const az = geometry.positions[ia + 2] as number;
|
|
153
|
+
const bx = geometry.positions[ib] as number;
|
|
154
|
+
const by = geometry.positions[ib + 1] as number;
|
|
155
|
+
const bz = geometry.positions[ib + 2] as number;
|
|
156
|
+
const cx = geometry.positions[ic] as number;
|
|
157
|
+
const cy = geometry.positions[ic + 1] as number;
|
|
158
|
+
const cz = geometry.positions[ic + 2] as number;
|
|
159
|
+
|
|
160
|
+
/* The normal's y against its length is the cosine of the slope, which is the whole slope test. */
|
|
161
|
+
const nx = (by - ay) * (cz - az) - (bz - az) * (cy - ay);
|
|
162
|
+
const ny = (bz - az) * (cx - ax) - (bx - ax) * (cz - az);
|
|
163
|
+
const nz = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
|
|
164
|
+
const length = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
|
165
|
+
if (length === 0) continue;
|
|
166
|
+
const walkable = Math.abs(ny) / length >= cosLimit;
|
|
167
|
+
|
|
168
|
+
const loX = Math.max(0, Math.floor((Math.min(ax, bx, cx) - minX) / settings.cellSize));
|
|
169
|
+
const hiX = Math.min(width - 1, Math.floor((Math.max(ax, bx, cx) - minX) / settings.cellSize));
|
|
170
|
+
const loZ = Math.max(0, Math.floor((Math.min(az, bz, cz) - minZ) / settings.cellSize));
|
|
171
|
+
const hiZ = Math.min(depth - 1, Math.floor((Math.max(az, bz, cz) - minZ) / settings.cellSize));
|
|
172
|
+
|
|
173
|
+
for (let gz = loZ; gz <= hiZ; gz += 1) {
|
|
174
|
+
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 });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return pack(columns, width, depth, origin, settings);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The triangle's height at (px, pz), or null where the point is outside it.
|
|
190
|
+
*
|
|
191
|
+
* Barycentric on the xz projection. A degenerate triangle — one edge-on to the grid — has zero
|
|
192
|
+
* area there and is skipped: it covers no column, so there is nothing for it to contribute.
|
|
193
|
+
*/
|
|
194
|
+
function heightAt(
|
|
195
|
+
ax: number,
|
|
196
|
+
ay: number,
|
|
197
|
+
az: number,
|
|
198
|
+
bx: number,
|
|
199
|
+
by: number,
|
|
200
|
+
bz: number,
|
|
201
|
+
cx: number,
|
|
202
|
+
cy: number,
|
|
203
|
+
cz: number,
|
|
204
|
+
px: number,
|
|
205
|
+
pz: number,
|
|
206
|
+
): number | null {
|
|
207
|
+
const area = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz);
|
|
208
|
+
if (Math.abs(area) < 1e-12) return null;
|
|
209
|
+
const u = ((bz - cz) * (px - cx) + (cx - bx) * (pz - cz)) / area;
|
|
210
|
+
const v = ((cz - az) * (px - cx) + (ax - cx) * (pz - cz)) / area;
|
|
211
|
+
const w = 1 - u - v;
|
|
212
|
+
const slack = -1e-6;
|
|
213
|
+
if (u < slack || v < slack || w < slack) return null;
|
|
214
|
+
return u * ay + v * by + w * cy;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Merge samples into spans, apply headroom, then erode by the agent's radius.
|
|
219
|
+
*
|
|
220
|
+
* **Headroom before erosion**, because a floor with no headroom is not walkable at all and eroding
|
|
221
|
+
* around it would carve a margin into ground that was already unwalkable — which shows up as a
|
|
222
|
+
* navigation mesh with a hole larger than the obstacle that made it.
|
|
223
|
+
*/
|
|
224
|
+
function pack(
|
|
225
|
+
columns: Sample[][],
|
|
226
|
+
width: number,
|
|
227
|
+
depth: number,
|
|
228
|
+
origin: Float64Array,
|
|
229
|
+
settings: VoxeliseSettings,
|
|
230
|
+
): VoxelField {
|
|
231
|
+
const headroom = Math.ceil(settings.agentHeight / settings.cellHeight);
|
|
232
|
+
const merged: number[][] = [];
|
|
233
|
+
|
|
234
|
+
for (const samples of columns) {
|
|
235
|
+
samples.sort((a, b) => a.floor - b.floor || Number(a.walkable) - Number(b.walkable));
|
|
236
|
+
const spans: number[] = [];
|
|
237
|
+
for (const sample of samples) {
|
|
238
|
+
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);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
spans.push(sample.floor, Number.MAX_SAFE_INTEGER, sample.walkable ? 1 : 0);
|
|
245
|
+
}
|
|
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;
|
|
249
|
+
if ((spans[at + 1] as number) - (spans[at] as number) < headroom) spans[at + 2] = 0;
|
|
250
|
+
}
|
|
251
|
+
merged.push(spans);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
erode(merged, width, depth, settings);
|
|
255
|
+
|
|
256
|
+
const columnStart = new Uint32Array(width * depth + 1);
|
|
257
|
+
let total = 0;
|
|
258
|
+
for (let at = 0; at < merged.length; at += 1) {
|
|
259
|
+
columnStart[at] = total;
|
|
260
|
+
total += (merged[at] as number[]).length;
|
|
261
|
+
}
|
|
262
|
+
columnStart[width * depth] = total;
|
|
263
|
+
|
|
264
|
+
const spans = new Int32Array(total);
|
|
265
|
+
let write = 0;
|
|
266
|
+
for (const column of merged) {
|
|
267
|
+
for (const value of column) {
|
|
268
|
+
spans[write] = value;
|
|
269
|
+
write += 1;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
width,
|
|
275
|
+
depth,
|
|
276
|
+
cellSize: settings.cellSize,
|
|
277
|
+
cellHeight: settings.cellHeight,
|
|
278
|
+
origin,
|
|
279
|
+
columnStart,
|
|
280
|
+
spans,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Clear walkability within the agent's radius of anything that is not walkable.
|
|
286
|
+
*
|
|
287
|
+
* A distance transform would be exact and is not needed: the radius is a handful of cells, and
|
|
288
|
+
* doing it as that many single-cell passes keeps the whole thing integer arithmetic with no
|
|
289
|
+
* distance to round. **Copied per pass**, because eroding in place erodes into what the same pass
|
|
290
|
+
* just cleared and eats the mesh from its edges inward.
|
|
291
|
+
*/
|
|
292
|
+
function erode(
|
|
293
|
+
columns: number[][],
|
|
294
|
+
width: number,
|
|
295
|
+
depth: number,
|
|
296
|
+
settings: VoxeliseSettings,
|
|
297
|
+
): void {
|
|
298
|
+
const steps = Math.ceil(settings.agentRadius / settings.cellSize);
|
|
299
|
+
for (let pass = 0; pass < steps; pass += 1) {
|
|
300
|
+
const before = columns.map((column) => [...column]);
|
|
301
|
+
const open = (gx: number, gz: number): boolean => {
|
|
302
|
+
if (gx < 0 || gz < 0 || gx >= width || gz >= depth) return false;
|
|
303
|
+
const column = before[gx + gz * width] as number[];
|
|
304
|
+
for (let at = 0; at < column.length; at += SPAN_STRIDE) {
|
|
305
|
+
if (column[at + 2] === 1) return true;
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
for (let gz = 0; gz < depth; gz += 1) {
|
|
311
|
+
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
|
+
const column = columns[gx + gz * width] as number[];
|
|
314
|
+
for (let at = 0; at < column.length; at += SPAN_STRIDE) column[at + 2] = 0;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|