@threenative/terrain 0.0.0-stage → 0.1.1

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.
Files changed (125) hide show
  1. package/AGENT_GUIDE.md +194 -0
  2. package/LICENSE +21 -0
  3. package/README.md +71 -2
  4. package/THIRD_PARTY_NOTICES.md +38 -0
  5. package/dist/editor/index.d.ts +231 -0
  6. package/dist/editor/index.js +5624 -0
  7. package/dist/editor/server.d.ts +3 -0
  8. package/dist/editor/server.js +3688 -0
  9. package/dist/editor/worker.d.ts +2 -0
  10. package/dist/editor/worker.js +2212 -0
  11. package/dist/export.d.ts +54 -0
  12. package/dist/export.js +297 -0
  13. package/dist/index.d.ts +335 -0
  14. package/dist/index.js +2927 -0
  15. package/dist/server-ByM2hmYJ.d.ts +630 -0
  16. package/dist/terrain-gPnkCU6S.d.ts +141 -0
  17. package/dist/three.d.ts +14 -0
  18. package/dist/three.js +40 -0
  19. package/dist/types-Ant8-Axc.d.ts +486 -0
  20. package/package.json +86 -3
  21. package/starter/alpine/README.md +29 -0
  22. package/starter/alpine/assets.json +12 -0
  23. package/starter/alpine/bake.mjs +118 -0
  24. package/starter/alpine/recipe.json +66167 -0
  25. package/starter/alpine/sky.ts +23 -0
  26. package/starter/alpine/surface.json +53 -0
  27. package/starter/alpine/world.ts +335 -0
  28. package/starter/coastal/README.md +37 -0
  29. package/starter/coastal/assets.json +10 -0
  30. package/starter/coastal/bake.mjs +118 -0
  31. package/starter/coastal/recipe.json +66161 -0
  32. package/starter/coastal/sea.ts +327 -0
  33. package/starter/coastal/sky.ts +24 -0
  34. package/starter/coastal/surface.json +41 -0
  35. package/starter/coastal/world.ts +386 -0
  36. package/starter/desert/README.md +35 -0
  37. package/starter/desert/assets.json +12 -0
  38. package/starter/desert/bake.mjs +118 -0
  39. package/starter/desert/recipe.json +66156 -0
  40. package/starter/desert/sky.ts +23 -0
  41. package/starter/desert/surface.json +54 -0
  42. package/starter/desert/world.ts +335 -0
  43. package/starter/forest/README.md +46 -0
  44. package/starter/forest/assets.json +32 -0
  45. package/starter/forest/bake.mjs +118 -0
  46. package/starter/forest/recipe.json +4586 -0
  47. package/starter/forest/sky.ts +27 -0
  48. package/starter/forest/surface.json +79 -0
  49. package/starter/forest/water.ts +266 -0
  50. package/starter/forest/world.ts +413 -0
  51. package/starter/tundra/README.md +29 -0
  52. package/starter/tundra/assets.json +12 -0
  53. package/starter/tundra/bake.mjs +118 -0
  54. package/starter/tundra/recipe.json +66325 -0
  55. package/starter/tundra/sky.ts +27 -0
  56. package/starter/tundra/surface.json +54 -0
  57. package/starter/tundra/water.ts +313 -0
  58. package/starter/tundra/world.ts +366 -0
  59. package/starter-assets/bark_brown_02/bark_brown_02_diff_1k.jpg +0 -0
  60. package/starter-assets/bark_brown_02/bark_brown_02_nor_gl_1k.jpg +0 -0
  61. package/starter-assets/bark_brown_02/bark_brown_02_rough_1k.jpg +0 -0
  62. package/starter-assets/boulder_01/boulder-mid.glb +0 -0
  63. package/starter-assets/boulder_01/boulder-near.glb +0 -0
  64. package/starter-assets/cliff_side/cliff_side_ao_1k.jpg +0 -0
  65. package/starter-assets/cliff_side/cliff_side_diff_1k.jpg +0 -0
  66. package/starter-assets/cliff_side/cliff_side_nor_gl_1k.jpg +0 -0
  67. package/starter-assets/cliff_side/cliff_side_rough_1k.jpg +0 -0
  68. package/starter-assets/credits.json +1022 -0
  69. package/starter-assets/fern_02/fern_02_alpha_512.png +0 -0
  70. package/starter-assets/fern_02/fern_02_diff_512.jpg +0 -0
  71. package/starter-assets/fern_02/fern_02_nor_gl_512.jpg +0 -0
  72. package/starter-assets/fir_tree_01/fir-b-lod1.glb +0 -0
  73. package/starter-assets/fir_tree_01/fir-b-lod2.glb +0 -0
  74. package/starter-assets/fir_tree_01/fir-c-lod1.glb +0 -0
  75. package/starter-assets/fir_tree_01/fir-c-lod2.glb +0 -0
  76. package/starter-assets/fir_tree_01/fir_tree_01_twig_arm_1k.jpg +0 -0
  77. package/starter-assets/fir_tree_01/fir_tree_01_twig_diff_1k.jpg +0 -0
  78. package/starter-assets/fir_tree_01/fir_tree_01_twig_nor_gl_1k.jpg +0 -0
  79. package/starter-assets/forest_ground_04/forest_ground_04_ao_1k.jpg +0 -0
  80. package/starter-assets/forest_ground_04/forest_ground_04_diff_1k.jpg +0 -0
  81. package/starter-assets/forest_ground_04/forest_ground_04_nor_gl_1k.jpg +0 -0
  82. package/starter-assets/forest_ground_04/forest_ground_04_rough_1k.jpg +0 -0
  83. package/starter-assets/forest_leaves_02/forest_leaves_02_arm_512.jpg +0 -0
  84. package/starter-assets/forest_leaves_02/forest_leaves_02_diff_512.jpg +0 -0
  85. package/starter-assets/forest_leaves_02/forest_leaves_02_nor_gl_512.jpg +0 -0
  86. package/starter-assets/grass_medium_02/grass_medium_02_alpha_512.png +0 -0
  87. package/starter-assets/grass_medium_02/grass_medium_02_diff_512.jpg +0 -0
  88. package/starter-assets/grass_medium_02/grass_medium_02_nor_gl_512.jpg +0 -0
  89. package/starter-assets/hdri/kloofendal_48d_partly_cloudy_1k.hdr +0 -0
  90. package/starter-assets/leafy_grass/leafy_grass_ao_1k.jpg +0 -0
  91. package/starter-assets/leafy_grass/leafy_grass_diff_1k.jpg +0 -0
  92. package/starter-assets/leafy_grass/leafy_grass_nor_gl_1k.jpg +0 -0
  93. package/starter-assets/leafy_grass/leafy_grass_rough_1k.jpg +0 -0
  94. package/starter-assets/lichen_rock/lichen_rock_arm_512.jpg +0 -0
  95. package/starter-assets/lichen_rock/lichen_rock_diff_512.jpg +0 -0
  96. package/starter-assets/lichen_rock/lichen_rock_nor_gl_512.jpg +0 -0
  97. package/starter-assets/mossy_rock/mossy_rock_ao_1k.jpg +0 -0
  98. package/starter-assets/mossy_rock/mossy_rock_diff_1k.jpg +0 -0
  99. package/starter-assets/mossy_rock/mossy_rock_nor_gl_1k.jpg +0 -0
  100. package/starter-assets/mossy_rock/mossy_rock_rough_1k.jpg +0 -0
  101. package/starter-assets/needle-atlas.png +0 -0
  102. package/starter-assets/needle-surface.png +0 -0
  103. package/starter-assets/river_small_rocks/river_small_rocks_arm_512.jpg +0 -0
  104. package/starter-assets/river_small_rocks/river_small_rocks_diff_512.jpg +0 -0
  105. package/starter-assets/river_small_rocks/river_small_rocks_nor_gl_512.jpg +0 -0
  106. package/starter-assets/rock_boulder_dry/rock_boulder_dry_arm_512.jpg +0 -0
  107. package/starter-assets/rock_boulder_dry/rock_boulder_dry_diff_512.jpg +0 -0
  108. package/starter-assets/rock_boulder_dry/rock_boulder_dry_nor_gl_512.jpg +0 -0
  109. package/starter-assets/rocks/boulder-mid.glb +0 -0
  110. package/starter-assets/rocks/boulder-near.glb +0 -0
  111. package/starter-assets/rocks/boulder-prep.json +37 -0
  112. package/starter-assets/rocks/rock01-mid.glb +0 -0
  113. package/starter-assets/rocks/rock01-near.glb +0 -0
  114. package/starter-assets/rocks/rock01-prep.json +37 -0
  115. package/starter-assets/rocks/rock04-mid.glb +0 -0
  116. package/starter-assets/rocks/rock04-near.glb +0 -0
  117. package/starter-assets/rocks/rock04-prep.json +37 -0
  118. package/starter-assets/sand_01/sand_01_ao_1k.jpg +0 -0
  119. package/starter-assets/sand_01/sand_01_diff_1k.jpg +0 -0
  120. package/starter-assets/sand_01/sand_01_nor_gl_1k.jpg +0 -0
  121. package/starter-assets/sand_01/sand_01_rough_1k.jpg +0 -0
  122. package/starter-assets/snow_02/snow_02_ao_1k.jpg +0 -0
  123. package/starter-assets/snow_02/snow_02_diff_1k.jpg +0 -0
  124. package/starter-assets/snow_02/snow_02_nor_gl_1k.jpg +0 -0
  125. package/starter-assets/snow_02/snow_02_rough_1k.jpg +0 -0
