@call-me-sensei/toonlab 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 (146) hide show
  1. package/ATTRIBUTION.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/package.json +97 -0
  5. package/src/character/characterRig.js +353 -0
  6. package/src/character/freestyleSwimClip.js +541 -0
  7. package/src/character/index.js +4 -0
  8. package/src/character/modelLoader.js +433 -0
  9. package/src/core/materialRoles.js +438 -0
  10. package/src/core/presetDocuments.js +110 -0
  11. package/src/core/shaderBackend.js +24 -0
  12. package/src/debrisgen/debrisFields.js +544 -0
  13. package/src/debrisgen/debrisGenerator.js +1929 -0
  14. package/src/debrisgen/debrisPalettes.js +71 -0
  15. package/src/debrisgen/debrisPhysics.js +198 -0
  16. package/src/debrisgen/debrisPresets.js +155 -0
  17. package/src/debrisgen/debrisSettings.js +333 -0
  18. package/src/debrisgen/debrisTextures.js +380 -0
  19. package/src/debrisgen/index.js +5 -0
  20. package/src/debug/fieldValues.js +95 -0
  21. package/src/debug/index.js +7 -0
  22. package/src/debug/settingsPanel.js +170 -0
  23. package/src/environment/environmentAmbientProbe.js +160 -0
  24. package/src/environment/environmentMaterialAdapter.js +336 -0
  25. package/src/environment/environmentMaterialClassifier.js +178 -0
  26. package/src/environment/environmentPlanarReflection.js +201 -0
  27. package/src/environment/environmentPresets.js +443 -0
  28. package/src/environment/environmentRigs.js +684 -0
  29. package/src/environment/environmentSettings.js +504 -0
  30. package/src/environment/environmentShaderMaterials.js +318 -0
  31. package/src/environment/environmentSunShadowPass.js +268 -0
  32. package/src/environment/environmentTextureResolver.js +161 -0
  33. package/src/environment/environmentTimeOfDay.js +167 -0
  34. package/src/environment/environmentVertexAo.js +188 -0
  35. package/src/environment/index.js +8 -0
  36. package/src/environment/scanAssetStylize.js +103 -0
  37. package/src/index.js +21 -0
  38. package/src/loaders/index.js +1 -0
  39. package/src/post/index.js +2 -0
  40. package/src/post/postProcessing.js +1087 -0
  41. package/src/rockgen/export/glbExport.js +177 -0
  42. package/src/rockgen/heightfield/heightfieldErosion.js +7 -0
  43. package/src/rockgen/heightfield/heightfieldPatch.js +183 -0
  44. package/src/rockgen/heightfield/stylizedErosionSim.js +356 -0
  45. package/src/rockgen/index.js +14 -0
  46. package/src/rockgen/mesh/meshAttributes.js +374 -0
  47. package/src/rockgen/mesh/meshDocument.js +158 -0
  48. package/src/rockgen/mesh/surfaceNets.js +329 -0
  49. package/src/rockgen/noise/cellularNoise3.js +92 -0
  50. package/src/rockgen/noise/prng.js +62 -0
  51. package/src/rockgen/noise/simplexNoise3.js +89 -0
  52. package/src/rockgen/noise/valueNoise3.js +87 -0
  53. package/src/rockgen/rockDocument.js +255 -0
  54. package/src/rockgen/rockHelpers.js +8 -0
  55. package/src/rockgen/rockgenPresets.js +559 -0
  56. package/src/rockgen/rockgenSettings.js +756 -0
  57. package/src/rockgen/sdf/fieldCompiler.js +544 -0
  58. package/src/rockgen/sdf/sculptEdits.js +66 -0
  59. package/src/rockgen/sdf/sdfModifiers.js +45 -0
  60. package/src/rockgen/sdf/sdfOps.js +37 -0
  61. package/src/rockgen/sdf/sdfPrimitives.js +88 -0
  62. package/src/shaders-tsl/anime.js +963 -0
  63. package/src/shaders-tsl/chunks/character-color.js +64 -0
  64. package/src/shaders-tsl/chunks/character-highlights.js +174 -0
  65. package/src/shaders-tsl/chunks/character-lighting.js +309 -0
  66. package/src/shaders-tsl/chunks/character-material-maps.js +146 -0
  67. package/src/shaders-tsl/chunks/character-roles.js +52 -0
  68. package/src/shaders-tsl/chunks/character-scene-lights.js +270 -0
  69. package/src/shaders-tsl/chunks/character-shadow-color.js +61 -0
  70. package/src/shaders-tsl/chunks/character-skinning.js +140 -0
  71. package/src/shaders-tsl/chunks/environment-color.js +47 -0
  72. package/src/shaders-tsl/chunks/environment-debug.js +79 -0
  73. package/src/shaders-tsl/chunks/environment-lighting.js +260 -0
  74. package/src/shaders-tsl/chunks/environment-sun-shadow.js +87 -0
  75. package/src/shaders-tsl/chunks/foliage-fog.js +60 -0
  76. package/src/shaders-tsl/chunks/pass-depth-color.js +89 -0
  77. package/src/shaders-tsl/chunks/stylized-cloud-shadow.js +70 -0
  78. package/src/shaders-tsl/chunks/water-color.js +190 -0
  79. package/src/shaders-tsl/chunks/water-common.js +124 -0
  80. package/src/shaders-tsl/chunks/water-foam.js +93 -0
  81. package/src/shaders-tsl/chunks/water-lighting.js +116 -0
  82. package/src/shaders-tsl/chunks/water-ripple.js +64 -0
  83. package/src/shaders-tsl/chunks/water-waves.js +100 -0
  84. package/src/shaders-tsl/environment-ao-overlay.js +60 -0
  85. package/src/shaders-tsl/environment.js +680 -0
  86. package/src/shaders-tsl/flower.js +267 -0
  87. package/src/shaders-tsl/grass.js +193 -0
  88. package/src/shaders-tsl/post-composite.js +461 -0
  89. package/src/shaders-tsl/sky.js +175 -0
  90. package/src/shaders-tsl/tree-leaf.js +264 -0
  91. package/src/shaders-tsl/water-breaker.js +423 -0
  92. package/src/shaders-tsl/water-kelp.js +171 -0
  93. package/src/shaders-tsl/water-rain.js +107 -0
  94. package/src/shaders-tsl/water-simulation.js +140 -0
  95. package/src/shaders-tsl/water-splash.js +298 -0
  96. package/src/shaders-tsl/water.js +509 -0
  97. package/src/sky/index.js +2 -0
  98. package/src/sky/stylizedSky.js +341 -0
  99. package/src/toon/characterRenderPasses.js +646 -0
  100. package/src/toon/index.js +4 -0
  101. package/src/toon/settings/alphaSettings.js +170 -0
  102. package/src/toon/settings/averageShadowSettings.js +181 -0
  103. package/src/toon/settings/baseTextureSettings.js +133 -0
  104. package/src/toon/settings/celShadeSettings.js +96 -0
  105. package/src/toon/settings/contactShadowSettings.js +114 -0
  106. package/src/toon/settings/eyeHighlightSettings.js +187 -0
  107. package/src/toon/settings/faceLightingSettings.js +135 -0
  108. package/src/toon/settings/furSettings.js +112 -0
  109. package/src/toon/settings/glitterSettings.js +102 -0
  110. package/src/toon/settings/hairHighlightSettings.js +325 -0
  111. package/src/toon/settings/indirectLightSettings.js +244 -0
  112. package/src/toon/settings/localLightSettings.js +176 -0
  113. package/src/toon/settings/materialMapSettings.js +299 -0
  114. package/src/toon/settings/outlineSettings.js +263 -0
  115. package/src/toon/settings/perspectiveRemovalSettings.js +60 -0
  116. package/src/toon/settings/rimLightSettings.js +268 -0
  117. package/src/toon/settings/sceneShadowSettings.js +147 -0
  118. package/src/toon/settings/selfShadowSettings.js +221 -0
  119. package/src/toon/settings/shadowColorSettings.js +238 -0
  120. package/src/toon/settings/skinToneSettings.js +150 -0
  121. package/src/toon/settings/specularSettings.js +314 -0
  122. package/src/toon/settings/stickerSettings.js +102 -0
  123. package/src/toon/toonMaterialAdapter.js +2235 -0
  124. package/src/toon/toonSettings.js +860 -0
  125. package/src/vegetation/flowerSpecies.js +260 -0
  126. package/src/vegetation/index.js +10 -0
  127. package/src/vegetation/stylizedBush.js +136 -0
  128. package/src/vegetation/stylizedFlower.js +213 -0
  129. package/src/vegetation/stylizedFlowers.js +297 -0
  130. package/src/vegetation/stylizedGrass.js +491 -0
  131. package/src/vegetation/stylizedTree.js +2449 -0
  132. package/src/vegetation/stylizedTreeFoliage.js +691 -0
  133. package/src/vegetation/treeExport.js +300 -0
  134. package/src/vegetation/treeRecipe.js +647 -0
  135. package/src/water/index.js +2 -0
  136. package/src/water/water.js +20 -0
  137. package/src/water/waterBreakerSystem.js +591 -0
  138. package/src/water/waterInteraction.js +193 -0
  139. package/src/water/waterMaterial.js +263 -0
  140. package/src/water/waterRain.js +136 -0
  141. package/src/water/waterRippleSimulation.js +227 -0
  142. package/src/water/waterScenePasses.js +415 -0
  143. package/src/water/waterSettings.js +1138 -0
  144. package/src/water/waterSplashSystem.js +251 -0
  145. package/src/water/waterSurface.js +475 -0
  146. package/src/water/waterVegetation.js +83 -0
