@call-me-sensei/toonlab 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -0,0 +1,323 @@
1
+ // The PropAsset contract — the ONE shape every placeable thing reduces to,
2
+ // procedural or imported. This is the strategic seam of the library: the
3
+ // placement pipeline (propPlacement.js), buildings, villages, the catalog,
4
+ // and the pro asset faucet all traffic exclusively in PropAssets.
5
+ //
6
+ // asset.build(seed?) → {
7
+ // object3D, // origin at ground contact, +y up
8
+ // footprint, // { radius } | { circles: [{x, z, radius}] } — feeds
9
+ // // world.collision.addCircles verbatim (local frame)
10
+ // anchor, // ground-contact offset: final y = heightAt(x, z) + anchor
11
+ // lod, // optional { far: object3D, distance }
12
+ // }
13
+ //
14
+ // Linear assets (fences, walls) additionally implement
15
+ // `buildAlong(points3D)` — they are generated continuously along a line,
16
+ // not instanced — and placeAlongSpline dispatches on that automatically.
17
+
18
+ import * as THREE from 'three';
19
+
20
+ import { getDebrisDetailTexture } from '../debrisgen/debrisTextures.js';
21
+ import { createPropRandom } from './propParts.js';
22
+ import {
23
+ PROP_TYPES,
24
+ createPropSettings,
25
+ validatePropRecipeDocument,
26
+ } from './propSettings.js';
27
+ import {
28
+ generateFenceAlong,
29
+ generateLantern,
30
+ generateMilestone,
31
+ generateSignpost,
32
+ generateStoneStairs,
33
+ } from './generatorsWave1.js';
34
+ import {
35
+ generateBench,
36
+ generateCrateStack,
37
+ generateFirewood,
38
+ generatePier,
39
+ generateStoneWallAlong,
40
+ generateTorii,
41
+ generateWell,
42
+ } from './generatorsWave2.js';
43
+
44
+ const POINT_GENERATORS = {
45
+ bench: generateBench,
46
+ crateStack: generateCrateStack,
47
+ firewood: generateFirewood,
48
+ lantern: generateLantern,
49
+ milestone: generateMilestone,
50
+ pier: generatePier,
51
+ signpost: generateSignpost,
52
+ stoneStairs: generateStoneStairs,
53
+ torii: generateTorii,
54
+ well: generateWell,
55
+ };
56
+
57
+ const LINEAR_GENERATORS = {
58
+ fence: generateFenceAlong,
59
+ stoneWall: generateStoneWallAlong,
60
+ };
61
+
62
+ // Which procedural detail map suits each type (multiplies vertex colors,
63
+ // same texture cache debrisgen uses — no new texture systems).
64
+ const TEXTURE_FAMILY = {
65
+ bench: { kind: 'wood', style: 'grain' },
66
+ crateStack: { kind: 'wood', style: 'grain' },
67
+ fence: { kind: 'wood', style: 'grain' },
68
+ firewood: { kind: 'wood', style: 'bark' },
69
+ lantern: { kind: 'stone', style: 'speckle' },
70
+ milestone: { kind: 'stone', style: 'speckle' },
71
+ pier: { kind: 'wood', style: 'grain' },
72
+ signpost: { kind: 'wood', style: 'grain' },
73
+ stoneStairs: { kind: 'stone', style: 'speckle' },
74
+ stoneWall: { kind: 'stone', style: 'speckle' },
75
+ torii: { kind: 'wood', style: 'grain' },
76
+ well: { kind: 'stone', style: 'speckle' },
77
+ };
78
+
79
+ // LOD swap distance per type: bigger silhouettes hold detail longer.
80
+ const LOD_DISTANCE = {
81
+ bench: 40,
82
+ crateStack: 50,
83
+ fence: 70,
84
+ firewood: 45,
85
+ lantern: 55,
86
+ milestone: 45,
87
+ pier: 90,
88
+ signpost: 50,
89
+ stoneStairs: 60,
90
+ stoneWall: 70,
91
+ torii: 120,
92
+ well: 80,
93
+ };
94
+
95
+ function createPropMaterials(settings) {
96
+ const family = TEXTURE_FAMILY[settings.asset.type] ?? { kind: 'stone', style: 'speckle' };
97
+ const main = new THREE.MeshStandardMaterial({
98
+ map: getDebrisDetailTexture(family.kind, settings.asset.seed, family.style, 1),
99
+ metalness: 0,
100
+ roughness: settings.surface.roughness,
101
+ vertexColors: true,
102
+ });
103
+ main.name = `Prop ${settings.asset.type}`;
104
+ main.userData.envRole = 'standard';
105
+
106
+ // Warm unlit lamp bodies: emissive carries the glow through the toon
107
+ // conversion and into GLB exports.
108
+ const glow = new THREE.MeshStandardMaterial({
109
+ emissive: new THREE.Color(1, 0.72, 0.32),
110
+ emissiveIntensity: 0.85,
111
+ metalness: 0,
112
+ roughness: 0.6,
113
+ vertexColors: true,
114
+ });
115
+ glow.name = `Prop ${settings.asset.type} glow`;
116
+ glow.userData.envRole = 'emissive';
117
+ return { glow, main };
118
+ }
119
+
120
+ function assembleGroup(settings, result, materials, detail) {
121
+ const group = new THREE.Group();
122
+ group.name = `Prop ${settings.asset.type} ${settings.asset.variant}${detail === 'lo' ? ' (lo)' : ''}`;
123
+ if (result.main) {
124
+ const mesh = new THREE.Mesh(result.main, materials.main);
125
+ mesh.name = 'PropBody';
126
+ mesh.castShadow = true;
127
+ mesh.receiveShadow = true;
128
+ group.add(mesh);
129
+ }
130
+ if (result.glow) {
131
+ const mesh = new THREE.Mesh(result.glow, materials.glow);
132
+ mesh.name = 'PropGlow';
133
+ mesh.castShadow = false; // lamp cores are light sources, not occluders
134
+ mesh.receiveShadow = false;
135
+ group.add(mesh);
136
+ }
137
+ if (settings.asset.scale !== 1) group.scale.setScalar(settings.asset.scale);
138
+ return group;
139
+ }
140
+
141
+ function scaleFootprint(footprint, scale) {
142
+ if (!footprint || scale === 1) return footprint;
143
+ if (footprint.circles) {
144
+ return {
145
+ circles: footprint.circles.map((circle) => ({
146
+ radius: circle.radius * scale,
147
+ x: circle.x * scale,
148
+ z: circle.z * scale,
149
+ })),
150
+ };
151
+ }
152
+ return { radius: (footprint.radius ?? 0.3) * scale };
153
+ }
154
+
155
+ /**
156
+ * Builds one prop object (no asset wrapper) — the direct construction path
157
+ * labs and quick scripts use. Returns `{ object3D, footprint, anchor, stats }`.
158
+ */
159
+ export function buildProp(settingsInput = {}, { detail = 'hi' } = {}) {
160
+ const settings = createPropSettings(settingsInput);
161
+ const type = settings.asset.type;
162
+ const generator = POINT_GENERATORS[type];
163
+ if (!generator) {
164
+ throw new Error(`Prop type "${type}" is linear — use buildPropAlong / placeAlongSpline.`);
165
+ }
166
+ const random = createPropRandom(settings.asset.seed);
167
+ const result = generator(settings, random, detail);
168
+ const materials = createPropMaterials(settings);
169
+ const object3D = assembleGroup(settings, result, materials, detail);
170
+ object3D.userData.propRecipe = settings;
171
+ let triangles = 0;
172
+ object3D.traverse((object) => {
173
+ if (object.isMesh && object.geometry.index) triangles += object.geometry.index.count / 3;
174
+ });
175
+ return {
176
+ anchor: 0,
177
+ footprint: scaleFootprint(result.footprint, settings.asset.scale),
178
+ object3D,
179
+ stats: { triangles: Math.round(triangles) },
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Builds a linear prop run along resolved world-space points
185
+ * `[{x, y, z}, …]`. Returns `{ object3D, footprints }` — footprints are
186
+ * world-frame circles ready for `collision.addCircles`.
187
+ */
188
+ export function buildPropAlong(settingsInput, points, { detail = 'hi' } = {}) {
189
+ const settings = createPropSettings(settingsInput);
190
+ const type = settings.asset.type;
191
+ const generator = LINEAR_GENERATORS[type];
192
+ if (!generator) {
193
+ throw new Error(`Prop type "${type}" is not linear — use buildProp / placeProps.`);
194
+ }
195
+ const random = createPropRandom(settings.asset.seed);
196
+ const result = generator(settings, random, points, detail);
197
+ const materials = createPropMaterials(settings);
198
+ const object3D = assembleGroup(settings, result, materials, detail);
199
+ object3D.userData.propRecipe = settings;
200
+ return { footprints: result.footprints, object3D };
201
+ }
202
+
203
+ /**
204
+ * Wraps generator settings into the PropAsset contract. `build(seed)`
205
+ * overrides only the seed (variants of one design); pass full settings to
206
+ * `createPropAsset` for anything more.
207
+ */
208
+ export function createPropAsset(settingsInput = {}) {
209
+ const settings = createPropSettings(settingsInput);
210
+ const type = settings.asset.type;
211
+ const linear = Boolean(PROP_TYPES[type]?.linear);
212
+ const asset = {
213
+ linear,
214
+ settings,
215
+ type,
216
+ variant: settings.asset.variant,
217
+ };
218
+ if (linear) {
219
+ asset.buildAlong = (points, options = {}) => buildPropAlong(
220
+ { ...settings, asset: { ...settings.asset, seed: options.seed ?? settings.asset.seed } },
221
+ points,
222
+ options,
223
+ );
224
+ // Linear assets still expose a build() so thumbnails/labs can preview a
225
+ // short straight run without a spline.
226
+ asset.build = (seed = settings.asset.seed) => {
227
+ const points = [0, 1, 2].map((index) => ({ x: (index - 1) * 2.2, y: 0, z: 0 }));
228
+ const { object3D, footprints } = buildPropAlong(
229
+ { ...settings, asset: { ...settings.asset, seed } },
230
+ points,
231
+ );
232
+ return { anchor: 0, footprint: { circles: footprints }, object3D };
233
+ };
234
+ return asset;
235
+ }
236
+ asset.build = (seed = settings.asset.seed) => {
237
+ const buildSettings = { ...settings, asset: { ...settings.asset, seed } };
238
+ const hi = buildProp(buildSettings, { detail: 'hi' });
239
+ const lo = buildProp(buildSettings, { detail: 'lo' });
240
+ return {
241
+ anchor: hi.anchor,
242
+ footprint: hi.footprint,
243
+ lod: { distance: LOD_DISTANCE[type] ?? 55, far: lo.object3D },
244
+ object3D: hi.object3D,
245
+ stats: hi.stats,
246
+ };
247
+ };
248
+ return asset;
249
+ }
250
+
251
+ /**
252
+ * The OSS↔pro seam: wraps ANY Object3D (imported GLB, pro-generated, hand
253
+ * modeling) into the PropAsset contract so every pipeline downstream —
254
+ * scatter, spline placement, collision, catalog — takes it with zero extra
255
+ * work. Footprint and anchor are measured from the bounding box when not
256
+ * given.
257
+ */
258
+ export function propAssetFromObject(object3D, {
259
+ footprint = null,
260
+ anchor = null,
261
+ lod = null,
262
+ name = null,
263
+ } = {}) {
264
+ if (!object3D?.isObject3D) throw new Error('propAssetFromObject needs an Object3D.');
265
+ const box = new THREE.Box3().setFromObject(object3D);
266
+ const measuredAnchor = Number.isFinite(anchor) ? anchor : (box.isEmpty() ? 0 : -box.min.y);
267
+ let resolvedFootprint = footprint;
268
+ if (!resolvedFootprint) {
269
+ const sizeX = box.isEmpty() ? 0.6 : box.max.x - box.min.x;
270
+ const sizeZ = box.isEmpty() ? 0.6 : box.max.z - box.min.z;
271
+ resolvedFootprint = { radius: Math.max(Math.hypot(sizeX, sizeZ) * 0.32, 0.2) };
272
+ }
273
+ return {
274
+ build(/* seed ignored: imported meshes have one shape */) {
275
+ const clone = object3D.clone(true);
276
+ clone.traverse((child) => {
277
+ if (child.isMesh) {
278
+ child.castShadow = true;
279
+ child.receiveShadow = true;
280
+ }
281
+ });
282
+ return {
283
+ anchor: measuredAnchor,
284
+ footprint: resolvedFootprint,
285
+ lod: lod ?? null,
286
+ object3D: clone,
287
+ };
288
+ },
289
+ imported: true,
290
+ linear: false,
291
+ type: name ?? object3D.name ?? 'imported',
292
+ variant: 'imported',
293
+ };
294
+ }
295
+
296
+ /** Deterministic rebuild from a recipe document (throws on invalid input). */
297
+ export function createPropFromRecipe(recipe, options = {}) {
298
+ const { ok, errors } = validatePropRecipeDocument(recipe);
299
+ if (!ok) throw new Error(`Invalid prop recipe: ${errors.join(' ')}`);
300
+ const type = recipe.settings.asset.type;
301
+ if (PROP_TYPES[type]?.linear) return createPropAsset(recipe.settings).build();
302
+ return buildProp(recipe.settings, options);
303
+ }
304
+
305
+ /** Recipe → PropAsset (what `catalog.spawn` returns for prop entries). */
306
+ export function createPropAssetFromRecipe(recipe) {
307
+ const { ok, errors } = validatePropRecipeDocument(recipe);
308
+ if (!ok) throw new Error(`Invalid prop recipe: ${errors.join(' ')}`);
309
+ return createPropAsset(recipe.settings);
310
+ }
311
+
312
+ export function disposeProp(root) {
313
+ const materials = new Set();
314
+ root?.traverse((object) => {
315
+ if (!object.isMesh) return;
316
+ object.geometry?.dispose();
317
+ for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
318
+ if (material) materials.add(material);
319
+ }
320
+ });
321
+ // Detail textures are cache-owned (shared across props) — never disposed here.
322
+ for (const material of materials) material.dispose?.();
323
+ }
@@ -0,0 +1,170 @@
1
+ // Tiny geometry kit the prop generators share. Every part is a primitive
2
+ // (box / cylinder / lathe) transformed into place and vertex-painted from
3
+ // the settings palette; a generator's output is ONE merged geometry per
4
+ // material role (main + optional glow), which is what keeps a prop at 1–2
5
+ // draw calls and makes instanced placement trivial.
6
+
7
+ import * as THREE from 'three';
8
+
9
+ import { createDebrisRandom } from '../debrisgen/debrisGenerator.js';
10
+
11
+ const scratchMatrix = new THREE.Matrix4();
12
+ const scratchQuaternion = new THREE.Quaternion();
13
+ const scratchEuler = new THREE.Euler();
14
+ const scratchPosition = new THREE.Vector3();
15
+ const scratchScale = new THREE.Vector3();
16
+
17
+ export { createDebrisRandom as createPropRandom };
18
+
19
+ function paletteColor(surface, slot) {
20
+ const values = slot === 2 ? surface.accentColor
21
+ : slot === 1 ? surface.secondaryColor
22
+ : surface.primaryColor;
23
+ return values;
24
+ }
25
+
26
+ /**
27
+ * Collects transformed, painted primitives and merges them per role.
28
+ * Roles: 'main' (environment-shaded) and 'glow' (warm unlit lamp bodies).
29
+ *
30
+ * Two RNG streams: the generator's `random` drives STRUCTURE (placement
31
+ * jitter — must draw identically for hi and lo detail so LOD swaps don't
32
+ * rearrange the prop), while per-vertex paint drift uses `paintRandom`
33
+ * (vertex counts differ between LODs, so it must not share the stream).
34
+ */
35
+ export class PartsBuilder {
36
+ constructor(surface, random, paintRandom = null) {
37
+ this.surface = surface;
38
+ this.random = random;
39
+ // One structural draw seeds the paint stream — the same single draw on
40
+ // every detail level, so hi/lo geometry stays jitter-identical while
41
+ // per-vertex drift (whose call count differs by LOD) runs independently.
42
+ this.paintRandom = paintRandom ?? createDebrisRandom(Math.floor(random() * 0xffffffff));
43
+ this.parts = { glow: [], main: [] };
44
+ }
45
+
46
+ /**
47
+ * Adds a geometry. Options: position [x,y,z], rotation [rx,ry,rz] (YXZ),
48
+ * scale number|[x,y,z], slot 0|1|2 (palette), shade multiplier, role.
49
+ * The geometry is consumed (cloned by value into the merge lists).
50
+ */
51
+ add(geometry, {
52
+ position = [0, 0, 0],
53
+ rotation = [0, 0, 0],
54
+ scale = 1,
55
+ slot = 0,
56
+ shade = 1,
57
+ role = 'main',
58
+ flatShade = false,
59
+ } = {}) {
60
+ const scaleVec = Array.isArray(scale) ? scale : [scale, scale, scale];
61
+ scratchEuler.set(rotation[0], rotation[1], rotation[2], 'YXZ');
62
+ scratchQuaternion.setFromEuler(scratchEuler);
63
+ scratchPosition.set(position[0], position[1], position[2]);
64
+ scratchScale.set(scaleVec[0], scaleVec[1], scaleVec[2]);
65
+ scratchMatrix.compose(scratchPosition, scratchQuaternion, scratchScale);
66
+ geometry.applyMatrix4(scratchMatrix);
67
+ if (flatShade) geometry.computeVertexNormals();
68
+ this.paint(geometry, slot, shade);
69
+ this.parts[role in this.parts ? role : 'main'].push(geometry);
70
+ return this;
71
+ }
72
+
73
+ box(w, h, d, options = {}) {
74
+ return this.add(new THREE.BoxGeometry(w, h, d), options);
75
+ }
76
+
77
+ cylinder(radiusTop, radiusBottom, height, options = {}) {
78
+ const segments = options.segments ?? 10;
79
+ return this.add(
80
+ new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments),
81
+ options,
82
+ );
83
+ }
84
+
85
+ cone(radius, height, options = {}) {
86
+ const segments = options.segments ?? 10;
87
+ return this.add(new THREE.ConeGeometry(radius, height, segments), options);
88
+ }
89
+
90
+ sphere(radius, options = {}) {
91
+ const segments = options.segments ?? 8;
92
+ return this.add(new THREE.SphereGeometry(radius, segments, Math.max(6, segments - 2)), options);
93
+ }
94
+
95
+ /** Lathe from [ [radius, y], ... ] profile pairs (meters). */
96
+ lathe(profile, options = {}) {
97
+ const points = profile.map(([radius, y]) => new THREE.Vector2(radius, y));
98
+ const segments = options.segments ?? 10;
99
+ return this.add(new THREE.LatheGeometry(points, segments), options);
100
+ }
101
+
102
+ /** Baked vertex paint: palette slot × drift × upward edge light. */
103
+ paint(geometry, slot, shade) {
104
+ if (!geometry.attributes.normal) geometry.computeVertexNormals();
105
+ const { surface, paintRandom: random } = this;
106
+ const base = paletteColor(surface, slot);
107
+ const normal = geometry.attributes.normal;
108
+ const count = geometry.attributes.position.count;
109
+ const colors = new Float32Array(count * 3);
110
+ for (let index = 0; index < count; index += 1) {
111
+ const drift = 1 + (random() * 2 - 1) * surface.variation;
112
+ const upward = Math.max(0, normal.getY(index));
113
+ const edge = 1 + upward * surface.edgeLight * 0.3;
114
+ const value = drift * edge * shade;
115
+ colors[index * 3] = Math.min(base[0] * value, 1);
116
+ colors[index * 3 + 1] = Math.min(base[1] * value, 1);
117
+ colors[index * 3 + 2] = Math.min(base[2] * value, 1);
118
+ }
119
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
120
+ return geometry;
121
+ }
122
+
123
+ /** Merges collected parts: { main, glow } BufferGeometries (glow null when unused). */
124
+ build() {
125
+ return {
126
+ glow: mergePainted(this.parts.glow),
127
+ main: mergePainted(this.parts.main),
128
+ };
129
+ }
130
+ }
131
+
132
+ /** Position+normal+color+uv merge (uv optional per-part; missing uv fills 0). */
133
+ export function mergePainted(geometries) {
134
+ if (!geometries.length) return null;
135
+ const positions = [];
136
+ const normals = [];
137
+ const uvs = [];
138
+ const colors = [];
139
+ const indices = [];
140
+ let offset = 0;
141
+ for (const geometry of geometries) {
142
+ const position = geometry.attributes.position;
143
+ const normal = geometry.attributes.normal;
144
+ const uv = geometry.attributes.uv;
145
+ const color = geometry.attributes.color;
146
+ for (let index = 0; index < position.count; index += 1) {
147
+ positions.push(position.getX(index), position.getY(index), position.getZ(index));
148
+ normals.push(normal.getX(index), normal.getY(index), normal.getZ(index));
149
+ uvs.push(uv ? uv.getX(index) : 0, uv ? uv.getY(index) : 0);
150
+ if (color) colors.push(color.getX(index), color.getY(index), color.getZ(index));
151
+ else colors.push(1, 1, 1);
152
+ }
153
+ if (geometry.index) {
154
+ for (let index = 0; index < geometry.index.count; index += 1) {
155
+ indices.push(geometry.index.getX(index) + offset);
156
+ }
157
+ } else {
158
+ for (let index = 0; index < position.count; index += 1) indices.push(index + offset);
159
+ }
160
+ offset += position.count;
161
+ geometry.dispose();
162
+ }
163
+ const merged = new THREE.BufferGeometry();
164
+ merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
165
+ merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
166
+ merged.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
167
+ merged.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
168
+ merged.setIndex(indices);
169
+ return merged;
170
+ }