@@ -0,0 +1,3688 @@
1
+ import { randomUUID, createHash } from 'crypto';
2
+ import { lstatSync, readFileSync, writeFileSync, renameSync, unlinkSync, openSync, fstatSync, readSync, closeSync, mkdirSync } from 'fs';
3
+ import { isAbsolute, resolve, dirname, basename, join } from 'path';
4
+ import { Vector3, Matrix4, Quaternion } from 'three';
5
+
6
+ // src/editor/server.ts
7
+
8
+ // src/core/math.ts
9
+ var clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
10
+ var lerp = (a, b, t) => a + (b - a) * t;
11
+ var smoothstep = (a, b, x) => {
12
+ if (a === b) return x >= b ? 1 : 0;
13
+ const t = clamp((x - a) / (b - a));
14
+ return t * t * (3 - 2 * t);
15
+ };
16
+ var hashString = (text3) => {
17
+ let h = 2166136261;
18
+ for (const c of text3) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
19
+ return h >>> 0;
20
+ };
21
+ function random(seed) {
22
+ let a = seed >>> 0;
23
+ return () => {
24
+ a |= 0;
25
+ a = a + 1831565813 | 0;
26
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
27
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
28
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
29
+ };
30
+ }
31
+ var hash2 = (x, z, seed) => {
32
+ let n = Math.imul(x | 0, 374761393) ^ Math.imul(z | 0, 668265263) ^ seed;
33
+ n = Math.imul(n ^ n >>> 13, 1274126177);
34
+ return (n ^ n >>> 16) >>> 0;
35
+ };
36
+ var diagonalGradient = 0.7071;
37
+ var grad = (h, x, z) => {
38
+ switch (h & 7) {
39
+ case 0:
40
+ return x;
41
+ case 1:
42
+ return -x;
43
+ case 2:
44
+ return z;
45
+ case 3:
46
+ return -z;
47
+ case 4:
48
+ return (x + z) * diagonalGradient;
49
+ case 5:
50
+ return (x - z) * diagonalGradient;
51
+ case 6:
52
+ return (-x + z) * diagonalGradient;
53
+ default:
54
+ return (-x - z) * diagonalGradient;
55
+ }
56
+ };
57
+ function noise2(x, z, seed = 1) {
58
+ const ix = Math.floor(x);
59
+ const iz = Math.floor(z);
60
+ const fx = x - ix;
61
+ const fz = z - iz;
62
+ const u = fx * fx * fx * (fx * (fx * 6 - 15) + 10);
63
+ const v = fz * fz * fz * (fz * (fz * 6 - 15) + 10);
64
+ return lerp(
65
+ lerp(grad(hash2(ix, iz, seed), fx, fz), grad(hash2(ix + 1, iz, seed), fx - 1, fz), u),
66
+ lerp(
67
+ grad(hash2(ix, iz + 1, seed), fx, fz - 1),
68
+ grad(hash2(ix + 1, iz + 1, seed), fx - 1, fz - 1),
69
+ u
70
+ ),
71
+ v
72
+ ) * 1.65;
73
+ }
74
+ function fbm(inputX, inputZ, { seed = 1, octaves = 5, persistence = 0.5, lacunarity = 2, mode = "fbm" } = {}) {
75
+ let x = inputX;
76
+ let z = inputZ;
77
+ let value = 0;
78
+ let amplitude = 1;
79
+ let total = 0;
80
+ for (let i = 0; i < octaves; i += 1) {
81
+ let n = noise2(x + 13.17, z - 8.71, seed + i * 71);
82
+ if (mode === "ridged") n = (1 - Math.abs(n)) ** 2 * 2 - 1;
83
+ if (mode === "billow") n = Math.abs(n) * 2 - 1;
84
+ value += n * amplitude;
85
+ total += amplitude;
86
+ amplitude *= persistence;
87
+ x *= lacunarity;
88
+ z *= lacunarity;
89
+ }
90
+ return total ? value / total : 0;
91
+ }
92
+ var world = (grid, x) => x / (grid.resolution - 1) * grid.size - grid.size / 2;
93
+ function sampleGrid(a, width, height, u, v) {
94
+ const gx = clamp(u, 0, 1) * (width - 1);
95
+ const gz = clamp(v, 0, 1) * (height - 1);
96
+ const x = Math.min(width - 2, Math.floor(gx));
97
+ const z = Math.min(height - 2, Math.floor(gz));
98
+ const tx = gx - x;
99
+ const tz = gz - z;
100
+ return lerp(
101
+ lerp(a[z * width + x], a[z * width + x + 1], tx),
102
+ lerp(a[(z + 1) * width + x], a[(z + 1) * width + x + 1], tx),
103
+ tz
104
+ );
105
+ }
106
+ var sampleHeight = (grid, x, z) => sampleGrid(
107
+ grid.height,
108
+ grid.resolution,
109
+ grid.resolution,
110
+ x / grid.size + 0.5,
111
+ z / grid.size + 0.5
112
+ );
113
+ function gradientAt(grid, x, z) {
114
+ const d = grid.size / (grid.resolution - 1);
115
+ return [
116
+ (sampleHeight(grid, x + d, z) - sampleHeight(grid, x - d, z)) / (Math.min(grid.size / 2, x + d) - Math.max(-grid.size / 2, x - d) || d),
117
+ (sampleHeight(grid, x, z + d) - sampleHeight(grid, x, z - d)) / (Math.min(grid.size / 2, z + d) - Math.max(-grid.size / 2, z - d) || d)
118
+ ];
119
+ }
120
+ function slopeAtIndex(grid, i) {
121
+ const n = grid.resolution;
122
+ const x = i % n;
123
+ const z = Math.floor(i / n);
124
+ const dx = x > 0 && x < n - 1 ? 2 : 1;
125
+ const dz = z > 0 && z < n - 1 ? 2 : 1;
126
+ const cell = grid.size / (n - 1);
127
+ const gx = (grid.height[z * n + Math.min(x + 1, n - 1)] - grid.height[z * n + Math.max(x - 1, 0)]) / (dx * cell);
128
+ const gz = (grid.height[Math.min(z + 1, n - 1) * n + x] - grid.height[Math.max(z - 1, 0) * n + x]) / (dz * cell);
129
+ return Math.atan(Math.hypot(gx, gz)) * (180 / Math.PI);
130
+ }
131
+ function falloff(distance, radius, softness = 0.6) {
132
+ const d = distance / radius;
133
+ return d >= 1 ? 0 : 1 - smoothstep(1 - clamp(softness, 1e-5, 1), 1, d);
134
+ }
135
+ function splinePoints(points, step = 3, smooth = true) {
136
+ const out = [];
137
+ for (let k = 0; k < points.length - 1; k += 1) {
138
+ const a = points[Math.max(0, k - 1)];
139
+ const b = points[k];
140
+ const c = points[k + 1];
141
+ const d = points[Math.min(points.length - 1, k + 2)];
142
+ const count = Math.max(
143
+ 1,
144
+ Math.min(512, Math.ceil(Math.hypot(c[0] - b[0], c[2] - b[2]) / step))
145
+ );
146
+ for (let j = 0; j < count; j += 1) {
147
+ const t = j / count;
148
+ const t2 = t * t;
149
+ const t3 = t2 * t;
150
+ const cat = (dim) => 0.5 * (2 * b[dim] + (-a[dim] + c[dim]) * t + (2 * a[dim] - 5 * b[dim] + 4 * c[dim] - d[dim]) * t2 + (-a[dim] + 3 * b[dim] - 3 * c[dim] + d[dim]) * t3);
151
+ out.push([
152
+ smooth ? cat(0) : lerp(b[0], c[0], t),
153
+ lerp(b[1], c[1], t),
154
+ smooth ? cat(2) : lerp(b[2], c[2], t)
155
+ ]);
156
+ }
157
+ }
158
+ const last = points[points.length - 1];
159
+ out.push([last[0], last[1], last[2]]);
160
+ return out;
161
+ }
162
+
163
+ // src/core/masks.ts
164
+ var MATERIAL_IDS = Object.freeze([
165
+ "grass",
166
+ "dirt",
167
+ "rock",
168
+ "snow",
169
+ "sand",
170
+ "mud",
171
+ "road",
172
+ "moss"
173
+ ]);
174
+ var range = (v, min, max, fade = 0) => fade > 0 ? smoothstep(min - fade, min, v) * (1 - smoothstep(max, max + fade, v)) : Number(v >= min && v <= max);
175
+ function compileMask(mask, s) {
176
+ if (!mask || mask.type === "all") return () => 1;
177
+ switch (mask.type) {
178
+ case "none":
179
+ return () => 0;
180
+ case "circle": {
181
+ const centre = mask.at;
182
+ return (i, x, z) => falloff(
183
+ Math.hypot(x - centre[0], z - centre[1]),
184
+ mask.radius,
185
+ mask.falloff ?? 0.6
186
+ );
187
+ }
188
+ case "rectangle": {
189
+ const centre = mask.at;
190
+ const rad = (mask.rotation ?? 0) * Math.PI / 180;
191
+ const c = Math.cos(rad);
192
+ const sn = Math.sin(rad);
193
+ const size = Array.isArray(mask.size) ? mask.size : [mask.size, mask.size];
194
+ return (i, x, z) => {
195
+ const dx = x - centre[0];
196
+ const dz = z - centre[1];
197
+ return falloff(
198
+ Math.max(
199
+ Math.abs(dx * c + dz * sn) / (size[0] / 2),
200
+ Math.abs(-dx * sn + dz * c) / (size[1] / 2)
201
+ ),
202
+ 1,
203
+ mask.falloff ?? 0.2
204
+ );
205
+ };
206
+ }
207
+ case "height":
208
+ return (i) => range(s.height[i], mask.min ?? -1e9, mask.max ?? 1e9, mask.fade);
209
+ case "slope":
210
+ return (i) => range(slopeAtIndex(s, i), mask.min ?? 0, mask.max ?? 90, mask.fade);
211
+ case "noise":
212
+ return (i, x, z) => smoothstep(
213
+ (mask.threshold ?? 0.5) - (mask.fade ?? 0.15),
214
+ (mask.threshold ?? 0.5) + (mask.fade ?? 0.15),
215
+ noise2(x / mask.scale, z / mask.scale, mask.seed ?? s.seed) * 0.5 + 0.5
216
+ );
217
+ case "biome": {
218
+ const field2 = s.biomes[mask.name];
219
+ return (i) => field2?.[i] ?? 0;
220
+ }
221
+ case "material": {
222
+ const channel = MATERIAL_IDS.indexOf(mask.name);
223
+ if (channel < 0) throw Error(`Unknown material: ${String(mask.name)}`);
224
+ return (i) => s.splat[i * 8 + channel];
225
+ }
226
+ case "not": {
227
+ const inner = compileMask(mask.mask, s);
228
+ return (i, x, z) => 1 - inner(i, x, z);
229
+ }
230
+ case "and": {
231
+ const inner = (mask.masks ?? []).map((entry) => compileMask(entry, s));
232
+ return (i, x, z) => inner.reduce((value, sample) => value * sample(i, x, z), 1);
233
+ }
234
+ case "or": {
235
+ const inner = (mask.masks ?? []).map((entry) => compileMask(entry, s));
236
+ return (i, x, z) => 1 - inner.reduce((value, sample) => value * (1 - sample(i, x, z)), 1);
237
+ }
238
+ default:
239
+ throw Error(`Unknown mask: ${String(mask.type)}`);
240
+ }
241
+ }
242
+ function rasterMask(mask, s) {
243
+ if (!mask || mask.type === "all") return null;
244
+ const out = new Float32Array(s.height.length);
245
+ const sample = compileMask(mask, s);
246
+ for (let z = 0; z < s.resolution; z += 1) {
247
+ for (let x = 0; x < s.resolution; x += 1) {
248
+ const i = z * s.resolution + x;
249
+ out[i] = clamp(sample(i, world(s, x), world(s, z)));
250
+ }
251
+ }
252
+ return out;
253
+ }
254
+ function paintWeights(s, i, channel, weight) {
255
+ const w = clamp(weight);
256
+ const base = i * 8;
257
+ for (let c = 0; c < 8; c += 1)
258
+ s.splat[base + c] = s.splat[base + c] * (1 - w) + (c === channel ? w : 0);
259
+ }
260
+
261
+ // src/core/erosion.ts
262
+ function thermal(height, n, size, { iterations = 20, talus = 32, rate = 0.22 } = {}, observations) {
263
+ const h = height.slice();
264
+ const delta = new Float64Array(h.length);
265
+ const limit = Math.tan(talus * Math.PI / 180) * (size / (n - 1));
266
+ for (let k = 0; k < iterations; k += 1) {
267
+ delta.fill(0);
268
+ for (let z = 0; z < n; z += 1) {
269
+ for (let x = 0; x < n; x += 1) {
270
+ const i = z * n + x;
271
+ let best = -1;
272
+ let diff = limit;
273
+ for (const j of [
274
+ x > 0 ? i - 1 : -1,
275
+ x < n - 1 ? i + 1 : -1,
276
+ z > 0 ? i - n : -1,
277
+ z < n - 1 ? i + n : -1
278
+ ]) {
279
+ if (j >= 0 && h[i] - h[j] > diff) {
280
+ diff = h[i] - h[j];
281
+ best = j;
282
+ }
283
+ }
284
+ if (best >= 0) {
285
+ const transfer = (diff - limit) * rate;
286
+ delta[i] = delta[i] - transfer;
287
+ delta[best] = delta[best] + transfer;
288
+ if (observations)
289
+ observations.talus[best] = observations.talus[best] + transfer;
290
+ }
291
+ }
292
+ }
293
+ for (let i = 0; i < h.length; i += 1) h[i] = h[i] + delta[i];
294
+ }
295
+ return h;
296
+ }
297
+ var brushes = /* @__PURE__ */ new Map();
298
+ function brush(radius) {
299
+ let cells = brushes.get(radius);
300
+ if (!cells) {
301
+ cells = [];
302
+ let total = 0;
303
+ for (let dz = -radius; dz <= radius; dz += 1) {
304
+ for (let dx = -radius; dx <= radius; dx += 1) {
305
+ const d = Math.hypot(dx, dz);
306
+ if (d > radius) continue;
307
+ const weight = 1 - d / (radius + 1);
308
+ cells.push({ dx, dz, weight });
309
+ total += weight;
310
+ }
311
+ }
312
+ for (const c of cells) c.weight /= total;
313
+ brushes.set(radius, cells);
314
+ }
315
+ return cells;
316
+ }
317
+ function hydraulic(height, n, size, {
318
+ // Roughly one droplet per cell. Incision saturates near here: more droplets keep cutting the
319
+ // same drainage lines over, which deepens nothing and spends the bake time.
320
+ droplets = n * n,
321
+ // A droplet has to be able to cross the world, so its step budget scales with the grid: a
322
+ // fixed 40 barely leaves a 257 world while a 65 world would walk off it in a few steps.
323
+ maxSteps = Math.round(Math.min(64, Math.max(24, n / 4))),
324
+ inertia = 0.2,
325
+ capacity = 4,
326
+ // Each step takes a small bite of the sediment the droplet can carry. A big bite carves
327
+ // fewer, deeper dimples that stand proud of their neighbours as spikes; many shallow bites
328
+ // over the same lines incise the same drainage with a smooth surface.
329
+ erosion = 0.03,
330
+ deposition = 0.3,
331
+ evaporation = 0.025,
332
+ seed = 1,
333
+ brushRadius = 3
334
+ } = {}, observations) {
335
+ const h = Float64Array.from(height);
336
+ const rnd = random(seed);
337
+ const cell = size / (n - 1);
338
+ const cells = brush(brushRadius);
339
+ const spread = (x, z, amount, floor = Number.NEGATIVE_INFINITY) => {
340
+ let total = 0;
341
+ for (const c of cells) {
342
+ const ix = x + c.dx;
343
+ const iz = z + c.dz;
344
+ if (ix >= 0 && ix < n && iz >= 0 && iz < n) total += c.weight;
345
+ }
346
+ if (total <= 0) return 0;
347
+ let moved = 0;
348
+ for (const c of cells) {
349
+ const ix = x + c.dx;
350
+ const iz = z + c.dz;
351
+ if (ix < 0 || ix >= n || iz < 0 || iz >= n) continue;
352
+ const i = iz * n + ix;
353
+ const share = amount * c.weight / total;
354
+ const change = amount < 0 ? -Math.min(-share, Math.max(0, h[i] - floor)) : share;
355
+ h[i] = h[i] + change;
356
+ moved += change;
357
+ if (observations && amount > 0)
358
+ observations.deposition[i] = observations.deposition[i] + amount * c.weight / total;
359
+ }
360
+ return moved;
361
+ };
362
+ const get = (x, z) => {
363
+ const ix = Math.min(n - 2, Math.floor(x));
364
+ const iz = Math.min(n - 2, Math.floor(z));
365
+ const tx = x - ix;
366
+ const tz = z - iz;
367
+ const i = iz * n + ix;
368
+ return {
369
+ value: lerp(
370
+ lerp(h[i], h[i + 1], tx),
371
+ lerp(h[i + n], h[i + n + 1], tx),
372
+ tz
373
+ ),
374
+ dx: lerp(
375
+ h[i + 1] - h[i],
376
+ h[i + n + 1] - h[i + n],
377
+ tz
378
+ ) / cell,
379
+ dz: lerp(
380
+ h[i + n] - h[i],
381
+ h[i + n + 1] - h[i + 1],
382
+ tx
383
+ ) / cell,
384
+ ix,
385
+ iz
386
+ };
387
+ };
388
+ for (let k = 0; k < droplets; k += 1) {
389
+ let x = rnd() * (n - 1 - 1e-3);
390
+ let z = rnd() * (n - 1 - 1e-3);
391
+ let dx = 0;
392
+ let dz = 0;
393
+ let water = 1;
394
+ let speed = 1;
395
+ let sediment = 0;
396
+ for (let step = 0; step < maxSteps; step += 1) {
397
+ const old = get(x, z);
398
+ if (observations) {
399
+ const tx = x - old.ix;
400
+ const tz = z - old.iz;
401
+ const i = old.iz * n + old.ix;
402
+ for (let oz = 0; oz < 2; oz++) {
403
+ for (let ox = 0; ox < 2; ox++) {
404
+ const index = i + oz * n + ox;
405
+ const weight = (ox ? tx : 1 - tx) * (oz ? tz : 1 - tz);
406
+ observations.flow[index] = observations.flow[index] + water * weight;
407
+ observations.sediment[index] = observations.sediment[index] + sediment * weight;
408
+ }
409
+ }
410
+ }
411
+ dx = dx * inertia - old.dx * (1 - inertia);
412
+ dz = dz * inertia - old.dz * (1 - inertia);
413
+ let length = Math.hypot(dx, dz);
414
+ if (length < 1e-9) {
415
+ const angle = rnd() * Math.PI * 2;
416
+ dx = Math.cos(angle);
417
+ dz = Math.sin(angle);
418
+ length = 1;
419
+ }
420
+ dx /= length;
421
+ dz /= length;
422
+ const nx = x + dx;
423
+ const nz = z + dz;
424
+ if (nx < 0 || nx >= n - 1 || nz < 0 || nz >= n - 1) {
425
+ sediment = 0;
426
+ break;
427
+ }
428
+ const next = get(nx, nz);
429
+ const dh = next.value - old.value;
430
+ const cap = Math.max(-dh, 5e-3 * cell) * speed * water * capacity;
431
+ if (dh > 0 || sediment > cap) {
432
+ const amount = dh > 0 ? Math.min(dh, sediment) : (sediment - cap) * deposition;
433
+ spread(old.ix, old.iz, amount);
434
+ sediment -= amount;
435
+ } else {
436
+ const amount = Math.max(0, Math.min((cap - sediment) * erosion, -dh));
437
+ sediment -= spread(old.ix, old.iz, -amount, next.value);
438
+ }
439
+ speed = Math.sqrt(Math.max(0.01, speed * speed - dh * 3));
440
+ water *= 1 - evaporation;
441
+ x = nx;
442
+ z = nz;
443
+ if (water < 0.02) break;
444
+ }
445
+ if (sediment > 0 && water < 0.02) {
446
+ const end = get(clamp(x, 0, n - 1.001), clamp(z, 0, n - 1.001));
447
+ spread(end.ix, end.iz, sediment);
448
+ }
449
+ }
450
+ return Float32Array.from(h);
451
+ }
452
+
453
+ // src/core/operations.ts
454
+ var OPERATION_TYPES = Object.freeze([
455
+ "noise",
456
+ "sculpt",
457
+ "smooth",
458
+ "flatten",
459
+ "ramp",
460
+ "stamp",
461
+ "erode",
462
+ "terrace",
463
+ "materials",
464
+ "paint",
465
+ "biome",
466
+ "scatter",
467
+ "clear",
468
+ "road",
469
+ "river",
470
+ "water",
471
+ "heightmap",
472
+ "paste"
473
+ ]);
474
+ function createState(config, resolution = config.resolution) {
475
+ const height = new Float32Array(resolution * resolution);
476
+ const splat = new Float32Array(height.length * 8);
477
+ for (let i = 0; i < height.length; i += 1) splat[i * 8] = 1;
478
+ return {
479
+ size: config.size,
480
+ resolution,
481
+ seed: config.seed,
482
+ height,
483
+ splat,
484
+ biomes: {},
485
+ scatterRules: [],
486
+ waterRules: [],
487
+ rivers: [],
488
+ instances: [],
489
+ waters: [],
490
+ diagnostics: []
491
+ };
492
+ }
493
+ function brushCenters(p, seed) {
494
+ const source = p.points ?? (p.at ? [p.at] : null);
495
+ if (!source) return null;
496
+ const radius = p.radius ?? 30;
497
+ const first = source[0];
498
+ if (!first) return null;
499
+ const out = [[first[0], first[1]]];
500
+ const step = Math.max(0.1, radius * 2 * (p.spacing ?? 0.15));
501
+ for (let k = 1; k < source.length; k += 1) {
502
+ const a = source[k - 1];
503
+ const b = source[k];
504
+ const count = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / step));
505
+ for (let j = 1; j <= count; j += 1)
506
+ out.push([lerp(a[0], b[0], j / count), lerp(a[1], b[1], j / count)]);
507
+ }
508
+ if (p.jitter) {
509
+ const rnd = random(seed);
510
+ for (const centre of out) {
511
+ const angle = rnd() * Math.PI * 2;
512
+ const offset = Math.sqrt(rnd()) * p.jitter * radius;
513
+ centre[0] += Math.cos(angle) * offset;
514
+ centre[1] += Math.sin(angle) * offset;
515
+ }
516
+ }
517
+ return out;
518
+ }
519
+ function brushWeights(s, p, mask, seed) {
520
+ const centers = brushCenters(p, seed);
521
+ if (!centers) return mask ?? new Float32Array(s.height.length).fill(1);
522
+ const n = s.resolution;
523
+ const r = p.radius ?? 30;
524
+ const cell = s.size / (n - 1);
525
+ const weights = new Float32Array(n * n);
526
+ const angle = (p.rotation ?? 0) * Math.PI / 180;
527
+ const cos = Math.cos(angle);
528
+ const sin = Math.sin(angle);
529
+ const bound = r * (p.shape === "square" ? Math.SQRT2 : 1);
530
+ for (const [cx, cz] of centers) {
531
+ const x0 = clamp(Math.floor((cx - bound + s.size / 2) / cell), 0, n - 1);
532
+ const x1 = clamp(Math.ceil((cx + bound + s.size / 2) / cell), 0, n - 1);
533
+ const z0 = clamp(Math.floor((cz - bound + s.size / 2) / cell), 0, n - 1);
534
+ const z1 = clamp(Math.ceil((cz + bound + s.size / 2) / cell), 0, n - 1);
535
+ for (let z = z0; z <= z1; z += 1) {
536
+ for (let x = x0; x <= x1; x += 1) {
537
+ const dx = world(s, x) - cx;
538
+ const dz = world(s, z) - cz;
539
+ const distance = p.shape === "square" ? Math.max(Math.abs(dx * cos + dz * sin), Math.abs(-dx * sin + dz * cos)) : Math.hypot(dx, dz);
540
+ const i = z * n + x;
541
+ weights[i] = Math.max(
542
+ weights[i],
543
+ falloff(distance, r, p.falloff ?? 0.6) * (mask?.[i] ?? 1)
544
+ );
545
+ }
546
+ }
547
+ }
548
+ return weights;
549
+ }
550
+ function followsTerrain(source, followTerrain) {
551
+ return followTerrain ?? source.some((point2) => !Number.isFinite(point2[1]));
552
+ }
553
+ function gradeToGround(s, points, p) {
554
+ const at = (k) => points[k];
555
+ const run = (k) => {
556
+ const a = at(k - 1);
557
+ const b = at(k);
558
+ return Math.hypot(b[0] - a[0], b[2] - a[2]);
559
+ };
560
+ const bed = points.map(([x, , z]) => sampleHeight(s, x, z));
561
+ let spacing = 0;
562
+ for (let k = 1; k < points.length && spacing === 0; k += 1) spacing = run(k);
563
+ spacing ||= 3;
564
+ const window = Math.max(1, Math.round(Math.max(8, p.width ?? 12) / spacing));
565
+ const profile2 = bed.slice();
566
+ for (let pass = 0; pass < 2; pass += 1) {
567
+ const next = profile2.slice();
568
+ for (let k = 0; k < profile2.length; k += 1) {
569
+ let sum = 0;
570
+ let count = 0;
571
+ for (let j = Math.max(0, k - window); j <= Math.min(profile2.length - 1, k + window); j += 1) {
572
+ sum += profile2[j];
573
+ count += 1;
574
+ }
575
+ next[k] = sum / count;
576
+ }
577
+ for (let k = 0; k < profile2.length; k += 1) profile2[k] = next[k];
578
+ }
579
+ const maxCut = p.maxCut ?? 2.5;
580
+ const maxFill = p.maxFill ?? 1.5;
581
+ const grade2 = p.maxGrade ?? 0.12;
582
+ const limit = (k) => grade2 * Math.max(0.5, run(k));
583
+ for (let round = 0; round < 16; round += 1) {
584
+ let moved = 0;
585
+ for (let k = 1; k < profile2.length; k += 1) {
586
+ const y = clamp(
587
+ profile2[k],
588
+ profile2[k - 1] - limit(k),
589
+ profile2[k - 1] + limit(k)
590
+ );
591
+ moved += Math.abs(y - profile2[k]);
592
+ profile2[k] = y;
593
+ }
594
+ for (let k = profile2.length - 2; k >= 0; k -= 1) {
595
+ const y = clamp(
596
+ profile2[k],
597
+ profile2[k + 1] - limit(k + 1),
598
+ profile2[k + 1] + limit(k + 1)
599
+ );
600
+ moved += Math.abs(y - profile2[k]);
601
+ profile2[k] = y;
602
+ }
603
+ if (moved < 1e-4) break;
604
+ }
605
+ for (let k = 0; k < profile2.length; k += 1)
606
+ profile2[k] = clamp(
607
+ profile2[k],
608
+ bed[k] - maxCut,
609
+ bed[k] + maxFill
610
+ );
611
+ for (let k = 0; k < points.length; k += 1) at(k)[1] = profile2[k];
612
+ }
613
+ function applySpline(s, layer, mask) {
614
+ const p = layer.params;
615
+ const river = layer.type === "river";
616
+ const ramp = layer.type === "ramp";
617
+ const seed = p.seed ?? s.seed;
618
+ const raw = ramp ? [p.from, p.to] : p.points ?? [];
619
+ const follows = followsTerrain(raw, p.followTerrain);
620
+ const source = raw.map(
621
+ ([x, y, z]) => [x, Number.isFinite(y) ? y : 0, z]
622
+ );
623
+ const points = splinePoints(
624
+ source,
625
+ Math.max(1, (p.width ?? 12) * 0.22),
626
+ !ramp && p.smooth !== false
627
+ );
628
+ if (follows) gradeToGround(s, points, p);
629
+ const half = (p.width ?? 12) / 2;
630
+ const shoulder = p.shoulder ?? half;
631
+ const extent = half + shoulder;
632
+ const depth = p.depth ?? 4;
633
+ const alpha = layer.opacity ?? 1;
634
+ const n = s.resolution;
635
+ const cell = s.size / (n - 1);
636
+ const dist = new Float32Array(n * n).fill(Number.POSITIVE_INFINITY);
637
+ const target = new Float32Array(n * n);
638
+ const bed = new Float32Array(n * n);
639
+ const floor = river ? 1.5 * depth : 0;
640
+ let reach = extent;
641
+ const raster = () => {
642
+ dist.fill(Number.POSITIVE_INFINITY);
643
+ for (let k = 1; k < points.length; k += 1) {
644
+ const a = points[k - 1];
645
+ const b = points[k];
646
+ const dx = b[0] - a[0];
647
+ const dz = b[2] - a[2];
648
+ const den = dx * dx + dz * dz;
649
+ if (den < 1e-12) continue;
650
+ const xmin = clamp(Math.floor((Math.min(a[0], b[0]) - reach + s.size / 2) / cell), 0, n - 1);
651
+ const xmax = clamp(Math.ceil((Math.max(a[0], b[0]) + reach + s.size / 2) / cell), 0, n - 1);
652
+ const zmin = clamp(Math.floor((Math.min(a[2], b[2]) - reach + s.size / 2) / cell), 0, n - 1);
653
+ const zmax = clamp(Math.ceil((Math.max(a[2], b[2]) + reach + s.size / 2) / cell), 0, n - 1);
654
+ for (let z = zmin; z <= zmax; z += 1) {
655
+ for (let x = xmin; x <= xmax; x += 1) {
656
+ const wx = world(s, x);
657
+ const wz = world(s, z);
658
+ const t = clamp(((wx - a[0]) * dx + (wz - a[2]) * dz) / den);
659
+ const d = Math.hypot(wx - a[0] - t * dx, wz - a[2] - t * dz);
660
+ const i = z * n + x;
661
+ if (d < dist[i]) {
662
+ dist[i] = d;
663
+ target[i] = lerp(a[1], b[1], t);
664
+ bed[i] = s.height[i];
665
+ }
666
+ }
667
+ }
668
+ }
669
+ };
670
+ raster();
671
+ let worst = 0;
672
+ for (let i = 0; i < dist.length; i += 1)
673
+ if (dist[i] <= extent)
674
+ worst = Math.max(worst, Math.abs(target[i] - bed[i]));
675
+ if (floor + 1.5 * worst > 0.05) {
676
+ reach = extent + floor + 1.5 * worst;
677
+ raster();
678
+ }
679
+ const material = MATERIAL_IDS.indexOf(p.material ?? (river ? "mud" : "road"));
680
+ for (let i = 0; i < s.height.length; i += 1) {
681
+ const d = dist[i];
682
+ if (d > reach) continue;
683
+ const pre = bed[i];
684
+ const edge = Math.min(reach, extent + floor + 1.5 * Math.abs(target[i] - pre));
685
+ const blend = 1 - smoothstep(half, edge, d);
686
+ const w = blend * blend * (mask?.[i] ?? 1) * alpha;
687
+ let y = target[i];
688
+ if (river) {
689
+ const bowl = 1 - 0.35 * clamp(d / half) ** 2;
690
+ const bars = 1 + 0.22 * noise2(world(s, i % n) / (half * 8), world(s, Math.floor(i / n)) / (half * 8), seed + 7);
691
+ y = Math.min(pre, y - depth * bowl * bars * blend);
692
+ }
693
+ s.height[i] = lerp(pre, y, w);
694
+ if (material >= 0) paintWeights(s, i, material, w * (river ? 0.85 : 1));
695
+ }
696
+ if (river && p.water !== false)
697
+ s.rivers.push({
698
+ id: layer.id,
699
+ points,
700
+ width: (p.width ?? 12) * (p.waterWidth ?? 0.76),
701
+ color: p.color,
702
+ opacity: alpha
703
+ });
704
+ }
705
+ function applyOperation(s, layer) {
706
+ const { type, params: p } = layer;
707
+ const opacity = layer.opacity ?? 1;
708
+ if (layer.enabled === false || opacity <= 0) return;
709
+ const mask = rasterMask(layer.mask, s);
710
+ const weight = (i) => (mask?.[i] ?? 1) * opacity;
711
+ const n = s.resolution;
712
+ const seed = p.seed ?? s.seed;
713
+ switch (type) {
714
+ case "noise": {
715
+ const amplitude = p.amplitude ?? 35;
716
+ const scale2 = p.scale ?? 100;
717
+ const warp = p.warp ?? 20;
718
+ for (let z = 0; z < n; z += 1) {
719
+ for (let x = 0; x < n; x += 1) {
720
+ const i = z * n + x;
721
+ const wx = world(s, x);
722
+ const wz = world(s, z);
723
+ const ox = noise2(wx / (scale2 * 1.7) + 17, wz / (scale2 * 1.7) - 19, seed + 29) * warp;
724
+ const oz = noise2(wx / (scale2 * 1.7) - 27, wz / (scale2 * 1.7) + 12, seed + 31) * warp;
725
+ let value = (p.base ?? 0) + fbm((wx + ox) / scale2, (wz + oz) / scale2, {
726
+ seed,
727
+ octaves: p.octaves,
728
+ persistence: p.persistence,
729
+ lacunarity: p.lacunarity,
730
+ mode: p.mode
731
+ }) * amplitude;
732
+ if (p.island) {
733
+ const r = Math.hypot(wx / (s.size * 0.59), wz / (s.size * 0.59));
734
+ value = value * (1 - smoothstep(0.45, 1, r)) - (p.coastDepth ?? 15) * smoothstep(0.6, 1, r);
735
+ }
736
+ s.height[i] = p.blend === "replace" ? lerp(s.height[i], value, weight(i)) : s.height[i] + value * weight(i);
737
+ }
738
+ }
739
+ break;
740
+ }
741
+ case "sculpt":
742
+ case "flatten":
743
+ case "smooth":
744
+ case "paint": {
745
+ const weights = brushWeights(s, p, mask, seed);
746
+ const w = (i) => (weights?.[i] ?? 1) * opacity;
747
+ if (type === "sculpt")
748
+ for (let i = 0; i < s.height.length; i += 1)
749
+ s.height[i] = s.height[i] + (p.strength ?? 5) * w(i);
750
+ if (type === "flatten") {
751
+ const level = "height" in p ? p.height : void 0;
752
+ if (typeof level !== "number" || !Number.isFinite(level)) {
753
+ const covered = brushWeights(s, p, mask, seed);
754
+ let sum = 0;
755
+ let total = 0;
756
+ for (let i = 0; i < s.height.length; i += 1) {
757
+ const share = covered[i] * opacity;
758
+ sum += s.height[i] * share;
759
+ total += share;
760
+ }
761
+ const target = total > 0 ? sum / total : 0;
762
+ let delta = 0;
763
+ for (let i = 0; i < s.height.length; i += 1)
764
+ if (covered[i] > 0)
765
+ delta = Math.max(delta, Math.abs(s.height[i] - target));
766
+ const graded = brushWeights(
767
+ s,
768
+ { ...p, radius: (p.radius ?? 30) + 1.5 * delta },
769
+ mask,
770
+ seed
771
+ );
772
+ for (let i = 0; i < s.height.length; i += 1)
773
+ s.height[i] = lerp(
774
+ s.height[i],
775
+ target,
776
+ graded[i] * opacity * clamp(p.strength ?? 1)
777
+ );
778
+ } else
779
+ for (let i = 0; i < s.height.length; i += 1)
780
+ s.height[i] = lerp(s.height[i], level, w(i) * clamp(p.strength ?? 1));
781
+ }
782
+ if (type === "paint") {
783
+ const channel = MATERIAL_IDS.indexOf(("material" in p ? p.material : void 0) ?? "dirt");
784
+ for (let i = 0; i < s.height.length; i += 1)
785
+ paintWeights(s, i, channel, w(i) * clamp(p.strength ?? 1));
786
+ }
787
+ if (type === "smooth")
788
+ for (let k = 0; k < (("iterations" in p ? p.iterations : void 0) ?? 3); k += 1) {
789
+ const old = s.height.slice();
790
+ for (let z = 0; z < n; z += 1) {
791
+ for (let x = 0; x < n; x += 1) {
792
+ const i = z * n + x;
793
+ if (w(i) === 0) continue;
794
+ let total = 0;
795
+ let count = 0;
796
+ for (let dz = -1; dz <= 1; dz += 1) {
797
+ for (let dx = -1; dx <= 1; dx += 1) {
798
+ const xx = x + dx;
799
+ const zz = z + dz;
800
+ if (xx >= 0 && xx < n && zz >= 0 && zz < n) {
801
+ total += old[zz * n + xx];
802
+ count += 1;
803
+ }
804
+ }
805
+ }
806
+ s.height[i] = lerp(old[i], total / count, w(i) * clamp(p.strength ?? 0.65));
807
+ }
808
+ }
809
+ }
810
+ break;
811
+ }
812
+ case "stamp":
813
+ case "paste": {
814
+ const at = p.at ?? [0, 0];
815
+ const radius = p.radius ?? (p.size ? typeof p.size === "number" ? p.size / 2 : p.size.map((v) => v / 2) : 60);
816
+ const extents = Array.isArray(radius) ? [radius[0] / 1, radius[1] / 1] : [radius / 1, radius / 1];
817
+ const rot = (p.rotation ?? 0) * Math.PI / 180;
818
+ const c = Math.cos(rot);
819
+ const sn = Math.sin(rot);
820
+ const shape = ("shape" in p ? p.shape : void 0) ?? "mountain";
821
+ for (let z = 0; z < n; z += 1) {
822
+ for (let x = 0; x < n; x += 1) {
823
+ const i = z * n + x;
824
+ const dx = world(s, x) - at[0];
825
+ const dz = world(s, z) - at[1];
826
+ const u = (dx * c + dz * sn) / extents[0] * (p.mirrorX ? -1 : 1);
827
+ const v = (-dx * sn + dz * c) / extents[1] * (p.mirrorZ ? -1 : 1);
828
+ const d = Math.hypot(u, v);
829
+ let profile2;
830
+ let w = 1;
831
+ if (p.data) {
832
+ if (Math.abs(u) > 1 || Math.abs(v) > 1) continue;
833
+ const source = p.data;
834
+ profile2 = sampleGrid(
835
+ source.values,
836
+ source.width,
837
+ source.height,
838
+ u * 0.5 + 0.5,
839
+ v * 0.5 + 0.5
840
+ );
841
+ w = falloff(Math.max(Math.abs(u), Math.abs(v)), 1, p.falloff ?? 0.12);
842
+ } else {
843
+ if (d >= 1) continue;
844
+ const rough = 1 + (("roughness" in p ? p.roughness : void 0) ?? 0.13) * fbm(u * 5, v * 5, { seed, octaves: 3 });
845
+ if (shape === "crater")
846
+ profile2 = (Math.exp(-(((d - 0.67) / 0.14) ** 2)) * 0.48 - Math.exp(-((d / 0.49) ** 4)) * 0.6) * (1 - smoothstep(0.85, 1, d));
847
+ else if (shape === "ridge")
848
+ profile2 = Math.max(0, 1 - Math.abs(v)) ** 1.7 * (1 - smoothstep(0.35, 1, Math.abs(u))) * rough;
849
+ else if (shape === "mesa") profile2 = (1 - smoothstep(0.5, 1, d)) * rough;
850
+ else if (shape === "valley") profile2 = -(1 - smoothstep(0, 1, d)) * rough;
851
+ else if (shape === "dune") profile2 = Math.max(0, 1 - d) ** 1.2 * (1 + 0.25 * u) * rough;
852
+ else profile2 = Math.max(0, 1 - d) ** 1.65 * rough;
853
+ profile2 *= ("amplitude" in p ? p.amplitude : void 0) ?? 45;
854
+ w = falloff(d, 1, p.falloff ?? 0);
855
+ }
856
+ const alpha = weight(i) * w;
857
+ const blend = p.blend ?? (type === "paste" ? "replace" : "add");
858
+ const height = profile2 * (p.scale ?? 1) + (p.offset ?? 0);
859
+ const value = blend === "replace" ? height : blend === "max" ? Math.max(s.height[i], height) : blend === "min" ? Math.min(s.height[i], height) : s.height[i] + height;
860
+ s.height[i] = lerp(s.height[i], value, alpha);
861
+ }
862
+ }
863
+ break;
864
+ }
865
+ case "erode": {
866
+ const observations = {
867
+ flow: new Float32Array(s.height.length),
868
+ sediment: new Float32Array(s.height.length),
869
+ deposition: new Float32Array(s.height.length),
870
+ talus: new Float32Array(s.height.length)
871
+ };
872
+ s.erosion ??= structuredClone(observations);
873
+ const eroded = p.method === "hydraulic" ? hydraulic(
874
+ s.height,
875
+ n,
876
+ s.size,
877
+ {
878
+ seed,
879
+ droplets: p.droplets,
880
+ maxSteps: p.maxSteps,
881
+ inertia: p.inertia,
882
+ capacity: p.capacity,
883
+ erosion: p.erosion,
884
+ deposition: p.deposition,
885
+ evaporation: p.evaporation
886
+ },
887
+ observations
888
+ ) : thermal(s.height, n, s.size, p, observations);
889
+ let influence = mask;
890
+ if (p.at || p.points) influence = brushWeights(s, p, mask, seed);
891
+ for (let i = 0; i < s.height.length; i += 1) {
892
+ const alpha = (influence?.[i] ?? 1) * opacity * (p.strength ?? 1);
893
+ s.height[i] = lerp(s.height[i], eroded[i], alpha);
894
+ for (const key of ["flow", "sediment", "deposition", "talus"])
895
+ s.erosion[key][i] = s.erosion[key][i] + observations[key][i] * alpha;
896
+ }
897
+ break;
898
+ }
899
+ case "terrace": {
900
+ const step = p.step ?? 8;
901
+ const softness = p.softness ?? 0.18;
902
+ for (let i = 0; i < s.height.length; i += 1) {
903
+ const v = (s.height[i] - (p.offset ?? 0)) / step;
904
+ const base = Math.floor(v);
905
+ const f = v - base;
906
+ const target = (base + smoothstep(0.5 - softness * 0.5, 0.5 + softness * 0.5, f)) * step + (p.offset ?? 0);
907
+ s.height[i] = lerp(s.height[i], target, weight(i) * (p.strength ?? 1));
908
+ }
909
+ break;
910
+ }
911
+ case "materials": {
912
+ if (p.reset !== false)
913
+ for (let i = 0; i < s.height.length; i += 1)
914
+ paintWeights(s, i, MATERIAL_IDS.indexOf(p.base ?? "grass"), weight(i));
915
+ for (const rule of p.rules ?? []) {
916
+ const ruleMask = rasterMask(rule.mask, s);
917
+ const channel = MATERIAL_IDS.indexOf(rule.material);
918
+ for (let i = 0; i < s.height.length; i += 1)
919
+ paintWeights(s, i, channel, (ruleMask?.[i] ?? 1) * weight(i) * (rule.strength ?? 1));
920
+ }
921
+ break;
922
+ }
923
+ case "biome": {
924
+ const name = p.name ?? "forest";
925
+ s.biomes[name] ??= new Float32Array(s.height.length);
926
+ const values = s.biomes[name];
927
+ const weights = brushWeights(s, p, mask, seed);
928
+ for (let i = 0; i < values.length; i += 1)
929
+ values[i] = lerp(
930
+ values[i],
931
+ p.value ?? 1,
932
+ (weights?.[i] ?? 1) * opacity * (p.strength ?? 1)
933
+ );
934
+ break;
935
+ }
936
+ case "scatter":
937
+ s.scatterRules.push({ id: layer.id, ...p, mask: layer.mask, opacity, clear: [] });
938
+ break;
939
+ case "clear": {
940
+ if (p.target === "scatter" || p.target === "all") {
941
+ for (const rule of s.scatterRules)
942
+ if (!p.asset || rule.asset === p.asset)
943
+ rule.clear.push({ mask: layer.mask ?? { type: "all" }, opacity });
944
+ }
945
+ if (p.target === "biome" || p.target === "all")
946
+ for (const [name, values] of Object.entries(s.biomes)) {
947
+ if (p.name && p.name !== name) continue;
948
+ for (let i = 0; i < values.length; i += 1)
949
+ values[i] = values[i] * (1 - weight(i));
950
+ }
951
+ if (p.target === "paint" || p.target === "all")
952
+ for (let i = 0; i < s.height.length; i += 1) paintWeights(s, i, 0, weight(i));
953
+ break;
954
+ }
955
+ case "road":
956
+ case "river":
957
+ case "ramp":
958
+ applySpline(s, layer, mask);
959
+ break;
960
+ case "water":
961
+ s.waterRules.push({ id: layer.id, ...p, mask: layer.mask, opacity });
962
+ break;
963
+ case "heightmap": {
964
+ if (p.at !== void 0 || p.size !== void 0 || p.rotation !== void 0 || p.falloff !== void 0) {
965
+ applyOperation(s, {
966
+ ...layer,
967
+ type: "paste",
968
+ params: { ...p, size: p.size ?? s.size, falloff: p.falloff ?? 0 }
969
+ });
970
+ break;
971
+ }
972
+ const data = p.data;
973
+ for (let z = 0; z < n; z += 1) {
974
+ for (let x = 0; x < n; x += 1) {
975
+ const i = z * n + x;
976
+ const value = sampleGrid(data.values, data.width, data.height, x / (n - 1), z / (n - 1)) * (p.scale ?? 1) + (p.offset ?? 0);
977
+ s.height[i] = p.blend === "add" ? s.height[i] + value * weight(i) : lerp(s.height[i], value, weight(i));
978
+ }
979
+ }
980
+ break;
981
+ }
982
+ default:
983
+ throw Error(`Unsupported operation: ${String(layer.type)}`);
984
+ }
985
+ }
986
+ function finalizeState(s) {
987
+ const n = s.resolution;
988
+ const cell = s.size / (n - 1);
989
+ s.waters = [];
990
+ s.instances = [];
991
+ for (const water of s.waterRules) {
992
+ const mask = new Uint8Array(n * n);
993
+ const queue = new Int32Array(n * n);
994
+ const allowed = compileMask(water.mask, s);
995
+ const level = water.level ?? 0;
996
+ const at = water.at ?? [0, 0];
997
+ const radius = water.radius ?? s.size;
998
+ let front = 0;
999
+ let back = 0;
1000
+ const eligible = (i) => {
1001
+ const x = world(s, i % n);
1002
+ const z = world(s, Math.floor(i / n));
1003
+ return s.height[i] < level && Math.hypot(x - at[0], z - at[1]) <= radius && allowed(i, x, z) > 0.05;
1004
+ };
1005
+ const push = (i) => {
1006
+ if (i >= 0 && i < n * n && !mask[i] && eligible(i)) {
1007
+ mask[i] = 1;
1008
+ queue[back] = i;
1009
+ back += 1;
1010
+ }
1011
+ };
1012
+ if (water.kind === "ocean") {
1013
+ for (let k = 0; k < n; k += 1) {
1014
+ push(k);
1015
+ push((n - 1) * n + k);
1016
+ push(k * n);
1017
+ push(k * n + n - 1);
1018
+ }
1019
+ } else {
1020
+ const x = clamp(Math.round((at[0] + s.size / 2) / cell), 0, n - 1);
1021
+ const z = clamp(Math.round((at[1] + s.size / 2) / cell), 0, n - 1);
1022
+ push(z * n + x);
1023
+ }
1024
+ while (front < back) {
1025
+ const i = queue[front];
1026
+ front += 1;
1027
+ const x = i % n;
1028
+ const z = Math.floor(i / n);
1029
+ if (x > 0) push(i - 1);
1030
+ if (x < n - 1) push(i + 1);
1031
+ if (z > 0) push(i - n);
1032
+ if (z < n - 1) push(i + n);
1033
+ }
1034
+ s.waters.push({ ...water, level, mask });
1035
+ if (!back)
1036
+ s.diagnostics.push(
1037
+ `Water '${water.id}' has no flooded cells; its seed may be above the water level.`
1038
+ );
1039
+ }
1040
+ const wet = (i, x, z, h) => {
1041
+ if (s.waters.some((water) => water.mask[i])) return true;
1042
+ for (const river of s.rivers) {
1043
+ const half = river.width / 2;
1044
+ for (let k = 1; k < river.points.length; k += 1) {
1045
+ const a = river.points[k - 1];
1046
+ const b = river.points[k];
1047
+ if (x < Math.min(a[0], b[0]) - half || x > Math.max(a[0], b[0]) + half || z < Math.min(a[2], b[2]) - half || z > Math.max(a[2], b[2]) + half)
1048
+ continue;
1049
+ const dx = b[0] - a[0];
1050
+ const dz = b[2] - a[2];
1051
+ const t = clamp(((x - a[0]) * dx + (z - a[2]) * dz) / (dx * dx + dz * dz || 1));
1052
+ if (Math.hypot(x - a[0] - t * dx, z - a[2] - t * dz) < half && h <= lerp(a[1], b[1], t) + 0.2)
1053
+ return true;
1054
+ }
1055
+ }
1056
+ return false;
1057
+ };
1058
+ for (const rule of s.scatterRules) {
1059
+ const count = Math.round((rule.count ?? 300) * (rule.opacity ?? 1));
1060
+ const seed = (rule.seed ?? s.seed ^ hashString(rule.id)) >>> 0;
1061
+ const rnd = random(seed);
1062
+ const accept = compileMask(rule.mask, s);
1063
+ const clears = rule.clear.map((entry) => ({
1064
+ fn: compileMask(entry.mask, s),
1065
+ opacity: entry.opacity
1066
+ }));
1067
+ const distance = rule.minDistance ?? 4;
1068
+ const gridSize = Math.max(distance, 0.1);
1069
+ const buckets = /* @__PURE__ */ new Map();
1070
+ const accepted = [];
1071
+ const maxAttempts = count * 40;
1072
+ const scale2 = rule.scale ?? [0.8, 1.3];
1073
+ for (let attempt = 0; attempt < maxAttempts && accepted.length < count; attempt += 1) {
1074
+ const x = (rnd() - 0.5) * s.size;
1075
+ const z = (rnd() - 0.5) * s.size;
1076
+ const acceptance = rnd();
1077
+ const scaleRandom = rnd();
1078
+ const yaw = rnd() * Math.PI * 2;
1079
+ const ix = clamp(Math.round((x / s.size + 0.5) * (n - 1)), 0, n - 1);
1080
+ const iz = clamp(Math.round((z / s.size + 0.5) * (n - 1)), 0, n - 1);
1081
+ const i = iz * n + ix;
1082
+ let w = accept(i, x, z);
1083
+ for (const clear of clears) w *= 1 - clear.fn(i, x, z) * clear.opacity;
1084
+ if (acceptance > w) continue;
1085
+ const h = sampleHeight(s, x, z);
1086
+ if (rule.avoidWater !== false && wet(i, x, z, h)) continue;
1087
+ if (h < (rule.minHeight ?? -1e9) || h > (rule.maxHeight ?? 1e9)) continue;
1088
+ const g = gradientAt(s, x, z);
1089
+ const slope = Math.atan(Math.hypot(g[0], g[1])) * (180 / Math.PI);
1090
+ if (slope < (rule.minSlope ?? 0) || slope > (rule.maxSlope ?? 90)) continue;
1091
+ const gx = Math.floor(x / gridSize);
1092
+ const gz = Math.floor(z / gridSize);
1093
+ let blocked = false;
1094
+ if (distance > 0)
1095
+ for (let dz = -1; dz <= 1 && !blocked; dz += 1) {
1096
+ for (let dx = -1; dx <= 1 && !blocked; dx += 1) {
1097
+ for (const j of buckets.get(`${gx + dx},${gz + dz}`) ?? []) {
1098
+ const other = accepted[j];
1099
+ if (Math.hypot(other.position[0] - x, other.position[2] - z) < distance) {
1100
+ blocked = true;
1101
+ break;
1102
+ }
1103
+ }
1104
+ }
1105
+ }
1106
+ if (blocked) continue;
1107
+ const index = accepted.length;
1108
+ const itemScale = Array.isArray(scale2) ? lerp(scale2[0], scale2[1], scaleRandom) : scale2;
1109
+ const normal = [-g[0], 1, -g[1]];
1110
+ const length = Math.hypot(normal[0], normal[1], normal[2]);
1111
+ accepted.push({
1112
+ id: `${rule.id}:candidate:${seed}:${attempt}`,
1113
+ layer: rule.id,
1114
+ asset: rule.asset ?? "pine",
1115
+ position: [x, h + (rule.offsetY ?? 0), z],
1116
+ rotation: yaw,
1117
+ scale: itemScale,
1118
+ normal: [normal[0] / length, normal[1] / length, normal[2] / length],
1119
+ alignToNormal: rule.alignToNormal ?? false
1120
+ });
1121
+ const key = `${gx},${gz}`;
1122
+ const bucket = buckets.get(key) ?? [];
1123
+ bucket.push(index);
1124
+ buckets.set(key, bucket);
1125
+ }
1126
+ s.instances.push(...accepted);
1127
+ if (accepted.length < count)
1128
+ s.diagnostics.push(
1129
+ `Scatter '${rule.id}': placed ${accepted.length}/${count}; masks or spacing exhausted the attempt budget.`
1130
+ );
1131
+ }
1132
+ return s;
1133
+ }
1134
+
1135
+ // src/core/validation.ts
1136
+ var RESOLUTIONS = Object.freeze([17, 33, 65, 129, 257, 513, 1025]);
1137
+ var brush2 = [
1138
+ "at",
1139
+ "points",
1140
+ "radius",
1141
+ "strength",
1142
+ "falloff",
1143
+ "shape",
1144
+ "rotation",
1145
+ "spacing",
1146
+ "jitter"
1147
+ ];
1148
+ var grade = ["followTerrain", "maxGrade", "maxCut", "maxFill"];
1149
+ var PARAMS = Object.freeze({
1150
+ noise: [
1151
+ "amplitude",
1152
+ "scale",
1153
+ "octaves",
1154
+ "persistence",
1155
+ "lacunarity",
1156
+ "mode",
1157
+ "warp",
1158
+ "base",
1159
+ "blend",
1160
+ "island",
1161
+ "coastDepth"
1162
+ ],
1163
+ sculpt: brush2,
1164
+ smooth: [...brush2, "iterations"],
1165
+ flatten: [...brush2, "height"],
1166
+ paint: [...brush2, "material"],
1167
+ stamp: [
1168
+ "at",
1169
+ "radius",
1170
+ "size",
1171
+ "amplitude",
1172
+ "shape",
1173
+ "rotation",
1174
+ "mirrorX",
1175
+ "mirrorZ",
1176
+ "blend",
1177
+ "roughness",
1178
+ "scale",
1179
+ "offset",
1180
+ "data",
1181
+ "falloff"
1182
+ ],
1183
+ paste: [
1184
+ "at",
1185
+ "size",
1186
+ "radius",
1187
+ "rotation",
1188
+ "mirrorX",
1189
+ "mirrorZ",
1190
+ "blend",
1191
+ "offset",
1192
+ "scale",
1193
+ "data",
1194
+ "falloff"
1195
+ ],
1196
+ erode: [
1197
+ ...brush2,
1198
+ "method",
1199
+ "iterations",
1200
+ "talus",
1201
+ "rate",
1202
+ "droplets",
1203
+ "maxSteps",
1204
+ "inertia",
1205
+ "capacity",
1206
+ "erosion",
1207
+ "deposition",
1208
+ "evaporation"
1209
+ ],
1210
+ terrace: ["step", "softness", "strength", "offset"],
1211
+ materials: ["base", "rules", "reset"],
1212
+ biome: [...brush2, "name", "value"],
1213
+ scatter: [
1214
+ "asset",
1215
+ "count",
1216
+ "minDistance",
1217
+ "scale",
1218
+ "minHeight",
1219
+ "maxHeight",
1220
+ "minSlope",
1221
+ "maxSlope",
1222
+ "avoidWater",
1223
+ "offsetY",
1224
+ "alignToNormal"
1225
+ ],
1226
+ clear: ["target", "asset", "name"],
1227
+ road: ["points", "width", "shoulder", "material", "smooth", ...grade],
1228
+ river: [
1229
+ "points",
1230
+ "width",
1231
+ "shoulder",
1232
+ "depth",
1233
+ "material",
1234
+ "smooth",
1235
+ "water",
1236
+ "waterWidth",
1237
+ "color",
1238
+ "enforceDownhill",
1239
+ ...grade
1240
+ ],
1241
+ ramp: ["from", "to", "width", "shoulder", "material", ...grade],
1242
+ water: ["kind", "at", "radius", "level", "color"],
1243
+ heightmap: ["data", "scale", "offset", "blend", "at", "size", "rotation", "falloff"]
1244
+ });
1245
+ function fail(message) {
1246
+ throw new TypeError(message);
1247
+ }
1248
+ function finiteTree(value, path = "value", depth = 0) {
1249
+ if (depth > 32) fail(`${path}: nesting is too deep`);
1250
+ if (typeof value === "number" && !Number.isFinite(value)) fail(`${path} must be finite`);
1251
+ if (value === void 0 || typeof value === "function" || typeof value === "bigint" || typeof value === "symbol")
1252
+ fail(`${path} is not JSON-serializable`);
1253
+ if (value && typeof value === "object") {
1254
+ if (ArrayBuffer.isView(value)) fail(`${path}: use a plain array in recipes`);
1255
+ for (const [key, entry] of Object.entries(value)) {
1256
+ if (["__proto__", "prototype", "constructor"].includes(key))
1257
+ fail(`${path}: unsafe object key`);
1258
+ finiteTree(entry, `${path}.${key}`, depth + 1);
1259
+ }
1260
+ }
1261
+ }
1262
+ var num = (v, name, min = -1e9, max = 1e9, integer = false) => {
1263
+ if (typeof v !== "number" || !Number.isFinite(v) || v < min || v > max || integer && !Number.isInteger(v))
1264
+ fail(`${name} must be ${integer ? "an integer" : "finite"} between ${min} and ${max}`);
1265
+ };
1266
+ var vec = (v, name, n) => {
1267
+ if (!Array.isArray(v) || v.length !== n) fail(`${name} must contain ${n} numbers`);
1268
+ for (const x of v) num(x, name);
1269
+ };
1270
+ var identifier = (v, name) => {
1271
+ if (typeof v !== "string" || v.length === 0 || v.length > 100 || ["__proto__", "prototype", "constructor"].includes(v))
1272
+ fail(`${name} must be a nonempty safe string, at most 100 characters`);
1273
+ };
1274
+ var enumeration = (v, name, choices) => {
1275
+ if (!choices.includes(v)) fail(`${name}: expected ${choices.join(", ")}`);
1276
+ };
1277
+ function validateConfig(config) {
1278
+ num(config.size, "size", 1, 1e5);
1279
+ if (!RESOLUTIONS.includes(config.resolution))
1280
+ fail(`resolution must be one of ${RESOLUTIONS.join(", ")}`);
1281
+ num(config.seed, "seed", 0, 4294967295, true);
1282
+ }
1283
+ function validateMask(m, depth = 0) {
1284
+ if (!m || typeof m !== "object" || depth > 20) fail("Invalid mask");
1285
+ enumeration(m.type, "mask.type", [
1286
+ "all",
1287
+ "none",
1288
+ "circle",
1289
+ "rectangle",
1290
+ "height",
1291
+ "slope",
1292
+ "noise",
1293
+ "biome",
1294
+ "material",
1295
+ "and",
1296
+ "or",
1297
+ "not"
1298
+ ]);
1299
+ const allowed = {
1300
+ all: [],
1301
+ none: [],
1302
+ circle: ["at", "radius", "falloff"],
1303
+ rectangle: ["at", "size", "rotation", "falloff"],
1304
+ height: ["min", "max", "fade"],
1305
+ slope: ["min", "max", "fade"],
1306
+ noise: ["scale", "threshold", "seed", "fade"],
1307
+ biome: ["name"],
1308
+ material: ["name"],
1309
+ and: ["masks"],
1310
+ or: ["masks"],
1311
+ not: ["mask"]
1312
+ };
1313
+ for (const key of Object.keys(m))
1314
+ if (key !== "type" && !allowed[m.type].includes(key)) fail(`Unknown mask field '${key}'`);
1315
+ if (m.rotation !== void 0) num(m.rotation, "mask.rotation");
1316
+ if (m.seed !== void 0) num(m.seed, "mask.seed", 0, 4294967295, true);
1317
+ if (m.type === "circle") {
1318
+ vec(m.at, "mask.at", 2);
1319
+ num(m.radius, "mask.radius", 1e-3, 1e6);
1320
+ }
1321
+ if (m.type === "rectangle") {
1322
+ vec(m.at, "mask.at", 2);
1323
+ const size = m.size;
1324
+ if (Array.isArray(size)) {
1325
+ vec(size, "mask.size", 2);
1326
+ for (const value of size) num(value, "mask.size", 1e-3, 1e6);
1327
+ } else num(size, "mask.size", 1e-3, 1e6);
1328
+ }
1329
+ if (["height", "slope"].includes(m.type)) {
1330
+ if (m.min !== void 0) num(m.min, "mask.min");
1331
+ if (m.max !== void 0) num(m.max, "mask.max");
1332
+ if ((m.min ?? -1e9) > (m.max ?? 1e9)) fail("mask.min must not exceed mask.max");
1333
+ if (m.fade !== void 0) num(m.fade, "mask.fade", 0, 1e6);
1334
+ }
1335
+ if (m.type === "noise") {
1336
+ num(m.scale, "mask.scale", 1e-3, 1e6);
1337
+ if (m.threshold !== void 0) num(m.threshold, "mask.threshold", 0, 1);
1338
+ if (m.fade !== void 0) num(m.fade, "mask.fade", 1e-6, 1);
1339
+ }
1340
+ if (m.type === "biome") identifier(m.name, "mask.name");
1341
+ if (m.type === "material") enumeration(m.name, "mask.material", MATERIAL_IDS);
1342
+ if (m.type === "not") validateMask(m.mask, depth + 1);
1343
+ if (["and", "or"].includes(m.type)) {
1344
+ if (!Array.isArray(m.masks) || m.masks.length > 32)
1345
+ fail("mask.masks must be an array of at most 32 masks");
1346
+ for (const entry of m.masks) validateMask(entry, depth + 1);
1347
+ }
1348
+ if (m.falloff !== void 0) num(m.falloff, "mask.falloff", 0, 1);
1349
+ }
1350
+ function splinePoint(v, name, follows) {
1351
+ if (!Array.isArray(v) || v.length !== 3) fail(`${name} must contain 3 numbers`);
1352
+ num(v[0], name);
1353
+ num(v[2], name);
1354
+ const y = v[1];
1355
+ if (y === null || y === void 0) {
1356
+ if (follows) return;
1357
+ fail(`${name} must contain 3 numbers`);
1358
+ }
1359
+ if (typeof y !== "number") fail(`${name} must contain 3 numbers`);
1360
+ if (!Number.isFinite(y) && !follows) fail(`${name} elevation must be finite`);
1361
+ }
1362
+ function layerFollowsTerrain(layer) {
1363
+ if (!layer || typeof layer !== "object" || !["road", "river", "ramp"].includes(layer.type))
1364
+ return false;
1365
+ const params = layer.params ?? {};
1366
+ const source = layer.type === "ramp" ? [params.from, params.to] : params.points;
1367
+ if (!Array.isArray(source)) return false;
1368
+ return followsTerrain(source, params.followTerrain);
1369
+ }
1370
+ function omittedElevations(layer, follows) {
1371
+ if (!follows) return layer;
1372
+ const params = layer.params ?? {};
1373
+ const zero = (p) => Array.isArray(p) && p.length === 3 ? [p[0], 0, p[2]] : p;
1374
+ const points = params.points;
1375
+ const fixes = {
1376
+ ...Array.isArray(points) ? { points: points.map(zero) } : {},
1377
+ ...Array.isArray(params.from) ? { from: zero(params.from) } : {},
1378
+ ...Array.isArray(params.to) ? { to: zero(params.to) } : {}
1379
+ };
1380
+ return Object.keys(fixes).length ? { ...layer, params: { ...params, ...fixes } } : layer;
1381
+ }
1382
+ function validateLayer(layer) {
1383
+ const follows = layerFollowsTerrain(layer);
1384
+ finiteTree(omittedElevations(layer, follows), "layer");
1385
+ if (!layer || typeof layer !== "object") fail("layer must be an object");
1386
+ for (const k of Object.keys(layer))
1387
+ if (!["id", "name", "type", "params", "enabled", "opacity", "mask"].includes(k))
1388
+ fail(`Unknown layer field '${k}'`);
1389
+ identifier(layer.id, "layer.id");
1390
+ enumeration(layer.type, "operation", OPERATION_TYPES);
1391
+ if (layer.name !== void 0) identifier(layer.name, "layer.name");
1392
+ if (layer.enabled !== void 0 && typeof layer.enabled !== "boolean")
1393
+ fail("enabled must be boolean");
1394
+ if (layer.opacity !== void 0) num(layer.opacity, "opacity", 0, 1);
1395
+ if (layer.mask) validateMask(layer.mask);
1396
+ const params = layer.params ?? {};
1397
+ if (!params || Array.isArray(params) || typeof params !== "object")
1398
+ fail("params must be an object");
1399
+ for (const k of Object.keys(params))
1400
+ if (!PARAMS[layer.type].includes(k) && k !== "seed")
1401
+ fail(`Unknown ${layer.type} parameter '${k}'`);
1402
+ for (const key of [
1403
+ "island",
1404
+ "mirrorX",
1405
+ "mirrorZ",
1406
+ "reset",
1407
+ "avoidWater",
1408
+ "alignToNormal",
1409
+ "smooth",
1410
+ "water",
1411
+ "enforceDownhill",
1412
+ "followTerrain"
1413
+ ])
1414
+ if (params[key] !== void 0 && typeof params[key] !== "boolean")
1415
+ fail(`${key} must be boolean`);
1416
+ if (params.seed !== void 0) num(params.seed, "seed", 0, 4294967295, true);
1417
+ if (params.at !== void 0) vec(params.at, "at", 2);
1418
+ if (params.from !== void 0) splinePoint(params.from, "from", follows);
1419
+ if (params.to !== void 0) splinePoint(params.to, "to", follows);
1420
+ const points = params.points;
1421
+ const isSpline = ["road", "river"].includes(layer.type);
1422
+ if (points) {
1423
+ if (!Array.isArray(points) || !points.length || points.length > 8192)
1424
+ fail("points must have 1..8192 entries");
1425
+ const dimensions = isSpline ? 3 : 2;
1426
+ for (const point2 of points)
1427
+ if (isSpline) splinePoint(point2, "point", follows);
1428
+ else vec(point2, "point", dimensions);
1429
+ }
1430
+ if (Array.isArray(points) && !isSpline) {
1431
+ const stroke = points;
1432
+ const spacing = Math.max(
1433
+ 0.1,
1434
+ (params.radius ?? 30) * 2 * (params.spacing ?? 0.15)
1435
+ );
1436
+ let samples = 1;
1437
+ for (let k = 1; k < stroke.length; k += 1) {
1438
+ const next = stroke[k];
1439
+ const previous = stroke[k - 1];
1440
+ samples += Math.max(
1441
+ 1,
1442
+ Math.ceil(
1443
+ Math.hypot(
1444
+ next[0] - previous[0],
1445
+ next[1] - previous[1]
1446
+ ) / spacing
1447
+ )
1448
+ );
1449
+ }
1450
+ if (samples > 65536)
1451
+ fail(
1452
+ "Brush interpolation exceeds 65536 samples; increase radius/spacing or shorten the stroke"
1453
+ );
1454
+ }
1455
+ if (isSpline && (!Array.isArray(points) || points.length < 2))
1456
+ fail(`${layer.type} requires at least two [x,y,z] points`);
1457
+ if (layer.type === "ramp" && (!params.from || !params.to)) fail("ramp requires from and to");
1458
+ for (const key of ["radius", "size"]) {
1459
+ const value = params[key];
1460
+ if (value === void 0) continue;
1461
+ if (Array.isArray(value)) {
1462
+ vec(value, key, 2);
1463
+ for (const entry of value) num(entry, key, 1e-3, 1e6);
1464
+ } else num(value, key, 1e-3, 1e6);
1465
+ }
1466
+ if (Array.isArray(params.radius) && !["stamp", "paste"].includes(layer.type))
1467
+ fail("Brush radius must be one number");
1468
+ for (const key of [
1469
+ "amplitude",
1470
+ "height",
1471
+ "base",
1472
+ "offset",
1473
+ "offsetY",
1474
+ "minHeight",
1475
+ "maxHeight",
1476
+ "level",
1477
+ "rotation"
1478
+ ])
1479
+ if (params[key] !== void 0 && !(key === "base" && layer.type === "materials")) {
1480
+ if (params[key] !== null || key !== "height") num(params[key], key);
1481
+ }
1482
+ for (const key of ["width", "step"])
1483
+ if (params[key] !== void 0) num(params[key], key, 1e-3, 1e6);
1484
+ for (const key of [
1485
+ "warp",
1486
+ "shoulder",
1487
+ "roughness",
1488
+ "coastDepth",
1489
+ "minDistance",
1490
+ "depth",
1491
+ "capacity",
1492
+ "maxCut",
1493
+ "maxFill"
1494
+ ])
1495
+ if (params[key] !== void 0) num(params[key], key, 0, 1e6);
1496
+ for (const key of [
1497
+ "falloff",
1498
+ "jitter",
1499
+ "softness",
1500
+ "inertia",
1501
+ "erosion",
1502
+ "deposition",
1503
+ "evaporation",
1504
+ "rate",
1505
+ "value",
1506
+ "waterWidth",
1507
+ "maxGrade"
1508
+ ])
1509
+ if (params[key] !== void 0) num(params[key], key, 0, 1);
1510
+ if (params.spacing !== void 0) num(params.spacing, "spacing", 0.01, 2);
1511
+ if (params.strength !== void 0)
1512
+ num(
1513
+ params.strength,
1514
+ "strength",
1515
+ layer.type === "sculpt" ? -1e4 : 0,
1516
+ layer.type === "sculpt" ? 1e4 : 1
1517
+ );
1518
+ if (params.scale !== void 0) {
1519
+ const scale2 = params.scale;
1520
+ if (layer.type === "scatter") {
1521
+ if (Array.isArray(scale2)) {
1522
+ vec(scale2, "scale", 2);
1523
+ if (scale2[0] > scale2[1]) fail("scale range is inverted");
1524
+ for (const entry of scale2) num(entry, "scale", 1e-3, 1e4);
1525
+ } else num(scale2, "scale", 1e-3, 1e4);
1526
+ } else num(scale2, "scale", 1e-3, 1e6);
1527
+ }
1528
+ for (const [key, max] of [
1529
+ ["octaves", 10],
1530
+ ["iterations", 200],
1531
+ ["droplets", 2e5],
1532
+ ["maxSteps", 128],
1533
+ ["count", 2e4]
1534
+ ])
1535
+ if (params[key] !== void 0) num(params[key], key, key === "count" ? 0 : 1, max, true);
1536
+ if (params.persistence !== void 0) num(params.persistence, "persistence", 0.01, 1);
1537
+ if (params.lacunarity !== void 0) num(params.lacunarity, "lacunarity", 1, 4);
1538
+ for (const key of ["minSlope", "maxSlope", "talus"])
1539
+ if (params[key] !== void 0) num(params[key], key, 0, key === "talus" ? 89.9 : 90);
1540
+ if (params.minHeight !== void 0 && params.maxHeight !== void 0 && params.minHeight > params.maxHeight)
1541
+ fail("height range is inverted");
1542
+ if (params.minSlope !== void 0 && params.maxSlope !== void 0 && params.minSlope > params.maxSlope)
1543
+ fail("slope range is inverted");
1544
+ if (params.material !== void 0) enumeration(params.material, "material", MATERIAL_IDS);
1545
+ if (layer.type === "materials") {
1546
+ if (params.base !== void 0) enumeration(params.base, "base", MATERIAL_IDS);
1547
+ const rules = params.rules;
1548
+ if (!Array.isArray(rules) || rules.length > 64) fail("materials requires a rules array");
1549
+ for (const rule of rules) {
1550
+ enumeration(rule.material, "rule.material", MATERIAL_IDS);
1551
+ if (rule.mask) validateMask(rule.mask);
1552
+ if (rule.strength !== void 0) num(rule.strength, "rule.strength", 0, 1);
1553
+ }
1554
+ }
1555
+ if (params.method !== void 0) enumeration(params.method, "method", ["thermal", "hydraulic"]);
1556
+ if (params.mode !== void 0) enumeration(params.mode, "mode", ["fbm", "ridged", "billow"]);
1557
+ if (params.shape !== void 0)
1558
+ enumeration(
1559
+ params.shape,
1560
+ "shape",
1561
+ ["stamp"].includes(layer.type) ? ["mountain", "crater", "ridge", "valley", "mesa", "dune"] : ["circle", "square"]
1562
+ );
1563
+ if (params.blend !== void 0)
1564
+ enumeration(
1565
+ params.blend,
1566
+ "blend",
1567
+ ["noise", "heightmap"].includes(layer.type) ? ["add", "replace"] : ["add", "replace", "min", "max"]
1568
+ );
1569
+ if (layer.type === "clear")
1570
+ enumeration(params.target, "clear.target", ["scatter", "paint", "biome", "all"]);
1571
+ if (layer.type === "water") enumeration(params.kind ?? "lake", "water.kind", ["lake", "ocean"]);
1572
+ if (layer.type === "river" && params.enforceDownhill && Array.isArray(points)) {
1573
+ const river = points;
1574
+ for (let i = 1; i < river.length; i += 1) {
1575
+ const next = river[i];
1576
+ const previous = river[i - 1];
1577
+ if (next[1] > previous[1])
1578
+ fail("River control-point elevations must be non-increasing");
1579
+ }
1580
+ }
1581
+ if (params.name !== void 0) identifier(params.name, "name");
1582
+ if (params.asset !== void 0) identifier(params.asset, "asset");
1583
+ if (["heightmap", "paste"].includes(layer.type) && !params.data)
1584
+ fail(`${layer.type} requires heightmap data`);
1585
+ const data = params.data;
1586
+ if (data) {
1587
+ num(data.width, "data.width", 2, 4097, true);
1588
+ num(data.height, "data.height", 2, 4097, true);
1589
+ if (!Array.isArray(data.values) || data.values.length !== data.width * data.height)
1590
+ fail("heightmap dimensions do not match values");
1591
+ for (const value of data.values)
1592
+ if (typeof value !== "number" || !Number.isFinite(value))
1593
+ fail("data.values must contain only finite numbers");
1594
+ }
1595
+ }
1596
+ function validateDocument(doc) {
1597
+ finiteTree(
1598
+ Array.isArray(doc.layers) ? {
1599
+ ...doc,
1600
+ layers: doc.layers.map((layer) => omittedElevations(layer, layerFollowsTerrain(layer)))
1601
+ } : doc,
1602
+ "recipe"
1603
+ );
1604
+ if (doc.version !== 1) fail("Unsupported recipe version; expected 1");
1605
+ validateConfig(doc.config);
1606
+ if (!Array.isArray(doc.layers) || doc.layers.length > 512)
1607
+ fail("layers must be an array of at most 512 layers");
1608
+ const ids = /* @__PURE__ */ new Set();
1609
+ for (const layer of doc.layers) {
1610
+ validateLayer(layer);
1611
+ if (ids.has(layer.id)) fail(`Duplicate layer ID: ${layer.id}`);
1612
+ ids.add(layer.id);
1613
+ }
1614
+ }
1615
+
1616
+ // src/core/placements.ts
1617
+ function validatePlacementOverrides(value = {}) {
1618
+ finiteTree(value, "placementOverrides");
1619
+ if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
1620
+ throw new Error("Expected placementOverrides object");
1621
+ return Object.fromEntries(
1622
+ Object.entries(value).map(([key, entry]) => {
1623
+ if (!key.length || key.length > 256)
1624
+ throw new Error("Placement key must contain 1\u2013256 characters");
1625
+ if (!entry || typeof entry !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(entry)))
1626
+ throw new Error(`Invalid placement override '${key}'`);
1627
+ const input = entry;
1628
+ if (Object.keys(input).some(
1629
+ (field2) => !["position", "quaternion", "scale", "grounding"].includes(field2)
1630
+ ))
1631
+ throw new Error(`Unknown placement override fields '${key}'`);
1632
+ function vector2(field2, length) {
1633
+ const values = input[field2];
1634
+ if (!Array.isArray(values) || values.length !== length || !Array.from(values).every(Number.isFinite))
1635
+ throw new Error(`${key}.${field2} must contain ${length} finite numbers`);
1636
+ return Array.from(values);
1637
+ }
1638
+ const position = vector2("position", 3);
1639
+ const quaternion = vector2("quaternion", 4);
1640
+ const scale2 = vector2("scale", 3);
1641
+ if (scale2.some((item) => item <= 0)) throw new Error(`${key}.scale must be positive`);
1642
+ if (Math.abs(Math.hypot(...quaternion) - 1) > 1e-6)
1643
+ throw new Error(`${key}.quaternion must be a unit quaternion`);
1644
+ const grounding = input.grounding === void 0 ? true : input.grounding;
1645
+ if (typeof grounding !== "boolean") throw new Error(`${key}.grounding must be boolean`);
1646
+ return [key, { position, quaternion, scale: scale2, grounding }];
1647
+ })
1648
+ );
1649
+ }
1650
+
1651
+ // src/core/terrain.ts
1652
+ var TerrainEvaluator = class {
1653
+ cache = /* @__PURE__ */ new Map();
1654
+ bytes = 0;
1655
+ limit;
1656
+ constructor({ cacheMB = 48 } = {}) {
1657
+ if (!Number.isFinite(cacheMB) || cacheMB < 0)
1658
+ throw new RangeError("cacheMB must be finite and nonnegative");
1659
+ this.limit = cacheMB * 1024 * 1024;
1660
+ }
1661
+ clear() {
1662
+ this.cache.clear();
1663
+ this.bytes = 0;
1664
+ }
1665
+ evaluate(doc, { resolution = doc.config.resolution, signal, onProgress } = {}) {
1666
+ validateDocument(doc);
1667
+ validateConfig({ ...doc.config, resolution });
1668
+ const keys = [];
1669
+ let key = JSON.stringify({ ...doc.config, resolution });
1670
+ for (const layer of doc.layers) {
1671
+ key += `|${JSON.stringify(layer)}`;
1672
+ keys.push(key);
1673
+ }
1674
+ let state;
1675
+ let start = 0;
1676
+ for (let i = keys.length - 1; i >= 0; i--) {
1677
+ const k = keys[i];
1678
+ const hit = this.cache.get(k);
1679
+ if (hit) {
1680
+ state = structuredClone(hit.state);
1681
+ start = i + 1;
1682
+ this.cache.delete(k);
1683
+ this.cache.set(k, hit);
1684
+ break;
1685
+ }
1686
+ }
1687
+ state ??= createState(doc.config, resolution);
1688
+ for (let i = start; i < doc.layers.length; i++) {
1689
+ signal?.throwIfAborted();
1690
+ const layer = doc.layers[i];
1691
+ applyOperation(state, layer);
1692
+ onProgress?.({ index: i, total: doc.layers.length, id: layer.id });
1693
+ const bytes = state.height.byteLength + state.splat.byteLength + Object.values(state.erosion ?? {}).reduce((n, a) => n + a.byteLength, 0) + Object.values(state.biomes).reduce((n, a) => n + a.byteLength, 0);
1694
+ if (bytes <= this.limit) {
1695
+ while (this.bytes + bytes > this.limit && this.cache.size) {
1696
+ const first = this.cache.entries().next().value;
1697
+ if (!first) break;
1698
+ this.bytes -= first[1].bytes;
1699
+ this.cache.delete(first[0]);
1700
+ }
1701
+ this.cache.set(keys[i], { state: structuredClone(state), bytes });
1702
+ this.bytes += bytes;
1703
+ }
1704
+ }
1705
+ signal?.throwIfAborted();
1706
+ if (!state.height.every(Number.isFinite))
1707
+ throw new Error("A terrain operation produced a non-finite height");
1708
+ return finalizeState(state);
1709
+ }
1710
+ };
1711
+ var Terrain = class _Terrain {
1712
+ _doc;
1713
+ _undo = [];
1714
+ _redo = [];
1715
+ _listeners = /* @__PURE__ */ new Set();
1716
+ _historyLimit;
1717
+ _transactionDepth = 0;
1718
+ _evaluator = new TerrainEvaluator();
1719
+ constructor({
1720
+ size = 512,
1721
+ resolution = 257,
1722
+ seed = 73,
1723
+ historyLimit = 60
1724
+ } = {}) {
1725
+ const config = { size, resolution, seed };
1726
+ validateConfig(config);
1727
+ if (!Number.isInteger(historyLimit) || historyLimit < 0)
1728
+ throw new RangeError("historyLimit must be a nonnegative integer");
1729
+ this._doc = { version: 1, config, layers: [] };
1730
+ this._historyLimit = historyLimit;
1731
+ }
1732
+ static fromJSON(json2) {
1733
+ const doc = typeof json2 === "string" ? JSON.parse(json2) : structuredClone(json2);
1734
+ validateDocument(doc);
1735
+ const terrain = new _Terrain(doc.config);
1736
+ terrain._doc = doc;
1737
+ return terrain;
1738
+ }
1739
+ get layers() {
1740
+ return structuredClone(this._doc.layers);
1741
+ }
1742
+ get config() {
1743
+ return structuredClone(this._doc.config);
1744
+ }
1745
+ get canUndo() {
1746
+ return this._undo.length > 0;
1747
+ }
1748
+ get canRedo() {
1749
+ return this._redo.length > 0;
1750
+ }
1751
+ toJSON() {
1752
+ return structuredClone(this._doc);
1753
+ }
1754
+ subscribe(fn) {
1755
+ this._listeners.add(fn);
1756
+ return () => this._listeners.delete(fn);
1757
+ }
1758
+ _emit() {
1759
+ for (const fn of this._listeners) {
1760
+ try {
1761
+ fn(this);
1762
+ } catch (error) {
1763
+ console.error("Terrain subscriber failed:", error);
1764
+ }
1765
+ }
1766
+ }
1767
+ _change(fn) {
1768
+ const before = structuredClone(this._doc);
1769
+ try {
1770
+ fn();
1771
+ validateDocument(this._doc);
1772
+ } catch (error) {
1773
+ this._doc = before;
1774
+ throw error;
1775
+ }
1776
+ if (!this._transactionDepth) {
1777
+ this._undo.push(before);
1778
+ if (this._undo.length > this._historyLimit) this._undo.shift();
1779
+ this._redo = [];
1780
+ this._emit();
1781
+ }
1782
+ return this;
1783
+ }
1784
+ _id(type) {
1785
+ let i = 1;
1786
+ while (this._doc.layers.some((layer) => layer.id === `${type}-${i}`)) i++;
1787
+ return `${type}-${i}`;
1788
+ }
1789
+ add(layer) {
1790
+ const next = { enabled: true, opacity: 1, ...structuredClone(layer) };
1791
+ next.id ??= this._id(next.type);
1792
+ validateLayer(next);
1793
+ return this._change(() => {
1794
+ const i = this._doc.layers.findIndex((layer2) => layer2.id === next.id);
1795
+ if (i < 0) this._doc.layers.push(next);
1796
+ else this._doc.layers[i] = next;
1797
+ });
1798
+ }
1799
+ _operation(type, options) {
1800
+ const { id, label, enabled, opacity, mask, ...params } = options;
1801
+ if ("data" in params && params.data && typeof params.data === "object" && "values" in params.data && ArrayBuffer.isView(params.data.values)) {
1802
+ const data = params.data;
1803
+ Object.assign(params, { data: { ...data, values: Array.from(data.values) } });
1804
+ }
1805
+ const layer = { id: id ?? this._id(type), type, params };
1806
+ const optional = Object.fromEntries(
1807
+ Object.entries({ name: label, enabled, opacity, mask }).filter(
1808
+ ([, value]) => value !== void 0
1809
+ )
1810
+ );
1811
+ return this.add(Object.assign(layer, optional));
1812
+ }
1813
+ layer(id) {
1814
+ const layer = this._doc.layers.find((layer2) => layer2.id === id);
1815
+ if (!layer) throw new Error(`Unknown layer '${id}'`);
1816
+ return structuredClone(layer);
1817
+ }
1818
+ update(id, patch) {
1819
+ const old = this.layer(id);
1820
+ const next = {
1821
+ ...old,
1822
+ ...structuredClone(patch),
1823
+ id,
1824
+ params: { ...old.params, ...structuredClone(patch.params ?? {}) }
1825
+ };
1826
+ const { mask: _mask, ...unmasked } = next;
1827
+ return this.add(patch.mask === null ? unmasked : next);
1828
+ }
1829
+ toggle(id, enabled = this.layer(id).enabled === false) {
1830
+ return this.update(id, { enabled });
1831
+ }
1832
+ move(id, index) {
1833
+ if (!Number.isInteger(index) || index < 0 || index >= this._doc.layers.length)
1834
+ throw new RangeError("Layer index out of bounds");
1835
+ return this._change(() => {
1836
+ const i = this._doc.layers.findIndex((layer2) => layer2.id === id);
1837
+ if (i < 0) throw new Error(`Unknown layer '${id}'`);
1838
+ const layer = this._doc.layers.splice(i, 1)[0];
1839
+ this._doc.layers.splice(index, 0, layer);
1840
+ });
1841
+ }
1842
+ remove(id) {
1843
+ this.layer(id);
1844
+ return this._change(() => {
1845
+ this._doc.layers = this._doc.layers.filter((layer) => layer.id !== id);
1846
+ });
1847
+ }
1848
+ setConfig(patch) {
1849
+ const config = { ...this._doc.config, ...patch };
1850
+ validateConfig(config);
1851
+ return this._change(() => {
1852
+ this._doc.config = config;
1853
+ });
1854
+ }
1855
+ loadJSON(json2) {
1856
+ const next = _Terrain.fromJSON(json2).toJSON();
1857
+ return this._change(() => {
1858
+ this._doc = next;
1859
+ });
1860
+ }
1861
+ transaction(fn) {
1862
+ if (fn.constructor.name === "AsyncFunction")
1863
+ throw new TypeError("Transactions must be synchronous");
1864
+ const before = structuredClone(this._doc);
1865
+ const undo = this._undo.slice();
1866
+ const redo = this._redo.slice();
1867
+ this._transactionDepth++;
1868
+ try {
1869
+ const result = fn(this);
1870
+ if (result && (typeof result === "object" || typeof result === "function") && "then" in result && typeof result.then === "function")
1871
+ throw new TypeError("Transactions must be synchronous");
1872
+ validateDocument(this._doc);
1873
+ this._transactionDepth--;
1874
+ if (!this._transactionDepth) {
1875
+ this._undo.push(before);
1876
+ if (this._undo.length > this._historyLimit) this._undo.shift();
1877
+ this._redo = [];
1878
+ this._emit();
1879
+ }
1880
+ return this;
1881
+ } catch (error) {
1882
+ this._transactionDepth--;
1883
+ this._doc = before;
1884
+ this._undo = undo;
1885
+ this._redo = redo;
1886
+ throw error;
1887
+ }
1888
+ }
1889
+ applyPatch(commands) {
1890
+ return this.transaction(() => {
1891
+ for (const command of commands) {
1892
+ switch (command.op) {
1893
+ case "upsert":
1894
+ this.add(command.layer);
1895
+ break;
1896
+ case "update":
1897
+ this.update(command.id, command.patch);
1898
+ break;
1899
+ case "remove":
1900
+ this.remove(command.id);
1901
+ break;
1902
+ case "move":
1903
+ this.move(command.id, command.index);
1904
+ break;
1905
+ case "toggle":
1906
+ this.toggle(command.id, command.enabled);
1907
+ break;
1908
+ default:
1909
+ throw new Error("Unknown patch command");
1910
+ }
1911
+ }
1912
+ });
1913
+ }
1914
+ undo() {
1915
+ const prior = this._undo.pop();
1916
+ if (prior) {
1917
+ this._redo.push(structuredClone(this._doc));
1918
+ this._doc = prior;
1919
+ this._emit();
1920
+ }
1921
+ return this;
1922
+ }
1923
+ redo() {
1924
+ const next = this._redo.pop();
1925
+ if (next) {
1926
+ this._undo.push(structuredClone(this._doc));
1927
+ this._doc = next;
1928
+ this._emit();
1929
+ }
1930
+ return this;
1931
+ }
1932
+ evaluate(options) {
1933
+ return this._evaluator.evaluate(this._doc, options);
1934
+ }
1935
+ clearCache() {
1936
+ this._evaluator.clear();
1937
+ }
1938
+ copyRegion({ at = [0, 0], size = 64, resolution = 33 } = {}) {
1939
+ if (!RESOLUTIONS.includes(resolution)) throw new RangeError("Unsupported copy resolution");
1940
+ if (!(size > 0 && Number.isFinite(size))) throw new RangeError("Copy size must be positive");
1941
+ const state = this.evaluate();
1942
+ const values = [];
1943
+ for (let z = 0; z < resolution; z++)
1944
+ for (let x = 0; x < resolution; x++)
1945
+ values.push(
1946
+ sampleHeight(
1947
+ state,
1948
+ at[0] + (x / (resolution - 1) - 0.5) * size,
1949
+ at[1] + (z / (resolution - 1) - 0.5) * size
1950
+ )
1951
+ );
1952
+ return { width: resolution, height: resolution, values };
1953
+ }
1954
+ inspect(options) {
1955
+ const state = this.evaluate(options);
1956
+ let min = Number.POSITIVE_INFINITY;
1957
+ let max = Number.NEGATIVE_INFINITY;
1958
+ for (const height of state.height) {
1959
+ min = Math.min(min, height);
1960
+ max = Math.max(max, height);
1961
+ }
1962
+ return {
1963
+ size: state.size,
1964
+ resolution: state.resolution,
1965
+ layers: this._doc.layers.length,
1966
+ vertices: state.height.length,
1967
+ triangles: 2 * (state.resolution - 1) ** 2,
1968
+ minHeight: min,
1969
+ maxHeight: max,
1970
+ instances: state.instances.length,
1971
+ diagnostics: state.diagnostics
1972
+ };
1973
+ }
1974
+ noise(options = {}) {
1975
+ return this._operation("noise", options);
1976
+ }
1977
+ sculpt(options = {}) {
1978
+ return this._operation("sculpt", options);
1979
+ }
1980
+ smooth(options = {}) {
1981
+ return this._operation("smooth", options);
1982
+ }
1983
+ flatten(options = {}) {
1984
+ return this._operation("flatten", options);
1985
+ }
1986
+ ramp(options) {
1987
+ return this._operation("ramp", options);
1988
+ }
1989
+ stamp(options = {}) {
1990
+ return this._operation("stamp", options);
1991
+ }
1992
+ erode(options = {}) {
1993
+ return this._operation("erode", options);
1994
+ }
1995
+ terrace(options = {}) {
1996
+ return this._operation("terrace", options);
1997
+ }
1998
+ materials(options) {
1999
+ return this._operation("materials", options);
2000
+ }
2001
+ paint(options = {}) {
2002
+ return this._operation("paint", options);
2003
+ }
2004
+ biome(options = {}) {
2005
+ return this._operation("biome", options);
2006
+ }
2007
+ scatter(options = {}) {
2008
+ return this._operation("scatter", options);
2009
+ }
2010
+ clear(options) {
2011
+ return this._operation("clear", options);
2012
+ }
2013
+ road(options) {
2014
+ return this._operation("road", options);
2015
+ }
2016
+ river(options) {
2017
+ return this._operation("river", options);
2018
+ }
2019
+ water(options = {}) {
2020
+ return this._operation("water", options);
2021
+ }
2022
+ heightmap(options) {
2023
+ return this._operation("heightmap", options);
2024
+ }
2025
+ paste(options) {
2026
+ return this._operation("paste", options);
2027
+ }
2028
+ };
2029
+
2030
+ // src/editor/images.ts
2031
+ var SURFACE_CHANNELS = {
2032
+ albedo: "srgb",
2033
+ ao: "linear",
2034
+ height: "linear",
2035
+ metalness: "linear",
2036
+ normal: "linear",
2037
+ opacity: "linear",
2038
+ roughness: "linear"
2039
+ };
2040
+ function fail2(message) {
2041
+ throw new Error(message);
2042
+ }
2043
+ var CRC = (() => {
2044
+ const table = new Uint32Array(256);
2045
+ for (let n = 0; n < 256; n += 1) {
2046
+ let c = n;
2047
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
2048
+ table[n] = c >>> 0;
2049
+ }
2050
+ return table;
2051
+ })();
2052
+ function crc32(bytes) {
2053
+ let c = 4294967295;
2054
+ for (const byte of bytes) c = CRC[(c ^ byte) & 255] ^ c >>> 8;
2055
+ return (c ^ 4294967295) >>> 0;
2056
+ }
2057
+ function png(bytes) {
2058
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
2059
+ let width = 0;
2060
+ let height = 0;
2061
+ let depth = 0;
2062
+ let sawData = false;
2063
+ let sawEnd = false;
2064
+ const diagnostics = [];
2065
+ for (let offset = 8; offset < bytes.byteLength; ) {
2066
+ if (offset + 12 > bytes.byteLength) fail2("PNG chunk is truncated");
2067
+ const length = view.getUint32(offset);
2068
+ const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
2069
+ if (offset + 12 + length > bytes.byteLength) fail2(`PNG ${type} chunk runs past the end`);
2070
+ const body2 = bytes.subarray(offset + 4, offset + 8 + length);
2071
+ if (crc32(body2) !== view.getUint32(offset + 8 + length))
2072
+ fail2(`PNG ${type} chunk fails its checksum; the file is damaged`);
2073
+ if (type === "IHDR") {
2074
+ if (length !== 13) fail2("PNG header is malformed");
2075
+ width = view.getUint32(offset + 8);
2076
+ height = view.getUint32(offset + 12);
2077
+ depth = bytes[offset + 16];
2078
+ } else if (type === "IDAT") sawData = true;
2079
+ else if (type === "IEND") {
2080
+ sawEnd = true;
2081
+ break;
2082
+ }
2083
+ offset += 12 + length;
2084
+ }
2085
+ if (!width || !height) fail2("PNG has no header");
2086
+ if (!sawData || !sawEnd) fail2("PNG is missing its image data or end marker");
2087
+ if (depth === 16) diagnostics.push("16-bit PNG: browsers decode it to 8 bits per channel");
2088
+ return { format: "png", width, height, diagnostics };
2089
+ }
2090
+ function jpeg(bytes) {
2091
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
2092
+ let offset = 2;
2093
+ while (offset + 4 <= bytes.byteLength) {
2094
+ if (bytes[offset] !== 255) fail2("JPEG has a damaged marker");
2095
+ const marker = bytes[offset + 1];
2096
+ if (marker === 255) {
2097
+ offset += 1;
2098
+ continue;
2099
+ }
2100
+ const length = view.getUint16(offset + 2);
2101
+ if ([192, 193, 194].includes(marker)) {
2102
+ if (offset + 9 > bytes.byteLength) fail2("JPEG frame header is truncated");
2103
+ const height = view.getUint16(offset + 5);
2104
+ const width = view.getUint16(offset + 7);
2105
+ if (!width || !height) fail2("JPEG has no size");
2106
+ const ended = bytes[bytes.byteLength - 2] === 255 && bytes[bytes.byteLength - 1] === 217;
2107
+ if (!ended) fail2("JPEG is truncated: it has no end marker");
2108
+ return { format: "jpeg", width, height, diagnostics: [] };
2109
+ }
2110
+ offset += 2 + length;
2111
+ }
2112
+ return fail2("JPEG has no frame header");
2113
+ }
2114
+ function webp(bytes) {
2115
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
2116
+ if (view.getUint32(4, true) + 8 !== bytes.byteLength)
2117
+ fail2("WebP length header disagrees with the file");
2118
+ const kind = String.fromCharCode(...bytes.subarray(12, 16));
2119
+ if (bytes.byteLength < 30) fail2("WebP header is truncated");
2120
+ if (kind === "VP8X")
2121
+ return {
2122
+ format: "webp",
2123
+ width: 1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16),
2124
+ height: 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16),
2125
+ diagnostics: []
2126
+ };
2127
+ if (kind === "VP8 ")
2128
+ return {
2129
+ format: "webp",
2130
+ width: view.getUint16(26, true) & 16383,
2131
+ height: view.getUint16(28, true) & 16383,
2132
+ diagnostics: []
2133
+ };
2134
+ if (kind === "VP8L") {
2135
+ const bits = view.getUint32(21, true);
2136
+ return {
2137
+ format: "webp",
2138
+ width: 1 + (bits & 16383),
2139
+ height: 1 + (bits >> 14 & 16383),
2140
+ diagnostics: []
2141
+ };
2142
+ }
2143
+ return fail2(`WebP chunk '${kind}' is not supported`);
2144
+ }
2145
+ function hdr(bytes) {
2146
+ const head = new TextDecoder("latin1").decode(
2147
+ bytes.subarray(0, Math.min(bytes.byteLength, 4096))
2148
+ );
2149
+ const end = head.indexOf("\n\n");
2150
+ if (end < 0) fail2("Radiance HDR header is not terminated");
2151
+ if (!/FORMAT=32-bit_rle_rgbe/u.test(head.slice(0, end)))
2152
+ fail2("Radiance HDR must be 32-bit_rle_rgbe");
2153
+ const size = /^([-+])Y (\d+) ([-+])X (\d+)/mu.exec(head.slice(end + 2));
2154
+ if (!size) fail2("Radiance HDR has no resolution line");
2155
+ return { format: "hdr", width: Number(size[4]), height: Number(size[2]), diagnostics: [] };
2156
+ }
2157
+ function exr(bytes) {
2158
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
2159
+ const text3 = new TextDecoder("latin1");
2160
+ let offset = 8;
2161
+ let window;
2162
+ let compression;
2163
+ while (offset < bytes.byteLength && bytes[offset] !== 0) {
2164
+ const nameEnd = bytes.indexOf(0, offset);
2165
+ const typeEnd = bytes.indexOf(0, nameEnd + 1);
2166
+ if (nameEnd < 0 || typeEnd < 0 || typeEnd + 5 > bytes.byteLength)
2167
+ fail2("EXR header is truncated");
2168
+ const name = text3.decode(bytes.subarray(offset, nameEnd));
2169
+ const size = view.getInt32(typeEnd + 1, true);
2170
+ const value = typeEnd + 5;
2171
+ if (size < 0 || value + size > bytes.byteLength) fail2("EXR attribute runs past the file");
2172
+ if (name === "dataWindow" && size === 16)
2173
+ window = [
2174
+ view.getInt32(value, true),
2175
+ view.getInt32(value + 4, true),
2176
+ view.getInt32(value + 8, true),
2177
+ view.getInt32(value + 12, true)
2178
+ ];
2179
+ if (name === "compression") compression = bytes[value];
2180
+ offset = value + size;
2181
+ }
2182
+ if (!window) fail2("EXR has no data window");
2183
+ const diagnostics = [];
2184
+ if (compression === 6 || compression === 7)
2185
+ diagnostics.push("EXR B44 compression is not read by the loader");
2186
+ return {
2187
+ format: "exr",
2188
+ width: window[2] - window[0] + 1,
2189
+ height: window[3] - window[1] + 1,
2190
+ diagnostics
2191
+ };
2192
+ }
2193
+ function inspectImage(bytes, format, limits) {
2194
+ const report = format === "png" ? png(bytes) : format === "jpeg" ? jpeg(bytes) : format === "webp" ? webp(bytes) : format === "hdr" ? hdr(bytes) : format === "exr" ? exr(bytes) : fail2(`'${format}' is not a supported image format`);
2195
+ if (report.width > limits.maxDimension || report.height > limits.maxDimension)
2196
+ fail2(
2197
+ `Image is ${report.width}x${report.height}; this project's limit is ${limits.maxDimension} pixels`
2198
+ );
2199
+ if (report.width % 4 || report.height % 4)
2200
+ report.diagnostics.push(
2201
+ "Size is not a multiple of 4: block compression of this image would fail"
2202
+ );
2203
+ return report;
2204
+ }
2205
+
2206
+ // src/editor/assets.ts
2207
+ var DEFAULT_ASSET_LIMITS = {
2208
+ maxBytes: 128 * 1024 * 1024,
2209
+ maxDimension: 16384,
2210
+ maxTriangles: 5e6
2211
+ };
2212
+ var MAX_ASSETS = 64;
2213
+ var ID = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
2214
+ var PATH = /^(models|images|environments)\/[a-f0-9]{12}-[a-z0-9_-]+\.(glb|png|jpg|webp|hdr|exr)$/u;
2215
+ var FOLDER = {
2216
+ model: "models",
2217
+ image: "images",
2218
+ environment: "environments"
2219
+ };
2220
+ var GLB_MAGIC = 1179937895;
2221
+ var JSON_CHUNK = 1313821514;
2222
+ var BIN_CHUNK = 5130562;
2223
+ var COMPONENT_BYTES = {
2224
+ 5120: 1,
2225
+ 5121: 1,
2226
+ 5122: 2,
2227
+ 5123: 2,
2228
+ 5125: 4,
2229
+ 5126: 4
2230
+ };
2231
+ var TYPE_COUNT = {
2232
+ SCALAR: 1,
2233
+ VEC2: 2,
2234
+ VEC3: 3,
2235
+ VEC4: 4,
2236
+ MAT2: 4,
2237
+ MAT3: 9,
2238
+ MAT4: 16
2239
+ };
2240
+ function fail3(message) {
2241
+ throw new Error(message);
2242
+ }
2243
+ function inspectGlb(bytes, limits) {
2244
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
2245
+ if (bytes.byteLength < 20 || view.getUint32(0, true) !== GLB_MAGIC)
2246
+ fail3("Not a GLB file: missing the glTF binary header");
2247
+ if (view.getUint32(4, true) !== 2) fail3("Unsupported GLB version; expected glTF 2");
2248
+ if (view.getUint32(8, true) !== bytes.byteLength)
2249
+ fail3("GLB length header disagrees with the file size");
2250
+ let json2;
2251
+ let bin;
2252
+ for (let offset = 12; offset < bytes.byteLength; ) {
2253
+ if (offset + 8 > bytes.byteLength) fail3("GLB chunk header is truncated");
2254
+ const length = view.getUint32(offset, true);
2255
+ const type = view.getUint32(offset + 4, true);
2256
+ const start = offset + 8;
2257
+ if (start + length > bytes.byteLength) fail3("GLB chunk runs past the end of the file");
2258
+ if (type === JSON_CHUNK)
2259
+ try {
2260
+ json2 = JSON.parse(new TextDecoder().decode(bytes.subarray(start, start + length)));
2261
+ } catch {
2262
+ fail3("GLB JSON chunk is not valid JSON");
2263
+ }
2264
+ else if (type === BIN_CHUNK && bin === void 0) bin = bytes.subarray(start, start + length);
2265
+ offset = start + length + (4 - length % 4) % 4;
2266
+ }
2267
+ if (!json2 || json2.asset?.version?.startsWith("2") !== true) fail3("GLB has no glTF 2 JSON chunk");
2268
+ const gltf = json2;
2269
+ const diagnostics = [];
2270
+ for (const [index, buffer] of (gltf.buffers ?? []).entries())
2271
+ if (buffer.uri !== void 0 && !buffer.uri.startsWith("data:"))
2272
+ fail3(
2273
+ `GLB references external buffer '${buffer.uri}'; supply a self-contained .glb, dependencies are never fetched (buffer ${index})`
2274
+ );
2275
+ for (const image of gltf.images ?? [])
2276
+ if (image.uri !== void 0 && !image.uri.startsWith("data:"))
2277
+ fail3(`GLB references external image '${image.uri}'; embed it or supply it as a project file`);
2278
+ if (gltf.extensionsRequired?.length)
2279
+ fail3(
2280
+ `GLB requires extensions this editor does not guarantee: ${gltf.extensionsRequired.join(", ")}`
2281
+ );
2282
+ const accessorData = (index) => {
2283
+ const accessor = gltf.accessors?.[index] ?? fail3(`GLB accessor ${index} is missing`);
2284
+ if (accessor.sparse) fail3(`GLB accessor ${index} is sparse, which is not supported`);
2285
+ const width = COMPONENT_BYTES[accessor.componentType] ?? fail3(`GLB accessor ${index} has an unknown component type`);
2286
+ const components = TYPE_COUNT[accessor.type] ?? fail3(`GLB accessor ${index} has an unknown type`);
2287
+ const viewIndex = accessor.bufferView ?? fail3(`GLB accessor ${index} has no buffer view`);
2288
+ const bufferView = gltf.bufferViews?.[viewIndex] ?? fail3(`GLB buffer view ${viewIndex} is missing`);
2289
+ const source = bufferView.buffer === 0 && bin ? bin : fail3("GLB buffer data is not embedded");
2290
+ const stride = bufferView.byteStride ?? width * components;
2291
+ const base = (bufferView.byteOffset ?? 0) + (accessor.byteOffset ?? 0);
2292
+ if (base + stride * Math.max(accessor.count - 1, 0) + width * components > source.byteLength)
2293
+ fail3(`GLB accessor ${index} reads past its buffer`);
2294
+ const data = new DataView(source.buffer, source.byteOffset, source.byteLength);
2295
+ return {
2296
+ count: accessor.count,
2297
+ components,
2298
+ read: (i, c) => {
2299
+ const at = base + stride * i + width * c;
2300
+ switch (accessor.componentType) {
2301
+ case 5126:
2302
+ return data.getFloat32(at, true);
2303
+ case 5125:
2304
+ return data.getUint32(at, true);
2305
+ case 5123:
2306
+ return data.getUint16(at, true);
2307
+ case 5122:
2308
+ return data.getInt16(at, true);
2309
+ case 5121:
2310
+ return data.getUint8(at);
2311
+ default:
2312
+ return data.getInt8(at);
2313
+ }
2314
+ }
2315
+ };
2316
+ };
2317
+ const world2 = (node) => {
2318
+ if (node.matrix?.length === 16) return new Matrix4().fromArray(node.matrix);
2319
+ const t = node.translation ?? [0, 0, 0];
2320
+ const r = node.rotation ?? [0, 0, 0, 1];
2321
+ const s = node.scale ?? [1, 1, 1];
2322
+ return new Matrix4().compose(
2323
+ new Vector3(t[0], t[1], t[2]),
2324
+ new Quaternion(r[0], r[1], r[2], r[3]),
2325
+ new Vector3(s[0], s[1], s[2])
2326
+ );
2327
+ };
2328
+ const low = new Vector3(
2329
+ Number.POSITIVE_INFINITY,
2330
+ Number.POSITIVE_INFINITY,
2331
+ Number.POSITIVE_INFINITY
2332
+ );
2333
+ const high = new Vector3(
2334
+ Number.NEGATIVE_INFINITY,
2335
+ Number.NEGATIVE_INFINITY,
2336
+ Number.NEGATIVE_INFINITY
2337
+ );
2338
+ let triangles = 0;
2339
+ let lights = 0;
2340
+ let cameras = 0;
2341
+ const visit = (nodeIndex, parent, depth) => {
2342
+ if (depth > 64) fail3("GLB node hierarchy is too deep");
2343
+ const node = gltf.nodes?.[nodeIndex] ?? fail3(`GLB node ${nodeIndex} is missing`);
2344
+ const matrix = parent.clone().multiply(world2(node));
2345
+ if (node.camera !== void 0) cameras += 1;
2346
+ if (node.extensions?.KHR_lights_punctual) lights += 1;
2347
+ if (node.mesh !== void 0) {
2348
+ const mesh = gltf.meshes?.[node.mesh] ?? fail3(`GLB mesh ${node.mesh} is missing`);
2349
+ for (const primitive of mesh.primitives) {
2350
+ if ((primitive.mode ?? 4) !== 4)
2351
+ fail3("GLB has a non-triangle primitive, which is not supported");
2352
+ const position = accessorData(
2353
+ primitive.attributes.POSITION ?? fail3("GLB primitive has no POSITION")
2354
+ );
2355
+ if (position.components !== 3) fail3("GLB POSITION must be VEC3");
2356
+ const point2 = new Vector3();
2357
+ for (let i = 0; i < position.count; i += 1) {
2358
+ point2.set(position.read(i, 0), position.read(i, 1), position.read(i, 2));
2359
+ if (!Number.isFinite(point2.x + point2.y + point2.z))
2360
+ fail3(`GLB geometry has a non-finite vertex (vertex ${i})`);
2361
+ point2.applyMatrix4(matrix);
2362
+ low.min(point2);
2363
+ high.max(point2);
2364
+ }
2365
+ triangles += primitive.indices === void 0 ? Math.floor(position.count / 3) : Math.floor(accessorData(primitive.indices).count / 3);
2366
+ }
2367
+ }
2368
+ for (const child of node.children ?? []) visit(child, matrix, depth + 1);
2369
+ };
2370
+ const roots = gltf.scenes?.[gltf.scene ?? 0]?.nodes ?? fail3("GLB has no default scene with nodes");
2371
+ for (const root of roots) visit(root, new Matrix4(), 0);
2372
+ if (!Number.isFinite(low.x + high.x) || triangles === 0)
2373
+ fail3("GLB contains no triangle geometry");
2374
+ if (triangles > limits.maxTriangles)
2375
+ fail3(`GLB has ${triangles} triangles; this project's limit is ${limits.maxTriangles}`);
2376
+ if (lights)
2377
+ diagnostics.push(
2378
+ `${lights} imported light(s) stay inactive: lights come from the world's environment`
2379
+ );
2380
+ if (cameras)
2381
+ diagnostics.push(`${cameras} imported camera(s) stay inactive: the editor owns its cameras`);
2382
+ return {
2383
+ bounds: {
2384
+ min: low.toArray(),
2385
+ max: high.toArray()
2386
+ },
2387
+ triangles,
2388
+ diagnostics
2389
+ };
2390
+ }
2391
+ function text(value, name, max = 512) {
2392
+ if (typeof value !== "string" || !value || value.length > max)
2393
+ throw new Error(`${name} must be a short non-empty string`);
2394
+ return value;
2395
+ }
2396
+ function validateAsset(input) {
2397
+ if (!input || typeof input !== "object" || Array.isArray(input))
2398
+ throw new Error("An asset must be an object");
2399
+ const value = input;
2400
+ const known = [
2401
+ "id",
2402
+ "kind",
2403
+ "name",
2404
+ "path",
2405
+ "sha256",
2406
+ "bytes",
2407
+ "status",
2408
+ "license",
2409
+ "source",
2410
+ "bounds",
2411
+ "triangles",
2412
+ "adjust",
2413
+ "diagnostics",
2414
+ "width",
2415
+ "height",
2416
+ "format"
2417
+ ];
2418
+ const extra = Object.keys(value).filter((key) => !known.includes(key));
2419
+ if (extra.length) throw new Error(`Asset has unknown fields: ${extra.join(", ")}`);
2420
+ const id = text(value.id, "Asset.id", 48);
2421
+ if (!ID.test(id)) throw new Error(`Asset id '${id}' must be lowercase letters, digits, - or _`);
2422
+ if (value.kind !== "model" && value.kind !== "image" && value.kind !== "environment")
2423
+ throw new Error("Asset.kind must be model, image or environment");
2424
+ const path = text(value.path, "Asset.path", 96);
2425
+ if (!PATH.test(path) || !path.startsWith(`${FOLDER[value.kind]}/`))
2426
+ throw new Error(
2427
+ `Asset.path '${path}' must be a content-addressed file under ${FOLDER[value.kind]}/`
2428
+ );
2429
+ if (typeof value.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sha256))
2430
+ throw new Error("Asset.sha256 must be a 64-digit hex digest");
2431
+ if (!path.includes(`/${value.sha256.slice(0, 12)}-`))
2432
+ throw new Error("Asset.path must carry the first 12 digits of its sha256");
2433
+ if (!Number.isSafeInteger(value.bytes) || value.bytes <= 0)
2434
+ throw new Error("Asset.bytes must be a positive integer");
2435
+ if (value.status !== "ready") throw new Error("Asset.status must be ready");
2436
+ const out = {
2437
+ id,
2438
+ kind: value.kind,
2439
+ name: text(value.name, "Asset.name", 128),
2440
+ path,
2441
+ sha256: value.sha256,
2442
+ bytes: value.bytes,
2443
+ status: "ready"
2444
+ };
2445
+ for (const key of ["license", "source"])
2446
+ if (value[key] !== void 0) out[key] = text(value[key], `Asset.${key}`, 512);
2447
+ if (value.bounds !== void 0) {
2448
+ const bounds = value.bounds;
2449
+ const ok = (v) => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === "number" && Number.isFinite(n));
2450
+ const { min, max } = bounds ?? {};
2451
+ if (!ok(min) || !ok(max) || min.some((n, i) => n > max[i]))
2452
+ throw new Error("Asset.bounds must be finite ordered min/max vectors");
2453
+ out.bounds = { min, max };
2454
+ }
2455
+ for (const key of ["triangles", "width", "height"])
2456
+ if (value[key] !== void 0) {
2457
+ if (!Number.isSafeInteger(value[key]) || value[key] < 0)
2458
+ throw new Error(`Asset.${key} must be a non-negative integer`);
2459
+ out[key] = value[key];
2460
+ }
2461
+ if (value.format !== void 0) out.format = text(value.format, "Asset.format", 16);
2462
+ if (value.adjust !== void 0) {
2463
+ const adjust = value.adjust;
2464
+ if (typeof adjust?.scale !== "number" || !Number.isFinite(adjust.scale) || adjust.scale <= 0 || adjust.scale > 1e4)
2465
+ throw new Error("Asset.adjust.scale must be a positive finite number");
2466
+ if (!["base", "centre", "origin"].includes(adjust.pivot))
2467
+ throw new Error("Asset.adjust.pivot must be base, centre or origin");
2468
+ out.adjust = { scale: adjust.scale, pivot: adjust.pivot };
2469
+ }
2470
+ if (value.diagnostics !== void 0) {
2471
+ if (!Array.isArray(value.diagnostics) || value.diagnostics.length > 16)
2472
+ throw new Error("Asset.diagnostics must list at most 16 messages");
2473
+ out.diagnostics = value.diagnostics.map((entry) => text(entry, "Asset.diagnostics", 256));
2474
+ }
2475
+ return out;
2476
+ }
2477
+ function validateAssets(input) {
2478
+ if (!Array.isArray(input) || input.length > MAX_ASSETS)
2479
+ throw new Error(`A document holds at most ${MAX_ASSETS} assets`);
2480
+ const assets = input.map((entry) => validateAsset(entry));
2481
+ if (new Set(assets.map((entry) => entry.id)).size !== assets.length)
2482
+ throw new Error("Asset ids must be unique");
2483
+ return assets;
2484
+ }
2485
+ function sniff(bytes) {
2486
+ const head = (...codes) => codes.every((code, i) => bytes[i] === code);
2487
+ if (head(103, 108, 84, 70)) return { kind: "model", ext: "glb", format: "glb" };
2488
+ if (head(137, 80, 78, 71)) return { kind: "image", ext: "png", format: "png" };
2489
+ if (head(255, 216, 255)) return { kind: "image", ext: "jpg", format: "jpeg" };
2490
+ if (head(82, 73, 70, 70) && bytes[8] === 87 && bytes[9] === 69)
2491
+ return { kind: "image", ext: "webp", format: "webp" };
2492
+ const ascii = new TextDecoder().decode(bytes.subarray(0, 11));
2493
+ if (ascii.startsWith("#?RADIANCE") || ascii.startsWith("#?RGBE"))
2494
+ return { kind: "environment", ext: "hdr", format: "hdr" };
2495
+ if (head(118, 47, 49, 1)) return { kind: "environment", ext: "exr", format: "exr" };
2496
+ return void 0;
2497
+ }
2498
+ var SURFACE_INPUT = /^[a-z0-9][a-z0-9_-]{0,31}\.([a-z]+)$/u;
2499
+ function surfaceSpace(input) {
2500
+ const channel = SURFACE_INPUT.exec(input)?.[1];
2501
+ const space = channel ? SURFACE_CHANNELS[channel] : void 0;
2502
+ if (!space)
2503
+ throw new Error(
2504
+ `Surface input '${input}' must be <surface>.<channel>, channel one of ${Object.keys(SURFACE_CHANNELS).join(", ")}`
2505
+ );
2506
+ return space;
2507
+ }
2508
+ function validateSurfaces(input, assets) {
2509
+ if (!input || typeof input !== "object" || Array.isArray(input))
2510
+ throw new Error("surfaces must be an object");
2511
+ const entries = Object.entries(input);
2512
+ if (entries.length > 64) throw new Error("A document holds at most 64 surface mappings");
2513
+ const out = {};
2514
+ for (const [key, value] of entries) {
2515
+ surfaceSpace(key);
2516
+ const mapping = value;
2517
+ if (!mapping || typeof mapping !== "object" || Object.keys(mapping).some((k) => k !== "asset"))
2518
+ throw new Error(`surfaces.${key} must be { asset }`);
2519
+ const asset = assets.find((entry) => entry.id === mapping.asset);
2520
+ if (!asset || asset.kind !== "image")
2521
+ throw new Error(
2522
+ `surfaces.${key} names '${String(mapping.asset)}', which is not a registered image`
2523
+ );
2524
+ out[key] = { asset: asset.id };
2525
+ }
2526
+ return out;
2527
+ }
2528
+
2529
+ // src/editor/assetStore.ts
2530
+ var AssetStore = class {
2531
+ dir;
2532
+ limits;
2533
+ constructor(dir, limits = {}) {
2534
+ if (!isAbsolute(dir)) throw new Error("Assets directory must be absolute");
2535
+ this.dir = resolve(dir);
2536
+ this.limits = { ...DEFAULT_ASSET_LIMITS, ...limits };
2537
+ }
2538
+ /** Read a local source file for registration: a regular file, never a link, within the limit. */
2539
+ readSource(path) {
2540
+ if (!isAbsolute(path)) throw new Error("Asset path must be absolute");
2541
+ const info = lstatSync(path);
2542
+ if (!info.isFile() || info.isSymbolicLink())
2543
+ throw new Error("Asset path must be a regular file");
2544
+ if (info.size > this.limits.maxBytes)
2545
+ throw new Error(
2546
+ `Asset is ${info.size} bytes; this project's limit is ${this.limits.maxBytes}`
2547
+ );
2548
+ const descriptor = openSync(path, "r");
2549
+ try {
2550
+ const bytes = new Uint8Array(fstatSync(descriptor).size);
2551
+ let read = 0;
2552
+ while (read < bytes.length) {
2553
+ const count = readSync(descriptor, bytes, read, bytes.length - read, read);
2554
+ if (!count) break;
2555
+ read += count;
2556
+ }
2557
+ return bytes;
2558
+ } finally {
2559
+ closeSync(descriptor);
2560
+ }
2561
+ }
2562
+ /** The bytes of one stored file, addressed only by a path an entry carries. */
2563
+ read(path) {
2564
+ if (!PATH.test(path)) throw new Error("Unknown asset file");
2565
+ return readFileSync(join(this.dir, path));
2566
+ }
2567
+ /** Measure, hash and store one file; the returned entry is what the document saves. */
2568
+ store(id, name, bytes, extra) {
2569
+ if (!ID.test(id)) throw new Error(`Asset id '${id}' must be lowercase letters, digits, - or _`);
2570
+ if (bytes.byteLength > this.limits.maxBytes)
2571
+ throw new Error(
2572
+ `Asset is ${bytes.byteLength} bytes; this project's limit is ${this.limits.maxBytes}`
2573
+ );
2574
+ const kind = sniff(bytes);
2575
+ if (!kind)
2576
+ throw new Error(`'${name}' is not a supported GLB, PNG, JPEG, WebP, HDR or EXR file`);
2577
+ if (extra.kind && extra.kind !== kind.kind)
2578
+ throw new Error(`'${name}' is a ${kind.kind} file, not a ${extra.kind}`);
2579
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
2580
+ const measured = kind.kind === "model" ? inspectGlb(bytes, this.limits) : void 0;
2581
+ const picture = kind.kind === "model" ? void 0 : inspectImage(bytes, kind.format, this.limits);
2582
+ const path = `${FOLDER[kind.kind]}/${sha256.slice(0, 12)}-${id}.${kind.ext}`;
2583
+ mkdirSync(join(this.dir, FOLDER[kind.kind]), { recursive: true });
2584
+ const target = join(this.dir, path);
2585
+ const temporary = `${target}.${process.pid}.tmp`;
2586
+ try {
2587
+ lstatSync(target);
2588
+ } catch {
2589
+ writeFileSync(temporary, bytes, { flag: "wx", mode: 384 });
2590
+ renameSync(temporary, target);
2591
+ }
2592
+ return validateAsset({
2593
+ id,
2594
+ kind: kind.kind,
2595
+ name: name.slice(0, 128),
2596
+ path,
2597
+ sha256,
2598
+ bytes: bytes.byteLength,
2599
+ status: "ready",
2600
+ ...extra.license ? { license: extra.license } : {},
2601
+ ...extra.source ? { source: extra.source } : {},
2602
+ ...measured ? {
2603
+ bounds: measured.bounds,
2604
+ triangles: measured.triangles,
2605
+ ...measured.diagnostics.length ? { diagnostics: measured.diagnostics } : {},
2606
+ adjust: { scale: 1, pivot: "base" }
2607
+ } : {},
2608
+ ...picture ? {
2609
+ width: picture.width,
2610
+ height: picture.height,
2611
+ format: picture.format,
2612
+ ...picture.diagnostics.length ? { diagnostics: picture.diagnostics } : {}
2613
+ } : {}
2614
+ });
2615
+ }
2616
+ };
2617
+
2618
+ // src/editor/cameras.ts
2619
+ var MAX_CAMERAS = 32;
2620
+ var MIN_VIEW_DISTANCE = 1e-6;
2621
+ var MIN_UP_ANGLE = 1e-6;
2622
+ function object(value, name) {
2623
+ if (!value || typeof value !== "object" || Array.isArray(value))
2624
+ throw new Error(`${name} must be an object`);
2625
+ return value;
2626
+ }
2627
+ function text2(value, name) {
2628
+ if (typeof value !== "string" || !value) throw new Error(`${name} must be a name`);
2629
+ return value;
2630
+ }
2631
+ function vector(value, name) {
2632
+ if (!Array.isArray(value) || value.length !== 3 || !value.every((entry) => typeof entry === "number" && Number.isFinite(entry)))
2633
+ throw new Error(`${name} must be finite [x, y, z] coordinates`);
2634
+ return [value[0], value[1], value[2]];
2635
+ }
2636
+ function positive(value, name) {
2637
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
2638
+ throw new Error(`${name} must be a positive finite number`);
2639
+ return value;
2640
+ }
2641
+ function unit(value) {
2642
+ return Math.hypot(...value);
2643
+ }
2644
+ function scale(value, factor) {
2645
+ return [value[0] * factor, value[1] * factor, value[2] * factor];
2646
+ }
2647
+ function cross(first, second) {
2648
+ return [
2649
+ first[1] * second[2] - first[2] * second[1],
2650
+ first[2] * second[0] - first[0] * second[2],
2651
+ first[0] * second[1] - first[1] * second[0]
2652
+ ];
2653
+ }
2654
+ var COMMON_FIELDS = ["id", "name", "position", "target", "up", "near", "far"];
2655
+ function validateCamera(input, existing = []) {
2656
+ const value = object(input, "Camera");
2657
+ if (value.projection !== "perspective" && value.projection !== "orthographic")
2658
+ throw new Error("Camera.projection must be 'perspective' or 'orthographic'");
2659
+ const projection = value.projection;
2660
+ const fields = projection === "perspective" ? [...COMMON_FIELDS, "projection", "fov"] : [...COMMON_FIELDS, "projection", "extent", "zoom"];
2661
+ const extra = Object.keys(value).filter(
2662
+ (key) => !fields.includes(key)
2663
+ );
2664
+ if (extra.length) throw new Error(`Camera has unknown fields: ${extra.join(", ")}`);
2665
+ const id = text2(value.id, "Camera.id");
2666
+ if (existing.some((camera) => camera.id === id))
2667
+ throw new Error(`Camera id '${id}' already exists`);
2668
+ const position = vector(value.position, "Camera.position");
2669
+ const target = vector(value.target, "Camera.target");
2670
+ const up = vector(value.up, "Camera.up");
2671
+ if (unit([position[0] - target[0], position[1] - target[1], position[2] - target[2]]) < MIN_VIEW_DISTANCE)
2672
+ throw new Error("Camera.position and Camera.target must not coincide");
2673
+ const direction = scale(
2674
+ [position[0] - target[0], position[1] - target[1], position[2] - target[2]],
2675
+ 1 / unit([position[0] - target[0], position[1] - target[1], position[2] - target[2]])
2676
+ );
2677
+ if (unit(up) < MIN_UP_ANGLE) throw new Error("Camera.up must be a usable direction");
2678
+ if (unit(cross(up, direction)) < MIN_UP_ANGLE * unit(up))
2679
+ throw new Error("Camera.up is parallel to the view direction");
2680
+ const near = positive(value.near, "Camera.near");
2681
+ const far = positive(value.far, "Camera.far");
2682
+ if (far <= near) throw new Error("Camera.far must be greater than Camera.near");
2683
+ const pose = {
2684
+ id,
2685
+ name: text2(value.name, "Camera.name"),
2686
+ position,
2687
+ target,
2688
+ up,
2689
+ near,
2690
+ far
2691
+ };
2692
+ if (projection === "perspective") {
2693
+ const fov = positive(value.fov, "Camera.fov");
2694
+ if (fov >= 180) throw new Error("Camera.fov must be below 180 degrees");
2695
+ return { ...pose, projection, fov };
2696
+ }
2697
+ return {
2698
+ ...pose,
2699
+ projection,
2700
+ extent: positive(value.extent, "Camera.extent"),
2701
+ zoom: positive(value.zoom, "Camera.zoom")
2702
+ };
2703
+ }
2704
+ function validateCameras(input) {
2705
+ if (!Array.isArray(input) || input.length > MAX_CAMERAS)
2706
+ throw new Error(`A document holds at most ${MAX_CAMERAS} saved cameras`);
2707
+ const cameras = [];
2708
+ for (const entry of input) cameras.push(validateCamera(entry, cameras));
2709
+ return cameras;
2710
+ }
2711
+ function require_(cameras, id) {
2712
+ const name = text2(id, "Camera id");
2713
+ const camera = cameras.find((entry) => entry.id === name);
2714
+ if (!camera) throw new Error(`Unknown camera '${name}'`);
2715
+ return camera;
2716
+ }
2717
+ function runCameraOperation(set, operation) {
2718
+ const request = object(operation, "Camera operation");
2719
+ const op = text2(request.op, "Camera operation op");
2720
+ if (op === "list")
2721
+ return {
2722
+ op,
2723
+ cameras: [...set.cameras],
2724
+ activeCamera: set.activeCamera,
2725
+ camera: null,
2726
+ fallback: null
2727
+ };
2728
+ if (op === "get")
2729
+ return {
2730
+ op,
2731
+ cameras: [...set.cameras],
2732
+ activeCamera: set.activeCamera,
2733
+ camera: require_(set.cameras, request.id),
2734
+ fallback: null
2735
+ };
2736
+ if (op === "create") {
2737
+ if (set.cameras.length >= MAX_CAMERAS)
2738
+ throw new Error(`A document holds at most ${MAX_CAMERAS} saved cameras`);
2739
+ const camera = validateCamera(request.camera, set.cameras);
2740
+ return {
2741
+ op,
2742
+ cameras: [...set.cameras, camera],
2743
+ activeCamera: set.activeCamera,
2744
+ camera,
2745
+ fallback: null
2746
+ };
2747
+ }
2748
+ if (op === "update") {
2749
+ const camera = require_(set.cameras, request.id);
2750
+ const patch = object(request.patch, "Camera patch");
2751
+ const merged = { ...camera, ...patch, id: camera.id };
2752
+ let switched = merged;
2753
+ if (patch.projection !== void 0) {
2754
+ const stale = merged.projection === "perspective" ? ["extent", "zoom"] : ["fov"];
2755
+ switched = Object.fromEntries(Object.entries(merged).filter(([key]) => !stale.includes(key)));
2756
+ }
2757
+ const next = validateCamera(
2758
+ switched,
2759
+ set.cameras.filter((entry) => entry.id !== camera.id)
2760
+ );
2761
+ return {
2762
+ op,
2763
+ cameras: set.cameras.map((entry) => entry.id === camera.id ? next : entry),
2764
+ activeCamera: set.activeCamera,
2765
+ camera: next,
2766
+ fallback: null
2767
+ };
2768
+ }
2769
+ if (op === "delete") {
2770
+ const camera = require_(set.cameras, request.id);
2771
+ const cameras = set.cameras.filter((entry) => entry.id !== camera.id);
2772
+ const deleted = set.activeCamera === camera.id;
2773
+ return {
2774
+ op,
2775
+ cameras,
2776
+ activeCamera: deleted ? null : set.activeCamera,
2777
+ camera,
2778
+ fallback: deleted ? "editor-camera" : null
2779
+ };
2780
+ }
2781
+ if (op === "activate") {
2782
+ if (request.id === null)
2783
+ return {
2784
+ op,
2785
+ cameras: [...set.cameras],
2786
+ activeCamera: null,
2787
+ camera: null,
2788
+ fallback: "editor-camera"
2789
+ };
2790
+ require_(set.cameras, request.id);
2791
+ return {
2792
+ op,
2793
+ cameras: [...set.cameras],
2794
+ activeCamera: request.id,
2795
+ camera: require_(set.cameras, request.id),
2796
+ fallback: null
2797
+ };
2798
+ }
2799
+ throw new Error(`Unknown camera operation '${op}'`);
2800
+ }
2801
+
2802
+ // src/editor/environment.ts
2803
+ var SECTIONS = {
2804
+ sun: ["azimuth", "elevation", "intensity", "colour"],
2805
+ fill: ["intensity"],
2806
+ sky: ["colour", "image", "rotation", "intensity"],
2807
+ lighting: ["image", "rotation", "intensity"],
2808
+ fog: ["mode", "colour", "density"],
2809
+ ocean: ["shallow", "deep"]
2810
+ };
2811
+ var MAX_INTENSITY = 1e3;
2812
+ var MAX_EXPOSURE = 64;
2813
+ var MAX_DENSITY = 1;
2814
+ function object2(value, name) {
2815
+ if (!value || typeof value !== "object" || Array.isArray(value))
2816
+ throw new Error(`${name} must be an object`);
2817
+ return value;
2818
+ }
2819
+ function number(value, name, min, max) {
2820
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max)
2821
+ throw new Error(`${name} must be a finite number from ${min} to ${max}`);
2822
+ return value;
2823
+ }
2824
+ function colour(value, name) {
2825
+ if (typeof value !== "string" || !/^#[0-9a-fA-F]{6}$/u.test(value))
2826
+ throw new Error(`${name} must be a #rrggbb colour`);
2827
+ return value.toLowerCase();
2828
+ }
2829
+ function field(section, key, value) {
2830
+ const name = `environment.${section}.${key}`;
2831
+ switch (`${section}.${key}`) {
2832
+ case "sun.azimuth":
2833
+ return number(value, name, -360, 360);
2834
+ case "sun.elevation":
2835
+ return number(value, name, 0, 90);
2836
+ case "sky.image":
2837
+ case "lighting.image":
2838
+ if (typeof value !== "string" || !/^[a-z0-9][a-z0-9_-]{0,47}$/u.test(value))
2839
+ throw new Error(`${name} must be a registered asset id`);
2840
+ return value;
2841
+ case "sky.rotation":
2842
+ case "lighting.rotation":
2843
+ return number(value, name, -360, 360);
2844
+ case "sun.intensity":
2845
+ case "fill.intensity":
2846
+ case "sky.intensity":
2847
+ case "lighting.intensity":
2848
+ return number(value, name, 0, MAX_INTENSITY);
2849
+ case "fog.density":
2850
+ return number(value, name, 0, MAX_DENSITY);
2851
+ case "fog.mode":
2852
+ if (value !== "exp2")
2853
+ throw new Error(
2854
+ `environment.fog.mode '${String(value)}' is not supported by this render source; supported: exp2`
2855
+ );
2856
+ return value;
2857
+ default:
2858
+ return colour(value, name);
2859
+ }
2860
+ }
2861
+ function validateEnvironment(input) {
2862
+ const value = object2(input, "environment");
2863
+ const extra = Object.keys(value).filter((key) => key !== "exposure" && !(key in SECTIONS));
2864
+ if (extra.length) throw new Error(`environment has unknown fields: ${extra.join(", ")}`);
2865
+ const out = {};
2866
+ if (value.exposure !== void 0)
2867
+ out.exposure = number(value.exposure, "environment.exposure", 2 ** -8, MAX_EXPOSURE);
2868
+ for (const [section, keys] of Object.entries(SECTIONS)) {
2869
+ if (value[section] === void 0) continue;
2870
+ const entries = object2(value[section], `environment.${section}`);
2871
+ const unknown = Object.keys(entries).filter(
2872
+ (key) => !keys.includes(key)
2873
+ );
2874
+ if (unknown.length)
2875
+ throw new Error(`environment.${section} has unknown fields: ${unknown.join(", ")}`);
2876
+ const clean = {};
2877
+ for (const key of keys)
2878
+ if (entries[key] !== void 0) clean[key] = field(section, key, entries[key]);
2879
+ if (Object.keys(clean).length) out[section] = clean;
2880
+ }
2881
+ return out;
2882
+ }
2883
+ function runEnvironmentOperation(current, operation) {
2884
+ const request = object2(operation, "Environment operation");
2885
+ if (request.op === "get") return { op: "get", environment: current };
2886
+ if (request.op === "reset") return { op: "reset", environment: {} };
2887
+ if (request.op !== "patch") throw new Error("Environment op must be get, patch or reset");
2888
+ const values = object2(request.values, "environment patch");
2889
+ const merged = { ...current };
2890
+ const clear = (value) => value === null ? void 0 : value;
2891
+ for (const [key, patch] of Object.entries(values)) {
2892
+ if (key === "exposure") merged.exposure = clear(patch);
2893
+ else if (key in SECTIONS) {
2894
+ if (patch === null) merged[key] = void 0;
2895
+ else {
2896
+ const entries = object2(patch, `environment.${key}`);
2897
+ const section = { ...merged[key] ?? {} };
2898
+ for (const [name, entry] of Object.entries(entries)) section[name] = clear(entry);
2899
+ merged[key] = section;
2900
+ }
2901
+ } else throw new Error(`environment has unknown fields: ${key}`);
2902
+ }
2903
+ return { op: "patch", environment: validateEnvironment(merged) };
2904
+ }
2905
+ function checkEnvironmentAssets(environment, assets) {
2906
+ for (const [group, image] of [
2907
+ ["sky", environment.sky?.image],
2908
+ ["lighting", environment.lighting?.image]
2909
+ ]) {
2910
+ if (image === void 0) continue;
2911
+ const found = assets.find((entry) => entry.id === image);
2912
+ if (!found || found.kind !== "environment" && found.kind !== "image")
2913
+ throw new Error(
2914
+ `environment.${group}.image names '${image}', which is not a registered environment or image asset`
2915
+ );
2916
+ }
2917
+ }
2918
+
2919
+ // src/editor/spatialInspector.ts
2920
+ var MAX_PROFILE_SAMPLES = 1024;
2921
+ var MAX_CONTROLS = 8;
2922
+ var FLAT_SLOPE_DEG = 1e-6;
2923
+ function point(value, name) {
2924
+ if (!Array.isArray(value) || value.length !== 2 || !value.every((entry) => typeof entry === "number" && Number.isFinite(entry)))
2925
+ throw new Error(`${name} must be finite [x, z] coordinates`);
2926
+ return [value[0], value[1]];
2927
+ }
2928
+ function bounded(state, value, name) {
2929
+ const [x, z] = point(value, name);
2930
+ const half = state.size / 2;
2931
+ if (x < -half || x > half || z < -half || z > half)
2932
+ throw new Error(`${name} [${x}, ${z}] is outside the ${state.size} m terrain extent`);
2933
+ return [x, z];
2934
+ }
2935
+ function normalOf(gradient) {
2936
+ const length = Math.hypot(gradient[0], 1, gradient[1]);
2937
+ return [-gradient[0] / length, 1 / length, -gradient[1] / length];
2938
+ }
2939
+ function reading(source, height, gradient) {
2940
+ const slopeDeg = Math.atan(Math.hypot(gradient[0], gradient[1])) * 180 / Math.PI;
2941
+ return {
2942
+ source,
2943
+ height,
2944
+ slopeDeg,
2945
+ aspectDeg: slopeDeg < FLAT_SLOPE_DEG ? null : (Math.atan2(-gradient[0], gradient[1]) * 180 / Math.PI + 360) % 360,
2946
+ normal: normalOf(gradient)
2947
+ };
2948
+ }
2949
+ function bilinear(state, x, z) {
2950
+ return reading("bilinear-heightfield", sampleHeight(state, x, z), gradientAt(state, x, z));
2951
+ }
2952
+ function triangle(state, x, z) {
2953
+ const n = state.resolution;
2954
+ const cell = state.size / (n - 1);
2955
+ const gx = Math.min(n - 2, Math.max(0, Math.floor((x + state.size / 2) / cell)));
2956
+ const gz = Math.min(n - 2, Math.max(0, Math.floor((z + state.size / 2) / cell)));
2957
+ const tx = Math.min(1, Math.max(0, (x + state.size / 2) / cell - gx));
2958
+ const tz = Math.min(1, Math.max(0, (z + state.size / 2) / cell - gz));
2959
+ const x0 = -state.size / 2 + gx * cell;
2960
+ const z0 = -state.size / 2 + gz * cell;
2961
+ const h = (ix, iz) => state.height[(gz + iz) * n + gx + ix];
2962
+ const a = [x0, h(0, 0), z0];
2963
+ const b = [x0 + cell, h(1, 0), z0];
2964
+ const c = [x0, h(0, 1), z0 + cell];
2965
+ const d = [x0 + cell, h(1, 1), z0 + cell];
2966
+ const [first, second, third] = tx + tz <= 1 ? [a, c, b] : [b, c, d];
2967
+ const u = [second[0] - first[0], second[1] - first[1], second[2] - first[2]];
2968
+ const v = [third[0] - first[0], third[1] - first[1], third[2] - first[2]];
2969
+ const normal = [
2970
+ u[1] * v[2] - u[2] * v[1],
2971
+ u[2] * v[0] - u[0] * v[2],
2972
+ u[0] * v[1] - u[1] * v[0]
2973
+ ];
2974
+ const length = Math.hypot(...normal) || 1;
2975
+ const nx = normal[0] / length;
2976
+ const ny = Math.abs(normal[1] / length) || 1;
2977
+ const nz = normal[2] / length;
2978
+ return reading(
2979
+ "evaluated-triangle",
2980
+ first[1] - (nx * (x - first[0]) + nz * (z - first[2])) / ny,
2981
+ [-nx / ny, -nz / ny]
2982
+ );
2983
+ }
2984
+ function probeTerrain(state, revision2, at) {
2985
+ const [x, z] = bounded(state, at, "Point");
2986
+ const bilinearValue = bilinear(state, x, z);
2987
+ const triangleValue = triangle(state, x, z);
2988
+ return {
2989
+ kind: "point",
2990
+ revision: revision2,
2991
+ units: "metres",
2992
+ resolution: state.resolution,
2993
+ at: [x, z],
2994
+ bilinear: bilinearValue,
2995
+ triangle: triangleValue,
2996
+ difference: triangleValue.height - bilinearValue.height,
2997
+ datum: null,
2998
+ labels: ["unknown-datum"]
2999
+ };
3000
+ }
3001
+ function profile(state, revision2, query) {
3002
+ const from = bounded(state, query.from, "Profile from");
3003
+ const to = bounded(state, query.to, "Profile to");
3004
+ const horizontalDistance = Math.hypot(to[0] - from[0], to[1] - from[1]);
3005
+ if (horizontalDistance === 0) throw new Error("Profile endpoints are degenerate");
3006
+ const cell = state.size / (state.resolution - 1);
3007
+ const count = query.samples ?? Math.min(MAX_PROFILE_SAMPLES, Math.ceil(horizontalDistance / cell) + 1);
3008
+ if (!Number.isInteger(count) || count < 2 || count > MAX_PROFILE_SAMPLES)
3009
+ throw new Error(`Profile samples must be an integer 2\u2013${MAX_PROFILE_SAMPLES}`);
3010
+ const step = horizontalDistance / (count - 1);
3011
+ const samples = [];
3012
+ let surfaceDistance = 0;
3013
+ for (let index = 0; index < count; index++) {
3014
+ const distance = index * step;
3015
+ const x = from[0] + (to[0] - from[0]) * (distance / horizontalDistance);
3016
+ const z = from[1] + (to[1] - from[1]) * (distance / horizontalDistance);
3017
+ const value = bilinear(state, x, z);
3018
+ const previous = samples[index - 1];
3019
+ if (previous) {
3020
+ const run = Math.hypot(step, value.height - previous.height);
3021
+ surfaceDistance += run;
3022
+ samples[index - 1] = { ...previous, surfaceDistance };
3023
+ }
3024
+ samples.push({ distance, surfaceDistance, height: value.height, slopeDeg: value.slopeDeg });
3025
+ }
3026
+ let maxGradeDeg = 0;
3027
+ for (let index = 1; index < samples.length; index++) {
3028
+ const previous = samples[index - 1];
3029
+ const sample = samples[index];
3030
+ maxGradeDeg = Math.max(
3031
+ maxGradeDeg,
3032
+ Math.atan(Math.abs(sample.height - previous.height) / step) * 180 / Math.PI
3033
+ );
3034
+ }
3035
+ const heights = samples.map((sample) => sample.height);
3036
+ return {
3037
+ kind: "profile",
3038
+ revision: revision2,
3039
+ units: "metres",
3040
+ resolution: state.resolution,
3041
+ from,
3042
+ to,
3043
+ sampleCount: count,
3044
+ horizontalDistance,
3045
+ surfaceDistance,
3046
+ minElevation: Math.min(...heights),
3047
+ maxElevation: Math.max(...heights),
3048
+ maxGradeDeg,
3049
+ samples,
3050
+ datum: null,
3051
+ labels: ["unknown-datum", "bilinear-samples"]
3052
+ };
3053
+ }
3054
+ function control(value, name) {
3055
+ if (!value || typeof value !== "object" || Array.isArray(value))
3056
+ throw new Error(`${name} must be a control point object`);
3057
+ const input = value;
3058
+ const extra = Object.keys(input).filter((key) => !["id", "image", "world"].includes(key));
3059
+ if (extra.length) throw new Error(`${name} has unknown fields: ${extra.join(", ")}`);
3060
+ if (typeof input.id !== "string" || !input.id) throw new Error(`${name}.id must be a name`);
3061
+ return {
3062
+ id: input.id,
3063
+ image: point(input.image, `${name}.image`),
3064
+ world: point(input.world, `${name}.world`)
3065
+ };
3066
+ }
3067
+ function similarity(first, second) {
3068
+ const image = [second.image[0] - first.image[0], second.image[1] - first.image[1]];
3069
+ const world2 = [second.world[0] - first.world[0], second.world[1] - first.world[1]];
3070
+ const imageLength = Math.hypot(...image);
3071
+ const worldLength = Math.hypot(...world2);
3072
+ if (imageLength === 0 || worldLength === 0)
3073
+ throw new Error("Reference controls must be two distinct image and world positions");
3074
+ const scale2 = worldLength / imageLength;
3075
+ const angle = Math.atan2(world2[1], world2[0]) - Math.atan2(image[1], image[0]);
3076
+ const cos = Math.cos(angle);
3077
+ const sin = Math.sin(angle);
3078
+ const offset = first.world;
3079
+ const place = (u) => {
3080
+ const x = scale2 * (cos * u[0] - sin * u[1]);
3081
+ const z = scale2 * (sin * u[0] + cos * u[1]);
3082
+ return [
3083
+ x + offset[0] - scale2 * (cos * first.image[0] - sin * first.image[1]),
3084
+ z + offset[1] - scale2 * (sin * first.image[0] + cos * first.image[1])
3085
+ ];
3086
+ };
3087
+ return { place, scale: scale2, degrees: angle * 180 / Math.PI };
3088
+ }
3089
+ function validateSpatialReference(input) {
3090
+ if (!input || typeof input !== "object" || Array.isArray(input))
3091
+ throw new Error("Reference must be an object");
3092
+ const value = input;
3093
+ const extra = Object.keys(value).filter(
3094
+ (key) => ![
3095
+ "id",
3096
+ "kind",
3097
+ "hash",
3098
+ "source",
3099
+ "datum",
3100
+ "toleranceMetres",
3101
+ "controls",
3102
+ "checkpoint"
3103
+ ].includes(key)
3104
+ );
3105
+ if (extra.length) throw new Error(`Reference has unknown fields: ${extra.join(", ")}`);
3106
+ if (typeof value.id !== "string" || !value.id) throw new Error("Reference.id must be a name");
3107
+ if (value.kind !== "top-down-map" && value.kind !== "screenshot")
3108
+ throw new Error("Reference.kind must be 'top-down-map' or 'screenshot'");
3109
+ if (typeof value.hash !== "string" || !value.hash) throw new Error("Reference.hash must be set");
3110
+ if (typeof value.source !== "string" || !value.source)
3111
+ throw new Error("Reference.source must describe the image");
3112
+ if (!Array.isArray(value.controls) || !value.controls.length || value.controls.length > MAX_CONTROLS)
3113
+ throw new Error(`Reference.controls must hold 1\u2013${MAX_CONTROLS} control points`);
3114
+ const controls = value.controls.map((entry, index) => control(entry, `controls[${index}]`));
3115
+ const checkpoint = value.checkpoint === void 0 || value.checkpoint === null ? null : control(value.checkpoint, "checkpoint");
3116
+ if (checkpoint && controls.some((entry) => entry.id === checkpoint.id))
3117
+ throw new Error("Reference.checkpoint must be independent of the fit controls");
3118
+ const toleranceMetres = value.toleranceMetres === void 0 ? 1 : value.toleranceMetres;
3119
+ if (typeof toleranceMetres !== "number" || !Number.isFinite(toleranceMetres) || toleranceMetres <= 0)
3120
+ throw new Error("Reference.toleranceMetres must be a positive finite number");
3121
+ const datum = typeof value.datum === "string" && value.datum ? value.datum : null;
3122
+ return {
3123
+ id: value.id,
3124
+ kind: value.kind,
3125
+ hash: value.hash,
3126
+ source: value.source,
3127
+ datum,
3128
+ toleranceMetres,
3129
+ controls,
3130
+ checkpoint
3131
+ };
3132
+ }
3133
+ function reference(revision2, input) {
3134
+ const saved = validateSpatialReference(input);
3135
+ const labels = [];
3136
+ const { controls, checkpoint, toleranceMetres, datum } = saved;
3137
+ if (!datum) labels.push("unknown-datum");
3138
+ const metric = saved.kind === "top-down-map";
3139
+ if (!metric) labels.push("perspective");
3140
+ let transform = null;
3141
+ if (metric && controls.length >= 2)
3142
+ transform = similarity(
3143
+ controls[0],
3144
+ controls[1]
3145
+ );
3146
+ else if (metric) labels.push("insufficient-controls");
3147
+ const landmarks = [
3148
+ ...controls.map((entry) => ({ entry, role: "control" })),
3149
+ ...checkpoint ? [{ entry: checkpoint, role: "checkpoint" }] : []
3150
+ ].map(({ entry, role }) => {
3151
+ const fitted = transform?.place(entry.image) ?? null;
3152
+ return {
3153
+ id: entry.id,
3154
+ role,
3155
+ image: entry.image,
3156
+ world: entry.world,
3157
+ fitted,
3158
+ horizontalResidualMetres: fitted ? Math.hypot(fitted[0] - entry.world[0], fitted[1] - entry.world[1]) : null
3159
+ };
3160
+ });
3161
+ const residuals = landmarks.filter((entry) => entry.role === "control").map((entry) => entry.horizontalResidualMetres);
3162
+ const checkpointEntry = checkpoint ? landmarks.find((entry) => entry.role === "checkpoint") : void 0;
3163
+ const checkpointObservation = checkpointEntry ? {
3164
+ id: checkpointEntry.id,
3165
+ residualMetres: checkpointEntry.horizontalResidualMetres,
3166
+ withinTolerance: checkpointEntry.horizontalResidualMetres <= toleranceMetres
3167
+ } : null;
3168
+ if (!transform) labels.push("unscaled");
3169
+ if (!checkpoint) labels.push("no-checkpoint");
3170
+ const calibrated = Boolean(metric && transform && checkpointObservation?.withinTolerance);
3171
+ if (!calibrated) labels.push("uncalibrated");
3172
+ return {
3173
+ kind: "reference",
3174
+ revision: revision2,
3175
+ units: "metres",
3176
+ id: saved.id,
3177
+ source: saved.source,
3178
+ hash: saved.hash,
3179
+ metric,
3180
+ fit: transform ? {
3181
+ scaleMetresPerPixel: transform.scale,
3182
+ rotationDegrees: transform.degrees,
3183
+ mirrored: false
3184
+ } : null,
3185
+ controlResidualMetres: residuals.length ? Math.max(...residuals) : null,
3186
+ landmarks,
3187
+ checkpoint: checkpointObservation,
3188
+ toleranceMetres,
3189
+ calibrated,
3190
+ datum,
3191
+ labels
3192
+ };
3193
+ }
3194
+ function inspectSpatial(state, revision2, query) {
3195
+ if (!query || typeof query !== "object" || Array.isArray(query))
3196
+ throw new Error("Inspection query must be an object");
3197
+ switch (query.kind) {
3198
+ case "point":
3199
+ return probeTerrain(state, revision2, query.at);
3200
+ case "profile":
3201
+ return profile(state, revision2, query);
3202
+ case "reference":
3203
+ return reference(revision2, query.reference);
3204
+ default:
3205
+ throw new Error("Unknown inspection query kind");
3206
+ }
3207
+ }
3208
+
3209
+ // src/editor/server.ts
3210
+ var PREFIX = "/terrain-editor/";
3211
+ var MAX_BYTES = 64 * 1024 * 1024;
3212
+ var MAX_REFERENCES = 16;
3213
+ function validate(value) {
3214
+ if (!value || typeof value !== "object" || Array.isArray(value))
3215
+ throw new Error("Expected an authoring document");
3216
+ const input = value;
3217
+ if (input.version !== 1 || Object.keys(input).some(
3218
+ (key) => ![
3219
+ "version",
3220
+ "recipe",
3221
+ "placementOverrides",
3222
+ "references",
3223
+ "cameras",
3224
+ "activeCamera",
3225
+ "environment",
3226
+ "assets",
3227
+ "surfaces"
3228
+ ].includes(key)
3229
+ ))
3230
+ throw new Error("Unsupported authoring document fields/version");
3231
+ const recipe = Terrain.fromJSON(input.recipe).toJSON();
3232
+ const document = { version: 1, recipe };
3233
+ if (input.placementOverrides !== void 0)
3234
+ document.placementOverrides = validatePlacementOverrides(input.placementOverrides);
3235
+ if (input.references !== void 0) {
3236
+ if (!Array.isArray(input.references) || input.references.length > MAX_REFERENCES)
3237
+ throw new Error(`A document holds at most ${MAX_REFERENCES} saved references`);
3238
+ document.references = input.references.map((entry) => validateSpatialReference(entry));
3239
+ }
3240
+ if (input.cameras !== void 0) document.cameras = validateCameras(input.cameras);
3241
+ if (input.activeCamera !== void 0) {
3242
+ const active = input.activeCamera;
3243
+ if (active !== null) {
3244
+ if (typeof active !== "string" || !(document.cameras ?? []).some((entry) => entry.id === active))
3245
+ throw new Error("activeCamera must name a saved camera");
3246
+ }
3247
+ document.activeCamera = active;
3248
+ }
3249
+ if (input.assets !== void 0) document.assets = validateAssets(input.assets);
3250
+ if (input.surfaces !== void 0)
3251
+ document.surfaces = validateSurfaces(input.surfaces, document.assets ?? []);
3252
+ if (input.environment !== void 0) {
3253
+ document.environment = validateEnvironment(input.environment);
3254
+ checkEnvironmentAssets(document.environment, document.assets ?? []);
3255
+ }
3256
+ if (Buffer.byteLength(JSON.stringify(document)) > MAX_BYTES)
3257
+ throw new Error("Authoring document exceeds 64 MiB");
3258
+ return document;
3259
+ }
3260
+ function revision(document) {
3261
+ return createHash("sha256").update(JSON.stringify(document)).digest("hex");
3262
+ }
3263
+ var EditorError = class extends Error {
3264
+ constructor(status, message) {
3265
+ super(message);
3266
+ this.status = status;
3267
+ }
3268
+ status;
3269
+ };
3270
+ var TerrainEditorDocument = class {
3271
+ path;
3272
+ #snapshot;
3273
+ #listeners = /* @__PURE__ */ new Set();
3274
+ constructor(path) {
3275
+ if (!isAbsolute(path)) throw new Error("Document path must be absolute");
3276
+ this.path = resolve(path);
3277
+ const document = this.#read();
3278
+ this.#snapshot = { document, revision: revision(document), diagnostic: null };
3279
+ }
3280
+ #read() {
3281
+ const info = lstatSync(this.path);
3282
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_BYTES)
3283
+ throw new Error("Document must be a regular file of at most 64 MiB");
3284
+ return validate(JSON.parse(readFileSync(this.path, "utf8")));
3285
+ }
3286
+ snapshot() {
3287
+ return structuredClone(this.#snapshot);
3288
+ }
3289
+ subscribe(listener) {
3290
+ this.#listeners.add(listener);
3291
+ return () => this.#listeners.delete(listener);
3292
+ }
3293
+ #emit() {
3294
+ for (const listener of this.#listeners) listener(this.snapshot());
3295
+ }
3296
+ refresh() {
3297
+ try {
3298
+ const document = this.#read();
3299
+ const nextRevision = revision(document);
3300
+ if (nextRevision === this.#snapshot.revision && this.#snapshot.diagnostic === null) return;
3301
+ this.#snapshot = { document, revision: nextRevision, diagnostic: null };
3302
+ } catch (error) {
3303
+ this.#snapshot.diagnostic = error instanceof Error ? error.message : String(error);
3304
+ }
3305
+ this.#emit();
3306
+ }
3307
+ commit(input) {
3308
+ this.refresh();
3309
+ if (this.#snapshot.diagnostic)
3310
+ throw new EditorError(409, "Disk document is invalid; restore a valid save before editing");
3311
+ if (!input || typeof input !== "object" || Array.isArray(input))
3312
+ throw new Error("Expected a transaction");
3313
+ const request = input;
3314
+ if (Object.keys(request).some((key) => !["baseRevision", "commands", "document"].includes(key)))
3315
+ throw new Error("Unknown transaction fields");
3316
+ if (request.baseRevision !== this.#snapshot.revision)
3317
+ throw new EditorError(409, "Stale base revision");
3318
+ if (request.commands === void 0 === (request.document === void 0))
3319
+ throw new Error("Supply commands or document, exclusively");
3320
+ let next;
3321
+ if (request.commands !== void 0) {
3322
+ if (!Array.isArray(request.commands) || !request.commands.length || request.commands.length > 512)
3323
+ throw new Error("Expected 1\u2013512 patch commands");
3324
+ const terrain = Terrain.fromJSON(this.#snapshot.document.recipe);
3325
+ terrain.applyPatch(request.commands);
3326
+ next = validate({ ...this.#snapshot.document, recipe: terrain.toJSON() });
3327
+ } else next = validate(request.document);
3328
+ const nextRevision = revision(next);
3329
+ if (nextRevision === this.#snapshot.revision) return this.snapshot();
3330
+ const temporary = `${this.path}.${randomUUID()}.tmp`;
3331
+ try {
3332
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}
3333
+ `, { flag: "wx", mode: 384 });
3334
+ renameSync(temporary, this.path);
3335
+ } catch (error) {
3336
+ try {
3337
+ unlinkSync(temporary);
3338
+ } catch (cleanupError) {
3339
+ if (cleanupError.code !== "ENOENT")
3340
+ throw new AggregateError(
3341
+ [error, cleanupError],
3342
+ "Document save and temporary-file cleanup failed"
3343
+ );
3344
+ }
3345
+ throw error;
3346
+ }
3347
+ this.#snapshot = { document: next, revision: nextRevision, diagnostic: null };
3348
+ this.#emit();
3349
+ return this.snapshot();
3350
+ }
3351
+ };
3352
+ async function body(request) {
3353
+ if (request.headers["content-type"]?.split(";")[0] !== "application/json")
3354
+ throw new EditorError(415, "Expected application/json");
3355
+ if (Number(request.headers["content-length"]) > MAX_BYTES)
3356
+ throw new EditorError(413, "Request exceeds 64 MiB");
3357
+ const chunks = [];
3358
+ let bytes = 0;
3359
+ for await (const chunk of request) {
3360
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3361
+ bytes += buffer.length;
3362
+ if (bytes > MAX_BYTES) throw new EditorError(413, "Request exceeds 64 MiB");
3363
+ chunks.push(buffer);
3364
+ }
3365
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
3366
+ }
3367
+ function json(response, status, value) {
3368
+ response.writeHead(status, {
3369
+ "content-type": "application/json",
3370
+ "cache-control": "no-store",
3371
+ "x-content-type-options": "nosniff"
3372
+ });
3373
+ response.end(JSON.stringify(value));
3374
+ }
3375
+ function terrainEditor(options) {
3376
+ const viewer = options.viewerUrl ? new URL(options.viewerUrl) : void 0;
3377
+ if (viewer && (!["http:", "https:"].includes(viewer.protocol) || viewer.username || viewer.password || viewer.search || viewer.hash || !viewer.pathname.endsWith(PREFIX)))
3378
+ throw new Error("Viewer URL must be an explicit HTTP(S) forwarded terrain-editor URL");
3379
+ const document = new TerrainEditorDocument(options.documentPath);
3380
+ const evaluator = new TerrainEvaluator();
3381
+ const store = new AssetStore(
3382
+ options.assetsDir ?? resolve(dirname(document.path), "assets"),
3383
+ options.assetLimits
3384
+ );
3385
+ const projectId = createHash("sha256").update(document.path).digest("hex");
3386
+ const sessionId = randomUUID();
3387
+ let origin;
3388
+ let url;
3389
+ const clients = /* @__PURE__ */ new Set();
3390
+ const unsubscribe = document.subscribe((snapshot) => {
3391
+ for (const response of clients)
3392
+ if (!response.write(`data: ${JSON.stringify(snapshot)}
3393
+
3394
+ `)) response.end();
3395
+ });
3396
+ const activation = () => {
3397
+ if (!origin || !url) throw new EditorError(503, "Editor server is not listening");
3398
+ return { editorUrl: url, projectId, sessionId, revision: document.snapshot().revision };
3399
+ };
3400
+ const activate = async () => {
3401
+ const value = activation();
3402
+ let response;
3403
+ try {
3404
+ response = await fetch(value.editorUrl, { signal: AbortSignal.timeout(5e3) });
3405
+ } catch (error) {
3406
+ throw new EditorError(
3407
+ 503,
3408
+ `Editor URL is unreachable: ${error instanceof Error ? error.message : String(error)}`
3409
+ );
3410
+ }
3411
+ if (!response.ok || !(await response.text()).includes("terrain-editor"))
3412
+ throw new EditorError(503, "Project terrain editor route is not ready");
3413
+ return value;
3414
+ };
3415
+ return {
3416
+ name: "threenative-terrain-editor",
3417
+ activate,
3418
+ config: () => viewer ? { server: { allowedHosts: [viewer.hostname] } } : {},
3419
+ configureServer(vite) {
3420
+ if (vite.config.server.host && !["127.0.0.1", "localhost", "::1"].includes(String(vite.config.server.host)))
3421
+ throw new Error("Terrain editor writes require a loopback bind");
3422
+ const http = vite.httpServer;
3423
+ if (!http) throw new Error("Terrain editor requires a listening loopback HTTP server");
3424
+ http.once("listening", () => {
3425
+ const address = http.address();
3426
+ if (!address || typeof address === "string" || !["127.0.0.1", "::1"].includes(address.address))
3427
+ throw new Error("Terrain editor writes require a loopback bind");
3428
+ origin = `http://${address.address === "::1" ? "[::1]" : address.address}:${address.port}`;
3429
+ url = viewer?.href ?? new URL(PREFIX, origin).href;
3430
+ console.log(`Terrain editor: ${url}`);
3431
+ });
3432
+ vite.watcher.add(document.path);
3433
+ const refresh = (path) => {
3434
+ if (resolve(path) === document.path) document.refresh();
3435
+ };
3436
+ const refreshRaw = (_event, path, details) => {
3437
+ if (typeof path !== "string") return;
3438
+ const watchedPath = details?.watchedPath;
3439
+ if (isAbsolute(path) && resolve(path) === document.path || typeof watchedPath === "string" && (resolve(watchedPath) === document.path || resolve(watchedPath, path) === document.path))
3440
+ refresh(document.path);
3441
+ };
3442
+ vite.watcher.on("add", refresh).on("change", refresh).on("unlink", refresh).on("raw", refreshRaw);
3443
+ const heartbeat = setInterval(() => {
3444
+ for (const response of clients) if (!response.write(": keepalive\n\n")) response.end();
3445
+ }, 15e3);
3446
+ heartbeat.unref();
3447
+ http.once("close", () => {
3448
+ clearInterval(heartbeat);
3449
+ unsubscribe();
3450
+ for (const response of clients) response.end();
3451
+ vite.watcher.off("add", refresh).off("change", refresh).off("unlink", refresh).off("raw", refreshRaw);
3452
+ });
3453
+ vite.middlewares.use((request, response, next) => {
3454
+ const path = request.url?.split("?")[0];
3455
+ if (!path?.startsWith(`${PREFIX}api/`)) return next();
3456
+ void (async () => {
3457
+ try {
3458
+ if (!origin) throw new EditorError(503, "Server is not ready");
3459
+ const remote = request.socket.remoteAddress;
3460
+ if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remote ?? ""))
3461
+ throw new EditorError(403, "Loopback clients only");
3462
+ const requestUrl = new URL(`http://${request.headers.host}`);
3463
+ const local = new URL(origin);
3464
+ const localhost = new URL(origin.replace("127.0.0.1", "localhost"));
3465
+ const hosts = [local.host, localhost.host, ...viewer ? [viewer.host] : []];
3466
+ const origins = [local.origin, localhost.origin, ...viewer ? [viewer.origin] : []];
3467
+ if (!hosts.includes(requestUrl.host)) throw new EditorError(403, "Untrusted host");
3468
+ if (request.headers.origin && !origins.includes(request.headers.origin))
3469
+ throw new EditorError(403, "Untrusted origin");
3470
+ if (request.method === "GET" && path === `${PREFIX}api/activate`) {
3471
+ json(response, 200, await activate());
3472
+ return;
3473
+ }
3474
+ if (request.method === "GET" && path === `${PREFIX}api/document`) {
3475
+ json(response, 200, document.snapshot());
3476
+ return;
3477
+ }
3478
+ if (request.method === "GET" && path === `${PREFIX}api/events`) {
3479
+ response.writeHead(200, {
3480
+ "content-type": "text/event-stream",
3481
+ "cache-control": "no-store",
3482
+ connection: "keep-alive"
3483
+ });
3484
+ clients.add(response);
3485
+ response.on("close", () => clients.delete(response));
3486
+ response.write(`data: ${JSON.stringify(document.snapshot())}
3487
+
3488
+ `);
3489
+ return;
3490
+ }
3491
+ if (request.method === "POST" && path === `${PREFIX}api/document`) {
3492
+ json(response, 200, document.commit(await body(request)));
3493
+ return;
3494
+ }
3495
+ if (request.method === "POST" && path === `${PREFIX}api/cameras`) {
3496
+ const transaction = await body(request);
3497
+ if (!transaction || typeof transaction !== "object" || Array.isArray(transaction) || Object.keys(transaction).some((key) => !["baseRevision", "operation"].includes(key)))
3498
+ throw new Error("Expected { baseRevision, operation }");
3499
+ const cameras = transaction;
3500
+ const current = document.snapshot();
3501
+ if (cameras.baseRevision !== current.revision)
3502
+ throw new EditorError(409, "Stale base revision");
3503
+ const result = runCameraOperation(
3504
+ {
3505
+ cameras: current.document.cameras ?? [],
3506
+ activeCamera: current.document.activeCamera ?? null
3507
+ },
3508
+ cameras.operation
3509
+ );
3510
+ const next2 = validate({
3511
+ ...current.document,
3512
+ cameras: result.cameras,
3513
+ activeCamera: result.activeCamera
3514
+ });
3515
+ const changed = JSON.stringify(result.cameras) !== JSON.stringify(current.document.cameras ?? []) || result.activeCamera !== (current.document.activeCamera ?? null);
3516
+ const revision2 = changed ? document.commit({ baseRevision: current.revision, document: next2 }).revision : current.revision;
3517
+ json(response, 200, { ...result, revision: revision2 });
3518
+ return;
3519
+ }
3520
+ if (request.method === "GET" && path.startsWith(`${PREFIX}api/assets/`)) {
3521
+ const name = decodeURIComponent(path.slice(`${PREFIX}api/assets/`.length));
3522
+ const entry = (document.snapshot().document.assets ?? []).find(
3523
+ (asset) => asset.path === name
3524
+ );
3525
+ if (!entry) throw new EditorError(404, `No registered asset file '${name}'`);
3526
+ const bytes = store.read(entry.path);
3527
+ response.writeHead(200, {
3528
+ "content-type": "application/octet-stream",
3529
+ // The file name carries its hash, so a replacement is a different URL.
3530
+ "cache-control": "public, max-age=31536000, immutable",
3531
+ "x-content-type-options": "nosniff"
3532
+ });
3533
+ response.end(bytes);
3534
+ return;
3535
+ }
3536
+ if (request.method === "POST" && path === `${PREFIX}api/assets`) {
3537
+ const transaction = await body(request);
3538
+ if (!transaction || typeof transaction !== "object" || Array.isArray(transaction) || Object.keys(transaction).some((key) => !["baseRevision", "operation"].includes(key)))
3539
+ throw new Error("Expected { baseRevision, operation }");
3540
+ const asset = transaction;
3541
+ const current = document.snapshot();
3542
+ if (asset.baseRevision !== current.revision)
3543
+ throw new EditorError(409, "Stale base revision");
3544
+ const operation = asset.operation;
3545
+ if (!operation || typeof operation !== "object" || typeof operation.op !== "string")
3546
+ throw new Error("Expected an asset operation");
3547
+ const assets = current.document.assets ?? [];
3548
+ let next2 = assets;
3549
+ const surfaces = current.document.surfaces ?? {};
3550
+ let nextSurfaces = surfaces;
3551
+ let touched = null;
3552
+ if (operation.op === "register" || operation.op === "upload") {
3553
+ if (operation.op === "register" && request.headers.origin)
3554
+ throw new EditorError(
3555
+ 403,
3556
+ "Registering a local path is for the local agent, not a page"
3557
+ );
3558
+ const bytes = operation.op === "register" ? store.readSource(operation.path) : Buffer.from(String(operation.data), "base64");
3559
+ const name = operation.op === "register" ? basename(operation.path) : String(operation.name ?? operation.id);
3560
+ const stored = store.store(operation.id, name, bytes, operation);
3561
+ const existing = assets.find((entry) => entry.id === stored.id);
3562
+ if (existing && existing.sha256 !== stored.sha256 && operation.replace !== true)
3563
+ throw new EditorError(
3564
+ 409,
3565
+ `Asset '${stored.id}' already exists; pass replace: true or choose a new id`
3566
+ );
3567
+ if (existing?.sha256 === stored.sha256) touched = existing;
3568
+ else {
3569
+ touched = existing ? { ...stored, adjust: existing.adjust ?? stored.adjust } : stored;
3570
+ next2 = [...assets.filter((entry) => entry.id !== stored.id), touched];
3571
+ }
3572
+ } else if (operation.op === "adjust") {
3573
+ const existing = assets.find((entry) => entry.id === operation.id);
3574
+ if (!existing || existing.kind !== "model")
3575
+ throw new EditorError(404, `No registered model '${operation.id}'`);
3576
+ touched = validateAsset({ ...existing, adjust: operation.adjust });
3577
+ next2 = assets.map(
3578
+ (entry) => entry.id === touched?.id ? touched : entry
3579
+ );
3580
+ } else if (operation.op === "remove") {
3581
+ touched = assets.find((entry) => entry.id === operation.id) ?? null;
3582
+ if (!touched) throw new EditorError(404, `No registered asset '${operation.id}'`);
3583
+ const used = Object.entries(surfaces).filter(
3584
+ ([, mapping]) => mapping.asset === operation.id
3585
+ );
3586
+ if (used.length)
3587
+ throw new EditorError(
3588
+ 409,
3589
+ `Asset '${operation.id}' is mapped to ${used.map(([input]) => input).join(", ")}; unmap it first`
3590
+ );
3591
+ const env = current.document.environment;
3592
+ const drawn = [
3593
+ env?.sky?.image === operation.id ? "environment.sky.image" : "",
3594
+ env?.lighting?.image === operation.id ? "environment.lighting.image" : ""
3595
+ ].filter(Boolean);
3596
+ if (drawn.length)
3597
+ throw new EditorError(
3598
+ 409,
3599
+ `Asset '${operation.id}' is used by ${drawn.join(", ")}; clear it first`
3600
+ );
3601
+ next2 = assets.filter((entry) => entry.id !== operation.id);
3602
+ } else if (operation.op === "map") {
3603
+ surfaceSpace(operation.input);
3604
+ touched = assets.find((entry) => entry.id === operation.asset) ?? null;
3605
+ if (!touched || touched.kind !== "image")
3606
+ throw new EditorError(404, `No registered image '${operation.asset}'`);
3607
+ nextSurfaces = { ...surfaces, [operation.input]: { asset: touched.id } };
3608
+ } else if (operation.op === "unmap") {
3609
+ if (!surfaces[operation.input])
3610
+ throw new EditorError(404, `Surface input '${operation.input}' has no mapping`);
3611
+ nextSurfaces = Object.fromEntries(
3612
+ Object.entries(surfaces).filter(([input]) => input !== operation.input)
3613
+ );
3614
+ } else if (operation.op !== "list") throw new Error("Unknown asset operation");
3615
+ const changed = JSON.stringify(next2) !== JSON.stringify(assets) || JSON.stringify(nextSurfaces) !== JSON.stringify(surfaces);
3616
+ const revision2 = changed ? document.commit({
3617
+ baseRevision: current.revision,
3618
+ document: JSON.parse(
3619
+ JSON.stringify({
3620
+ ...current.document,
3621
+ assets: next2,
3622
+ surfaces: Object.keys(nextSurfaces).length ? nextSurfaces : void 0
3623
+ })
3624
+ )
3625
+ }).revision : current.revision;
3626
+ const result = {
3627
+ op: operation.op,
3628
+ assets: next2,
3629
+ surfaces: nextSurfaces,
3630
+ asset: touched
3631
+ };
3632
+ json(response, 200, { ...result, revision: revision2 });
3633
+ return;
3634
+ }
3635
+ if (request.method === "POST" && path === `${PREFIX}api/environment`) {
3636
+ const transaction = await body(request);
3637
+ if (!transaction || typeof transaction !== "object" || Array.isArray(transaction) || Object.keys(transaction).some((key) => !["baseRevision", "operation"].includes(key)))
3638
+ throw new Error("Expected { baseRevision, operation }");
3639
+ const environment = transaction;
3640
+ const current = document.snapshot();
3641
+ if (environment.baseRevision !== current.revision)
3642
+ throw new EditorError(409, "Stale base revision");
3643
+ const result = runEnvironmentOperation(
3644
+ current.document.environment ?? {},
3645
+ environment.operation
3646
+ );
3647
+ const changed = JSON.stringify(result.environment) !== JSON.stringify(current.document.environment ?? {});
3648
+ const next2 = { ...current.document };
3649
+ if (Object.keys(result.environment).length) next2.environment = result.environment;
3650
+ else next2.environment = void 0;
3651
+ const revision2 = changed ? document.commit({
3652
+ baseRevision: current.revision,
3653
+ document: JSON.parse(JSON.stringify(next2))
3654
+ }).revision : current.revision;
3655
+ json(response, 200, { ...result, revision: revision2 });
3656
+ return;
3657
+ }
3658
+ if (request.method === "POST" && path === `${PREFIX}api/inspect`) {
3659
+ const transaction = await body(request);
3660
+ if (!transaction || typeof transaction !== "object" || Array.isArray(transaction) || Object.keys(transaction).some((key) => !["baseRevision", "query"].includes(key)))
3661
+ throw new Error("Expected { baseRevision, query }");
3662
+ const snapshot = document.snapshot();
3663
+ const inspection = transaction;
3664
+ if (inspection.baseRevision !== snapshot.revision)
3665
+ throw new EditorError(409, "Stale base revision");
3666
+ const state = evaluator.evaluate(snapshot.document.recipe);
3667
+ const result = inspectSpatial(
3668
+ state,
3669
+ snapshot.revision,
3670
+ inspection.query
3671
+ );
3672
+ json(response, 200, { result });
3673
+ return;
3674
+ }
3675
+ throw new EditorError(405, "Unsupported terrain editor route/method");
3676
+ } catch (error) {
3677
+ json(response, error instanceof EditorError ? error.status : 400, {
3678
+ error: error instanceof Error ? error.message : String(error),
3679
+ snapshot: document.snapshot()
3680
+ });
3681
+ }
3682
+ })();
3683
+ });
3684
+ }
3685
+ };
3686
+ }
3687
+
3688
+ export { TerrainEditorDocument, terrainEditor };