@@ -0,0 +1,544 @@
1
+ // Compiles a rock document into a FieldProgram: one closure per piece
2
+ // (inverse transform and stage constants baked in), left-folded with the
3
+ // piece combine ops, then sculpt edits applied on top.
4
+ //
5
+ // Lipschitz note: displaced fields under/over-estimate true distance, which
6
+ // would break sphere tracing but is harmless for rockgen's dense grid
7
+ // sampling as long as the zero crossing stays inside the sampled bounds.
8
+ // That is guaranteed by padding each piece's AABB with the worst-case
9
+ // modifier displacement (maxPieceDisplacement) plus the domain-warp reach
10
+ // (warpBoundsPad) plus its combine blend; meshDocument.js adds the final
11
+ // two-voxel pad once the cell size is known.
12
+ //
13
+ // Determinism: all per-sample math is hash/polynomial based (see noise/).
14
+ // Math.sin/cos appear only in per-piece compile-time constants (rotation),
15
+ // never per sample, so a document meshes bit-identically run to run.
16
+
17
+ import { cellular2, cellularCrease3 } from '../noise/cellularNoise3.js';
18
+ import { hash3f, hashCombine } from '../noise/prng.js';
19
+ import { simplexNoise3 } from '../noise/simplexNoise3.js';
20
+ import { fbm3, ridgedFbm3 } from '../noise/valueNoise3.js';
21
+ import { combine, opSmoothIntersect } from './sdfOps.js';
22
+ import { maxPieceDisplacement, strataProfile, warpBoundsPad } from './sdfModifiers.js';
23
+ import {
24
+ sdCapsule, sdEllipsoid, sdExtrudedPolygon, sdRoundBox, sdSphere,
25
+ } from './sdfPrimitives.js';
26
+ import { getHeightfieldPatch } from '../heightfield/heightfieldPatch.js';
27
+ import { isRockHelperPiece } from '../rockHelpers.js';
28
+ import { applySculptEditToField, evaluateSculptEdit, sculptEditBounds } from './sculptEdits.js';
29
+
30
+ const DEG_TO_RAD = Math.PI / 180;
31
+
32
+ // Seed lanes so each stage draws decorrelated noise from one piece seed.
33
+ const SEED_WARP_X = 11;
34
+ const SEED_WARP_Y = 23;
35
+ const SEED_WARP_Z = 37;
36
+ const SEED_NOISE = 53;
37
+ const SEED_FACET = 71;
38
+ const SEED_STRATA = 89;
39
+ const SEED_COLUMNS = 101;
40
+ const SEED_CUTS = 113;
41
+ const SEED_HEIGHTFIELD = 127;
42
+ const SEED_CRACKS = 137;
43
+ const SEED_CRACK_COVER = 149;
44
+
45
+ function clamp01(value) {
46
+ return Math.min(Math.max(value, 0), 1);
47
+ }
48
+
49
+ // A sketch piece is only meshable with a drawn outline of 3+ points; the
50
+ // select-a-type UI can set type 'sketch' before any outline exists, so both
51
+ // the metrics and the compiled shape fall back to the ellipsoid until then.
52
+ function usableOutline(piece) {
53
+ return piece.shape.type === 'sketch' && Array.isArray(piece.outline)
54
+ && piece.outline.length >= 3
55
+ ? piece.outline
56
+ : null;
57
+ }
58
+
59
+ // Exact support (farthest surface reach) of the base shape along a unit
60
+ // direction. Planar cuts place their planes relative to this; the box's L1
61
+ // support badly overestimates ellipsoids and capsules along diagonals,
62
+ // which parks cut planes outside the surface where they slice nothing.
63
+ function shapeSupport(piece, nx, ny, nz) {
64
+ const { capsuleLength, sizeX, sizeY, sizeZ, type } = piece.shape;
65
+ if (type === 'sphere') return sizeX;
66
+ if (type === 'box' || type === 'heightfield') {
67
+ return Math.abs(nx) * sizeX + Math.abs(ny) * sizeY + Math.abs(nz) * sizeZ;
68
+ }
69
+ if (type === 'capsule') {
70
+ return Math.abs(ny) * (capsuleLength / 2) + sizeX;
71
+ }
72
+ const outline = usableOutline(piece);
73
+ if (outline) {
74
+ let planar = 0;
75
+ for (const [px, py] of outline) {
76
+ planar = Math.max(planar, nx * px + ny * py);
77
+ }
78
+ return planar + Math.abs(nz) * sizeZ;
79
+ }
80
+ // Ellipsoid support: |diag(r) * n|.
81
+ return Math.sqrt((nx * sizeX) ** 2 + (ny * sizeY) ** 2 + (nz * sizeZ) ** 2);
82
+ }
83
+
84
+ // Shape metrics used by falloff normalization and local bounds.
85
+ function shapeMetrics(piece) {
86
+ const { capsuleLength, sizeX, sizeY, sizeZ, type } = piece.shape;
87
+ if (type === 'sphere') {
88
+ return { extentX: sizeX, extentY: sizeX, extentZ: sizeX };
89
+ }
90
+ if (type === 'box' || type === 'heightfield') {
91
+ return { extentX: sizeX, extentY: sizeY, extentZ: sizeZ };
92
+ }
93
+ if (type === 'capsule') {
94
+ return { extentX: sizeX, extentY: capsuleLength / 2 + sizeX, extentZ: sizeX };
95
+ }
96
+ const outline = usableOutline(piece);
97
+ if (outline) {
98
+ let extentX = 0;
99
+ let extentY = 0;
100
+ for (const [px, py] of outline) {
101
+ extentX = Math.max(extentX, Math.abs(px));
102
+ extentY = Math.max(extentY, Math.abs(py));
103
+ }
104
+ return { extentX, extentY, extentZ: sizeZ };
105
+ }
106
+ return { extentX: sizeX, extentY: sizeY, extentZ: sizeZ }; // ellipsoid
107
+ }
108
+
109
+ function compileBaseShape(piece, seed) {
110
+ const { capsuleLength, cornerRadius, sizeX, sizeY, sizeZ, type } = piece.shape;
111
+ if (type === 'sphere') {
112
+ return (x, y, z) => sdSphere(x, y, z, sizeX);
113
+ }
114
+ if (type === 'heightfield') {
115
+ // Eroded landform: the patch's normalized heights span the piece's
116
+ // vertical extents (y = -sizeY at height 0, +sizeY at height 1),
117
+ // clipped to the footprint box. The 0.7 slope-compensation keeps the
118
+ // vertical-distance bound conservative on steep gullies — dense-grid
119
+ // sampling only needs the zero crossing inside the padded bounds.
120
+ const patch = getHeightfieldPatch(
121
+ hashCombine(seed >>> 0, SEED_HEIGHTFIELD), piece.heightfield,
122
+ );
123
+ return (x, y, z) => {
124
+ const u = (x / sizeX) * 0.5 + 0.5;
125
+ const v = (z / sizeZ) * 0.5 + 0.5;
126
+ const top = -sizeY + patch.sample(u, v) * 2 * sizeY;
127
+ const dSurface = (y - top) * 0.7;
128
+ const dSides = Math.max(Math.abs(x) - sizeX, Math.abs(z) - sizeZ);
129
+ const dBottom = -sizeY - y;
130
+ return Math.max(dSurface, dSides, dBottom);
131
+ };
132
+ }
133
+ if (type === 'box') {
134
+ const r = clamp01(cornerRadius) * Math.min(sizeX, sizeY, sizeZ);
135
+ return (x, y, z) => sdRoundBox(x, y, z, sizeX, sizeY, sizeZ, r);
136
+ }
137
+ if (type === 'capsule') {
138
+ return (x, y, z) => sdCapsule(x, y, z, capsuleLength / 2, sizeX);
139
+ }
140
+ const outline = usableOutline(piece);
141
+ if (outline) {
142
+ const flat = new Float64Array(outline.length * 2);
143
+ for (let i = 0; i < outline.length; i += 1) {
144
+ flat[i * 2] = outline[i][0];
145
+ flat[i * 2 + 1] = outline[i][1];
146
+ }
147
+ const rounding = clamp01(cornerRadius) * sizeZ;
148
+ return (x, y, z) => sdExtrudedPolygon(flat, x, y, z, sizeZ, rounding);
149
+ }
150
+ return (x, y, z) => sdEllipsoid(x, y, z, sizeX, sizeY, sizeZ);
151
+ }
152
+
153
+ // Rotation matrix elements for euler XYZ (matches THREE.Matrix4
154
+ // .makeRotationFromEuler order so lab gizmo transforms round-trip).
155
+ function rotationFromEuler(rx, ry, rz) {
156
+ const a = Math.cos(rx);
157
+ const b = Math.sin(rx);
158
+ const c = Math.cos(ry);
159
+ const d = Math.sin(ry);
160
+ const e = Math.cos(rz);
161
+ const f = Math.sin(rz);
162
+ const ae = a * e;
163
+ const af = a * f;
164
+ const be = b * e;
165
+ const bf = b * f;
166
+ return [
167
+ c * e, -c * f, d,
168
+ af + be * d, ae - bf * d, -b * c,
169
+ bf - ae * d, be + af * d, a * c,
170
+ ];
171
+ }
172
+
173
+ function compilePiece(piece, documentSeed) {
174
+ const seed = hashCombine(documentSeed, piece.seed >>> 0);
175
+ const {
176
+ columns, cracks, cuts, facet, falloff, noise, strata, transform, warp,
177
+ } = piece;
178
+
179
+ const [posX, posY, posZ] = transform.position;
180
+ const [sclX, sclY, sclZ] = transform.scale;
181
+ const rot = rotationFromEuler(
182
+ transform.rotation[0],
183
+ transform.rotation[1],
184
+ transform.rotation[2],
185
+ );
186
+ const distanceScale = Math.min(sclX, sclY, sclZ);
187
+
188
+ const base = compileBaseShape(piece, seed);
189
+ const { extentX, extentY, extentZ } = shapeMetrics(piece);
190
+
191
+ const warpEnabled = warp.enabled && warp.strength > 0;
192
+ const warpFreq = warp.frequency;
193
+ const warpStrength = warp.strength;
194
+ const seedWarpX = hashCombine(seed, SEED_WARP_X);
195
+ const seedWarpY = hashCombine(seed, SEED_WARP_Y);
196
+ const seedWarpZ = hashCombine(seed, SEED_WARP_Z);
197
+
198
+ const noiseEnabled = noise.enabled && noise.amplitude > 0;
199
+ const noiseSeed = hashCombine(hashCombine(seed, SEED_NOISE), noise.seedOffset >>> 0);
200
+ const noiseOctaves = Math.round(noise.octaves);
201
+
202
+ const facetEnabled = facet.enabled && facet.strength > 0;
203
+ const facetSeed = hashCombine(seed, SEED_FACET);
204
+
205
+ const cracksEnabled = Boolean(cracks?.enabled) && cracks.depth > 0 && cracks.coverage > 0;
206
+ const cracksSeed = hashCombine(seed, SEED_CRACKS);
207
+ const crackCoverSeed = hashCombine(seed, SEED_CRACK_COVER);
208
+
209
+ const strataEnabled = strata.enabled && strata.strength > 0;
210
+ const strataSeed = hashCombine(seed, SEED_STRATA);
211
+ const tilt = strata.tiltDegrees * DEG_TO_RAD;
212
+ // Strata bands stack along a tilted axis in the local XZ->Y plane.
213
+ const tiltY = Math.cos(tilt);
214
+ const tiltX = Math.sin(tilt);
215
+
216
+ const columnsEnabled = Boolean(columns?.enabled)
217
+ && (columns.grooveDepth > 0 || columns.heightVariation > 0);
218
+ const columnSeed = hashCombine(seed, SEED_COLUMNS);
219
+
220
+ // Planar cuts compile to a flat array of [nx, ny, nz, offset] half-space
221
+ // planes. All randomness is drawn here (compile time), so per sample each
222
+ // cut costs one dot product and one max.
223
+ const cutsEnabled = Boolean(cuts?.enabled) && cuts.count >= 1 && cuts.depth > 0;
224
+ let cutPlanes = null;
225
+ let cutBevel = 0;
226
+ if (cutsEnabled) {
227
+ const cutSeed = hashCombine(hashCombine(seed, SEED_CUTS), cuts.seedOffset >>> 0);
228
+ const cutCount = Math.round(cuts.count);
229
+ cutBevel = Math.max(cuts.bevel, 0);
230
+ cutPlanes = new Float64Array(cutCount * 4);
231
+ for (let i = 0; i < cutCount; i += 1) {
232
+ const azimuth = hash3f(cutSeed, i, 0, 0) * Math.PI * 2;
233
+ // verticalBias squashes normals toward the horizon so the cut faces
234
+ // stand near-vertical (cliff walls); 0 slices from any direction.
235
+ const ny = (hash3f(cutSeed, i, 1, 0) * 2 - 1) * (1 - clamp01(cuts.verticalBias));
236
+ const horizontal = Math.sqrt(Math.max(1 - ny * ny, 0));
237
+ const nx = Math.cos(azimuth) * horizontal;
238
+ const nz = Math.sin(azimuth) * horizontal;
239
+ // The plane lands between (1 - depth) and (1 - depth/4) of the exact
240
+ // shape support along the normal, so a cut bites at most `depth` of
241
+ // the shape but always at least a quarter of that — a zero-bite
242
+ // plane is an invisible no-op slider.
243
+ const support = shapeSupport(piece, nx, ny, nz);
244
+ const bite = cuts.depth * (0.25 + 0.75 * hash3f(cutSeed, i, 2, 0));
245
+ const offset = support * (1 - bite);
246
+ cutPlanes[i * 4] = nx;
247
+ cutPlanes[i * 4 + 1] = ny;
248
+ cutPlanes[i * 4 + 2] = nz;
249
+ cutPlanes[i * 4 + 3] = offset;
250
+ }
251
+ }
252
+
253
+ const { bottomFlatten, radialPinch, topTaper } = falloff;
254
+ const maxRadius = Math.max(extentX, extentZ);
255
+ const flattenPlaneY = -extentY * (1 - clamp01(bottomFlatten));
256
+ const flattenBlend = extentY * 0.08;
257
+
258
+ const evaluate = (x, y, z) => {
259
+ // World -> local: translate, inverse-rotate (transpose), inverse-scale.
260
+ const dx = x - posX;
261
+ const dy = y - posY;
262
+ const dz = z - posZ;
263
+ const lx = (rot[0] * dx + rot[3] * dy + rot[6] * dz) / sclX;
264
+ const ly = (rot[1] * dx + rot[4] * dy + rot[7] * dz) / sclY;
265
+ const lz = (rot[2] * dx + rot[5] * dy + rot[8] * dz) / sclZ;
266
+
267
+ let ampScale = 1;
268
+ if (topTaper > 0) {
269
+ ampScale *= 1 - topTaper * clamp01((ly / extentY) * 0.5 + 0.5);
270
+ }
271
+ if (radialPinch > 0) {
272
+ const radial = Math.sqrt(lx * lx + lz * lz) / maxRadius;
273
+ ampScale *= 1 - radialPinch * clamp01(radial);
274
+ }
275
+
276
+ // Columnar jointing: each 2D Voronoi cell in the ground plane is one
277
+ // column. Lifting the sample's y per cell steps the column tops; the
278
+ // field discontinuity at cell borders meshes as the vertical seam.
279
+ let columnLift = 0;
280
+ let columnGroove = 0;
281
+ let columnPhase = 0;
282
+ if (columnsEnabled) {
283
+ const cell = cellular2(columnSeed, lx * columns.scale, lz * columns.scale, 1);
284
+ columnLift = (cell.id - 0.5) * columns.heightVariation;
285
+ columnPhase = cell.id;
286
+ const edge = (cell.f2 - cell.f1) / columns.grooveWidth;
287
+ if (edge < 1) {
288
+ const t = 1 - Math.max(edge, 0);
289
+ columnGroove = t * t * (3 - 2 * t);
290
+ }
291
+ }
292
+
293
+ let wx = lx;
294
+ let wy = ly - columnLift;
295
+ let wz = lz;
296
+ if (warpEnabled) {
297
+ wx += warpStrength * simplexNoise3(seedWarpX, lx * warpFreq, ly * warpFreq, lz * warpFreq);
298
+ wy += warpStrength * simplexNoise3(seedWarpY, lx * warpFreq, ly * warpFreq, lz * warpFreq);
299
+ wz += warpStrength * simplexNoise3(seedWarpZ, lx * warpFreq, ly * warpFreq, lz * warpFreq);
300
+ }
301
+
302
+ let d = base(wx, wy, wz);
303
+
304
+ if (noiseEnabled) {
305
+ const n = noise.ridged
306
+ ? ridgedFbm3(noiseSeed, wx * noise.frequency, wy * noise.frequency, wz * noise.frequency,
307
+ noiseOctaves, noise.lacunarity, noise.gain) * 2 - 1
308
+ : fbm3(noiseSeed, wx * noise.frequency, wy * noise.frequency, wz * noise.frequency,
309
+ noiseOctaves, noise.lacunarity, noise.gain);
310
+ d -= noise.amplitude * ampScale * n;
311
+ }
312
+
313
+ if (facetEnabled) {
314
+ d += facet.strength * ampScale
315
+ * cellularCrease3(facetSeed, wx * facet.scale, wy * facet.scale, wz * facet.scale, facet.jitter);
316
+ }
317
+
318
+ if (cracksEnabled) {
319
+ // Sparse fissures: narrow crease profile on LARGE cells, gated by a
320
+ // low-frequency coverage mask so cracks live in weathered patches
321
+ // instead of tiling the whole surface.
322
+ const cover = fbm3(crackCoverSeed, wx * 0.55, wy * 0.55, wz * 0.55, 2, 2, 0.5) * 0.5 + 0.5;
323
+ const gate = clamp01((cracks.coverage - cover) / 0.12 + 0.5);
324
+ if (gate > 0) {
325
+ d += cracks.depth * ampScale * gate
326
+ * cellularCrease3(cracksSeed, wx * cracks.scale, wy * cracks.scale, wz * cracks.scale, 1, cracks.width);
327
+ }
328
+ }
329
+
330
+ if (strataEnabled) {
331
+ // Joints follow the lifted column surface, and each column gets its
332
+ // own phase so horizontal break lines don't align across columns.
333
+ let v = (tiltX * lx + tiltY * (ly - columnLift)) * strata.frequency + columnPhase * 2.618;
334
+ if (strata.warpAmount > 0) {
335
+ v += strata.warpAmount * fbm3(strataSeed, lx * 0.8, ly * 0.8, lz * 0.8, 2, 2, 0.5);
336
+ }
337
+ d += strata.strength * ampScale * strataProfile(v, clamp01(strata.sharpness));
338
+ }
339
+
340
+ // Column grooves stay full-depth to the top — they define the
341
+ // silhouette, so no ampScale fade.
342
+ if (columnGroove > 0) d += columns.grooveDepth * columnGroove;
343
+
344
+ // Planar cuts intersect in UNWARPED local space, after all displacement
345
+ // stages: warp and noise never touch the cut faces, so they mesh as
346
+ // dead-flat planes with straight edges (no ampScale — they define the
347
+ // silhouette). Bevel > 0 softens the edge with a smooth intersect.
348
+ if (cutPlanes) {
349
+ for (let i = 0; i < cutPlanes.length; i += 4) {
350
+ const plane = cutPlanes[i] * lx + cutPlanes[i + 1] * ly + cutPlanes[i + 2] * lz
351
+ - cutPlanes[i + 3];
352
+ d = cutBevel > 0 ? opSmoothIntersect(d, plane, cutBevel) : Math.max(d, plane);
353
+ }
354
+ }
355
+
356
+ if (bottomFlatten > 0) {
357
+ // Keep the half-space above the flatten plane (negative above plane).
358
+ d = opSmoothIntersect(d, flattenPlaneY - ly, flattenBlend);
359
+ }
360
+
361
+ return d * distanceScale;
362
+ };
363
+
364
+ // Local AABB (pre-scale space) padded by displacement + warp reach, then
365
+ // scaled/rotated to a world AABB via the 8 corners.
366
+ const pad = maxPieceDisplacement(piece) + warpBoundsPad(piece);
367
+ const ex = extentX + pad;
368
+ const ey = extentY + pad;
369
+ const ez = extentZ + pad;
370
+ const boundsMin = [Infinity, Infinity, Infinity];
371
+ const boundsMax = [-Infinity, -Infinity, -Infinity];
372
+ for (let corner = 0; corner < 8; corner += 1) {
373
+ const cx = (corner & 1 ? ex : -ex) * sclX;
374
+ const cy = (corner & 2 ? ey : -ey) * sclY;
375
+ const cz = (corner & 4 ? ez : -ez) * sclZ;
376
+ // Forward rotation (row-major, matches rotationFromEuler layout).
377
+ const wxc = rot[0] * cx + rot[1] * cy + rot[2] * cz + posX;
378
+ const wyc = rot[3] * cx + rot[4] * cy + rot[5] * cz + posY;
379
+ const wzc = rot[6] * cx + rot[7] * cy + rot[8] * cz + posZ;
380
+ boundsMin[0] = Math.min(boundsMin[0], wxc);
381
+ boundsMin[1] = Math.min(boundsMin[1], wyc);
382
+ boundsMin[2] = Math.min(boundsMin[2], wzc);
383
+ boundsMax[0] = Math.max(boundsMax[0], wxc);
384
+ boundsMax[1] = Math.max(boundsMax[1], wyc);
385
+ boundsMax[2] = Math.max(boundsMax[2], wzc);
386
+ }
387
+
388
+ return { bounds: { max: boundsMax, min: boundsMin }, evaluate };
389
+ }
390
+
391
+ const PROGRAM_CACHE = new WeakMap();
392
+
393
+ /**
394
+ * Compiles a document into `{ evaluate(x, y, z), bounds, revision }`.
395
+ * Results are cached per document object and invalidated by `revision`,
396
+ * so repeated meshing while dragging one slider recompiles only once per
397
+ * document mutation.
398
+ *
399
+ * @param {object} document Rock document (rockDocument.js shape).
400
+ * @param {{ includeHelpers?: boolean, pieceId?: string }} [options]
401
+ * `pieceId` restricts to a single piece, evaluated in its LOCAL space
402
+ * (transform ignored) — used by the lab's per-piece preview mode where
403
+ * the transform lives on the scene group. `includeHelpers` keeps
404
+ * construction-only supports for authoring calculations; visual meshing
405
+ * and walker collision pass false so they match the final rock.
406
+ */
407
+ export function compileDocument(document, { includeHelpers = true, pieceId = null } = {}) {
408
+ const cacheKey = pieceId === null ? document : null;
409
+ const cacheSlot = includeHelpers ? 'withHelpers' : 'withoutHelpers';
410
+ if (cacheKey) {
411
+ const cached = PROGRAM_CACHE.get(cacheKey);
412
+ if (cached && cached.revision === document.revision && cached[cacheSlot]) {
413
+ return cached[cacheSlot];
414
+ }
415
+ }
416
+
417
+ const documentSeed = document.seed >>> 0;
418
+ let pieces = document.pieces;
419
+ if (pieceId !== null) {
420
+ const piece = document.pieces.find((entry) => entry.id === pieceId);
421
+ if (!piece) throw new Error(`Rock document has no piece "${pieceId}".`);
422
+ pieces = [{
423
+ ...piece,
424
+ transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] },
425
+ }];
426
+ } else {
427
+ // Hidden pieces are excluded from the field; an all-hidden document
428
+ // falls back to everything rather than meshing nothing, but visual
429
+ // fields never fall back to construction helpers.
430
+ const visible = pieces.filter((piece) => !piece.hidden && (includeHelpers || !isRockHelperPiece(piece)));
431
+ if (visible.length > 0) {
432
+ pieces = visible;
433
+ } else if (!includeHelpers) {
434
+ pieces = pieces.filter((piece) => !isRockHelperPiece(piece));
435
+ }
436
+ }
437
+ if (pieces.length === 0) throw new Error('Rock document has no pieces to compile.');
438
+
439
+ const compiled = pieces.map((piece) => ({
440
+ blend: piece.combine.blend,
441
+ op: piece.combine.op,
442
+ piece: compilePiece(piece, documentSeed),
443
+ }));
444
+
445
+ const sculptEdits = pieceId === null ? document.sculptEdits : [];
446
+
447
+ const boundsMin = [Infinity, Infinity, Infinity];
448
+ const boundsMax = [-Infinity, -Infinity, -Infinity];
449
+ for (let index = 0; index < compiled.length; index += 1) {
450
+ const entry = compiled[index];
451
+ // Subtract/intersect pieces only remove volume; they never extend the
452
+ // surface beyond the union pieces (plus their smooth blend radius).
453
+ // Piece 0 always counts — the fold starts from it, ignoring its op.
454
+ const expands = entry.op === 'union' || entry.op === 'smoothUnion';
455
+ const blendPad = entry.op === 'smoothUnion' ? entry.blend : 0;
456
+ if (!expands && index > 0) continue;
457
+ for (let axis = 0; axis < 3; axis += 1) {
458
+ boundsMin[axis] = Math.min(boundsMin[axis], entry.piece.bounds.min[axis] - blendPad);
459
+ boundsMax[axis] = Math.max(boundsMax[axis], entry.piece.bounds.max[axis] + blendPad);
460
+ }
461
+ }
462
+ for (const edit of sculptEdits) {
463
+ if (edit.tool !== 'add') continue;
464
+ const editBounds = sculptEditBounds(edit);
465
+ for (let axis = 0; axis < 3; axis += 1) {
466
+ boundsMin[axis] = Math.min(boundsMin[axis], editBounds.min[axis]);
467
+ boundsMax[axis] = Math.max(boundsMax[axis], editBounds.max[axis]);
468
+ }
469
+ }
470
+
471
+ const evaluate = (x, y, z) => {
472
+ let d = compiled[0].piece.evaluate(x, y, z);
473
+ for (let i = 1; i < compiled.length; i += 1) {
474
+ d = combine(compiled[i].op, d, compiled[i].piece.evaluate(x, y, z), compiled[i].blend);
475
+ }
476
+ // Phase D adds a coarse culling grid here; linear application is fine
477
+ // for the edit counts Phase A-C documents carry.
478
+ for (const edit of sculptEdits) {
479
+ d = applySculptEditToField(edit, d, evaluateSculptEdit(edit, x, y, z));
480
+ }
481
+ return d;
482
+ };
483
+
484
+ // Erosion-story tint hook: heightfield pieces expose their sim masks
485
+ // (deposition lightens gully floors, flow darkens water paths) to the
486
+ // vertex-color bake. First containing piece wins — good enough until a
487
+ // composition stacks eroded landforms.
488
+ const tintPieces = pieces
489
+ .filter((piece) => piece.shape.type === 'heightfield')
490
+ .map((piece) => {
491
+ const pieceSeed = hashCombine(documentSeed, piece.seed >>> 0);
492
+ const patch = getHeightfieldPatch(
493
+ hashCombine(pieceSeed, SEED_HEIGHTFIELD), piece.heightfield,
494
+ );
495
+ if (!patch.masks) return null;
496
+ const [posX, posY, posZ] = piece.transform.position;
497
+ const [sclX, sclY, sclZ] = piece.transform.scale;
498
+ const rot = rotationFromEuler(
499
+ piece.transform.rotation[0], piece.transform.rotation[1], piece.transform.rotation[2],
500
+ );
501
+ const { sizeX, sizeZ } = piece.shape;
502
+ return (x, y, z) => {
503
+ const dx = x - posX;
504
+ const dy = y - posY;
505
+ const dz = z - posZ;
506
+ const lx = (rot[0] * dx + rot[3] * dy + rot[6] * dz) / sclX;
507
+ const lz = (rot[2] * dx + rot[5] * dy + rot[8] * dz) / sclZ;
508
+ if (Math.abs(lx) > sizeX || Math.abs(lz) > sizeZ) return null;
509
+ return patch.sampleMasks((lx / sizeX) * 0.5 + 0.5, (lz / sizeZ) * 0.5 + 0.5);
510
+ };
511
+ })
512
+ .filter(Boolean);
513
+ const tintAt = tintPieces.length === 0 ? null : (x, y, z) => {
514
+ for (const sample of tintPieces) {
515
+ const tint = sample(x, y, z);
516
+ if (tint) return tint;
517
+ }
518
+ return null;
519
+ };
520
+
521
+ const program = {
522
+ bounds: { max: boundsMax, min: boundsMin },
523
+ evaluate,
524
+ revision: document.revision,
525
+ tintAt,
526
+ };
527
+ if (cacheKey) {
528
+ const cached = PROGRAM_CACHE.get(cacheKey);
529
+ const entry = cached && cached.revision === document.revision
530
+ ? cached
531
+ : { revision: document.revision, withHelpers: null, withoutHelpers: null };
532
+ entry[cacheSlot] = program;
533
+ PROGRAM_CACHE.set(cacheKey, entry);
534
+ }
535
+ return program;
536
+ }
537
+
538
+ /** Evaluates the document (or a precompiled program) at one point. */
539
+ export function evaluateField(fieldProgramOrDocument, x, y, z) {
540
+ const program = typeof fieldProgramOrDocument.evaluate === 'function'
541
+ ? fieldProgramOrDocument
542
+ : compileDocument(fieldProgramOrDocument);
543
+ return program.evaluate(x, y, z);
544
+ }
@@ -0,0 +1,66 @@
1
+ // Sculpt edits: brush strokes stored in the document as sphere/capsule SDF
2
+ // operations, applied after the piece fold. Keeping strokes as data (rather
3
+ // than baked vertex offsets) makes undo a list pop, keeps save/load free,
4
+ // and means export-resolution re-meshing includes sculpting with no bake.
5
+
6
+ import { opSmoothSubtract, opSmoothUnion } from './sdfOps.js';
7
+
8
+ /** Distance to a sculpt edit's brush volume (sphere, or capsule segment). */
9
+ export function evaluateSculptEdit(edit, x, y, z) {
10
+ const [cx, cy, cz] = edit.center;
11
+ let px = x - cx;
12
+ let py = y - cy;
13
+ let pz = z - cz;
14
+ if (edit.shape === 'capsule' && edit.end) {
15
+ const bx = edit.end[0] - cx;
16
+ const by = edit.end[1] - cy;
17
+ const bz = edit.end[2] - cz;
18
+ const lengthSq = bx * bx + by * by + bz * bz;
19
+ if (lengthSq > 0) {
20
+ const t = Math.min(Math.max((px * bx + py * by + pz * bz) / lengthSq, 0), 1);
21
+ px -= bx * t;
22
+ py -= by * t;
23
+ pz -= bz * t;
24
+ }
25
+ }
26
+ return Math.sqrt(px * px + py * py + pz * pz) - edit.radius;
27
+ }
28
+
29
+ /** Applies one edit to the folded field value. */
30
+ export function applySculptEditToField(edit, d, editDistance) {
31
+ return edit.tool === 'subtract'
32
+ ? opSmoothSubtract(d, editDistance, edit.blend)
33
+ : opSmoothUnion(d, editDistance, edit.blend);
34
+ }
35
+
36
+ /** World-space AABB of an edit, padded by its blend radius. */
37
+ export function sculptEditBounds(edit) {
38
+ const pad = edit.radius + Math.max(edit.blend, 0);
39
+ const min = [edit.center[0] - pad, edit.center[1] - pad, edit.center[2] - pad];
40
+ const max = [edit.center[0] + pad, edit.center[1] + pad, edit.center[2] + pad];
41
+ if (edit.shape === 'capsule' && edit.end) {
42
+ for (let axis = 0; axis < 3; axis += 1) {
43
+ min[axis] = Math.min(min[axis], edit.end[axis] - pad);
44
+ max[axis] = Math.max(max[axis], edit.end[axis] + pad);
45
+ }
46
+ }
47
+ return { max, min };
48
+ }
49
+
50
+ /**
51
+ * Builds one capsule edit for a pointer-drag segment (from -> to). The lab
52
+ * emits one of these per stroke sample instead of a sphere per pointermove,
53
+ * keeping documents at ~10-30 edits per stroke. The id is assigned by
54
+ * applySculptEdit (rockDocument.js) so ids stay unique per document.
55
+ */
56
+ export function createStrokeEdit({ blend = 0, from, to = null, radius, tool = 'add' }) {
57
+ return {
58
+ blend: Math.max(Number(blend) || 0, 0),
59
+ center: [...from],
60
+ end: to ? [...to] : null,
61
+ id: null,
62
+ radius: Math.max(Number(radius) || 0.1, 0.001),
63
+ shape: to ? 'capsule' : 'sphere',
64
+ tool: tool === 'subtract' ? 'subtract' : 'add',
65
+ };
66
+ }
@@ -0,0 +1,45 @@
1
+ // Modifier math shared by the field compiler: displacement profiles plus the
2
+ // per-stage maximum-displacement report that fieldCompiler.js uses to pad
3
+ // piece bounds (the contract that keeps displaced implicit surfaces inside
4
+ // the sampled grid — see the Lipschitz note there).
5
+
6
+ /**
7
+ * Strata groove profile. `v` is in band units (1 = one stratum); returns
8
+ * [0, 1] with 1 in the groove carved at each band boundary. `sharpness`
9
+ * narrows the groove from broad terracing (0) to thin score lines (1).
10
+ */
11
+ export function strataProfile(v, sharpness) {
12
+ const t = v - Math.floor(v);
13
+ const q = Math.min(t, 1 - t) * 2; // 0 at band boundary, 1 mid-band
14
+ const width = 0.15 + (1 - sharpness) * 0.45;
15
+ const edge = q / width;
16
+ if (edge >= 1) return 0;
17
+ const u = 1 - edge;
18
+ return u * u * (3 - 2 * u);
19
+ }
20
+
21
+ /**
22
+ * Worst-case surface displacement (world units, before piece scale) that the
23
+ * enabled modifier stages can apply. fbm3/ridgedFbm3 are amplitude-sum
24
+ * normalized to [-1, 1], so each stage's bound is simply its strength.
25
+ * Planar cuts contribute nothing: they intersect (remove volume only), so
26
+ * the surface can only move inward.
27
+ */
28
+ export function maxPieceDisplacement(piece) {
29
+ let total = 0;
30
+ if (piece.noise?.enabled) total += Math.abs(piece.noise.amplitude);
31
+ if (piece.facet?.enabled) total += Math.abs(piece.facet.strength);
32
+ if (piece.cracks?.enabled) total += Math.abs(piece.cracks.depth);
33
+ if (piece.strata?.enabled) total += Math.abs(piece.strata.strength);
34
+ // Column height steps lift the surface by up to half the variation.
35
+ if (piece.columns?.enabled) total += Math.abs(piece.columns.heightVariation) * 0.5;
36
+ return total;
37
+ }
38
+
39
+ /**
40
+ * Domain warp moves sample positions rather than distances, so it pads
41
+ * bounds by its full strength instead of contributing to displacement.
42
+ */
43
+ export function warpBoundsPad(piece) {
44
+ return piece.warp?.enabled ? Math.abs(piece.warp.strength) : 0;
45
+ }
@@ -0,0 +1,37 @@
1
+ // CSG combine operators for SDF values. The smooth variants use Quilez's
2
+ // polynomial smooth-min; `k` is the blend radius in world units (k <= 0
3
+ // degenerates to the hard operator).
4
+
5
+ export function opUnion(a, b) {
6
+ return Math.min(a, b);
7
+ }
8
+
9
+ export function opIntersect(a, b) {
10
+ return Math.max(a, b);
11
+ }
12
+
13
+ export function opSubtract(a, b) {
14
+ return Math.max(a, -b);
15
+ }
16
+
17
+ export function opSmoothUnion(a, b, k) {
18
+ if (k <= 0) return Math.min(a, b);
19
+ const h = Math.min(Math.max(0.5 + (0.5 * (b - a)) / k, 0), 1);
20
+ return b + (a - b) * h - k * h * (1 - h);
21
+ }
22
+
23
+ export function opSmoothIntersect(a, b, k) {
24
+ return -opSmoothUnion(-a, -b, k);
25
+ }
26
+
27
+ export function opSmoothSubtract(a, b, k) {
28
+ return -opSmoothUnion(-a, b, k);
29
+ }
30
+
31
+ /** Applies a named combine op ('union'|'smoothUnion'|'subtract'|'intersect'). */
32
+ export function combine(op, a, b, blend) {
33
+ if (op === 'smoothUnion') return opSmoothUnion(a, b, blend);
34
+ if (op === 'subtract') return blend > 0 ? opSmoothSubtract(a, b, blend) : opSubtract(a, b);
35
+ if (op === 'intersect') return blend > 0 ? opSmoothIntersect(a, b, blend) : opIntersect(a, b);
36
+ return opUnion(a, b);
37
+ }