@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.
- package/AGENTS.md +127 -4
- package/ATTRIBUTION.md +41 -3
- package/README.md +234 -34
- package/docs/characters.md +144 -0
- package/docs/debug-panel.md +126 -0
- package/docs/docs.css +589 -0
- package/docs/environment.md +186 -0
- package/docs/getting-started.md +180 -0
- package/docs/index.html +14 -0
- package/docs/lab-architecture.md +100 -0
- package/docs/lighting.md +825 -0
- package/docs/main.jsx +739 -0
- package/docs/mcp.md +97 -0
- package/docs/post-processing.md +98 -0
- package/docs/settings-reference.md +1878 -0
- package/docs/shader-constants.md +77 -0
- package/docs/sky.md +182 -0
- package/docs/style-labs.md +309 -0
- package/docs/texture-lab.md +135 -0
- package/docs/toon-shading.md +183 -0
- package/docs/tsl-conventions.md +167 -0
- package/docs/vegetation-sky.md +275 -0
- package/docs/water.md +430 -0
- package/docs/weather.md +200 -0
- package/docs/world-scale.md +111 -0
- package/mcp/server.mjs +610 -0
- package/mcp/style-lab-tools.mjs +371 -0
- package/mcp/vite-plugin.mjs +174 -0
- package/mcp/workspace.mjs +397 -0
- package/package.json +64 -5
- package/src/ambientfx/INTEGRATION.md +164 -0
- package/src/ambientfx/ambientFxPresets.js +83 -0
- package/src/ambientfx/ambientFxSettings.js +368 -0
- package/src/ambientfx/emitters.js +169 -0
- package/src/ambientfx/index.js +5 -0
- package/src/ambientfx/particleBackbone.js +493 -0
- package/src/ambientfx/stylizedAmbientFx.js +450 -0
- package/src/assetlib/ambientcg.js +117 -0
- package/src/assetlib/assetRef.js +91 -0
- package/src/assetlib/importedEntry.js +40 -0
- package/src/assetlib/index.js +23 -0
- package/src/assetlib/kaykit.js +192 -0
- package/src/assetlib/kaykitStaticIndex.js +700 -0
- package/src/assetlib/loadImported.js +143 -0
- package/src/assetlib/opensource3d.js +113 -0
- package/src/assetlib/polyhaven.js +182 -0
- package/src/assetlib/polypizza.js +115 -0
- package/src/assetlib/smithsonian.js +214 -0
- package/src/assetlib/sources.js +279 -0
- package/src/assetlib/zip.js +58 -0
- package/src/biome/biomeGenerator.js +385 -0
- package/src/biome/biomeRuntime.js +299 -0
- package/src/biome/index.js +2 -0
- package/src/buildinggen/buildingAsset.js +44 -0
- package/src/buildinggen/buildingGrammar.js +311 -0
- package/src/buildinggen/buildingMesh.js +451 -0
- package/src/buildinggen/buildingPresets.js +46 -0
- package/src/buildinggen/buildingRecipe.js +100 -0
- package/src/buildinggen/buildingSettings.js +238 -0
- package/src/buildinggen/index.js +6 -0
- package/src/camera/cameraDirector.js +157 -0
- package/src/camera/cameraGenerator.js +367 -0
- package/src/camera/cameraRig.js +570 -0
- package/src/camera/cameraSettings.js +236 -0
- package/src/camera/index.js +7 -0
- package/src/catalog/builtinEntries.js +245 -0
- package/src/catalog/catalog.js +210 -0
- package/src/catalog/index.js +3 -0
- package/src/catalog/manifest.js +84 -0
- package/src/core/generation.js +529 -0
- package/src/environment/environmentRigs.js +21 -1
- package/src/environment/environmentSettings.js +4 -0
- package/src/environment/environmentSunShadowPass.js +8 -0
- package/src/fauna/INTEGRATION.md +174 -0
- package/src/fauna/boids.js +861 -0
- package/src/fauna/faunaBodies.js +492 -0
- package/src/fauna/faunaPresets.js +52 -0
- package/src/fauna/faunaSettings.js +525 -0
- package/src/fauna/index.js +5 -0
- package/src/fauna/stylizedFauna.js +395 -0
- package/src/game-feel/gameFeelGenerator.js +402 -0
- package/src/game-feel/gameFeelRuntime.js +549 -0
- package/src/game-feel/index.js +2 -0
- package/src/index.js +17 -6
- package/src/lighting/colorIntensity.js +177 -0
- package/src/lighting/index.js +161 -0
- package/src/lighting/lightDescriptors.js +249 -0
- package/src/lighting/lightingCapabilities.js +79 -0
- package/src/lighting/lightingDocuments.js +247 -0
- package/src/lighting/lightingFixtures.js +446 -0
- package/src/lighting/lightingGenerator.js +449 -0
- package/src/lighting/lightingPresets.js +319 -0
- package/src/lighting/lightingRuntime.js +723 -0
- package/src/lighting/lightingStyle.js +386 -0
- package/src/lighting/lightingSystem.js +774 -0
- package/src/lighting/unrealExport.js +186 -0
- package/src/lighting/utils.js +87 -0
- package/src/motion/index.js +5 -0
- package/src/motion/motionClip.js +441 -0
- package/src/motion/motionController.js +628 -0
- package/src/motion/motionDocuments.js +225 -0
- package/src/motion/motionGraph.js +307 -0
- package/src/motion/motionSettings.js +222 -0
- package/src/pathgen/index.js +7 -0
- package/src/pathgen/pathBridge.js +232 -0
- package/src/pathgen/pathPresets.js +35 -0
- package/src/pathgen/pathRibbon.js +410 -0
- package/src/pathgen/pathRouter.js +380 -0
- package/src/pathgen/pathSettings.js +335 -0
- package/src/pathgen/pathTextures.js +123 -0
- package/src/pathgen/stylizedPaths.js +453 -0
- package/src/post/index.js +1 -0
- package/src/post/postGenerator.js +177 -0
- package/src/post/postProcessing.js +41 -0
- package/src/propgen/generatorsWave1.js +379 -0
- package/src/propgen/generatorsWave2.js +462 -0
- package/src/propgen/index.js +5 -0
- package/src/propgen/propAsset.js +323 -0
- package/src/propgen/propParts.js +170 -0
- package/src/propgen/propPlacement.js +459 -0
- package/src/propgen/propPresets.js +82 -0
- package/src/propgen/propSettings.js +395 -0
- package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
- package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
- package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
- package/src/shaders-tsl/chunks/water-waves.js +90 -11
- package/src/shaders-tsl/environment.js +15 -1
- package/src/shaders-tsl/flower.js +279 -30
- package/src/shaders-tsl/grass.js +60 -33
- package/src/shaders-tsl/sky.js +125 -31
- package/src/shaders-tsl/tree-leaf.js +61 -25
- package/src/shaders-tsl/water-breaker.js +7 -4
- package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
- package/src/shaders-tsl/water.js +439 -49
- package/src/shaders-tsl/woody-surface.js +154 -0
- package/src/sky/sceneOverrideLayers.js +10 -0
- package/src/sky/skyQuality.js +26 -0
- package/src/sky/stylizedSky.js +753 -45
- package/src/soundscape/index.js +4 -0
- package/src/soundscape/soundscapeGenerator.js +179 -0
- package/src/soundscape/soundscapeRuntime.js +806 -0
- package/src/soundscape/soundscapeSettings.js +292 -0
- package/src/styles/index.js +13 -0
- package/src/styles/styleBundle.js +325 -0
- package/src/stylizedTerrain.js +32 -2
- package/src/stylizedWorld.js +423 -20
- package/src/texgen/evaluateTexture.js +675 -0
- package/src/texgen/index.js +60 -0
- package/src/texgen/noise2.js +210 -0
- package/src/texgen/textureAi.js +436 -0
- package/src/texgen/textureGenerators.js +516 -0
- package/src/texgen/texturePresets.js +490 -0
- package/src/texgen/textureSettings.js +342 -0
- package/src/texgen/textureThree.js +59 -0
- package/src/vegetation/flowerSpecies.js +15 -3
- package/src/vegetation/grassPalettes.js +153 -0
- package/src/vegetation/index.js +6 -0
- package/src/vegetation/stylizedBush.js +2 -0
- package/src/vegetation/stylizedFlower.js +82 -0
- package/src/vegetation/stylizedFlowers.js +48 -7
- package/src/vegetation/stylizedForest.js +29 -1
- package/src/vegetation/stylizedGrass.js +291 -56
- package/src/vegetation/stylizedTree.js +38 -2
- package/src/vegetation/stylizedTreeFoliage.js +2 -1
- package/src/vegetation/vegetationShaders.js +1110 -0
- package/src/vfxgen/INTEGRATION.md +145 -0
- package/src/vfxgen/core/burstBackbone.js +380 -0
- package/src/vfxgen/core/projectileCore.js +92 -0
- package/src/vfxgen/core/spriteShapes.js +98 -0
- package/src/vfxgen/core/trailRibbon.js +272 -0
- package/src/vfxgen/core/vfxRandom.js +29 -0
- package/src/vfxgen/effects/emitHelpers.js +37 -0
- package/src/vfxgen/effects/magicEffects.js +162 -0
- package/src/vfxgen/effects/movementEffects.js +87 -0
- package/src/vfxgen/effects/weaponEffects.js +118 -0
- package/src/vfxgen/index.js +18 -0
- package/src/vfxgen/moves/moveController.js +146 -0
- package/src/vfxgen/moves/moveLibrary.js +335 -0
- package/src/vfxgen/vfxPresets.js +98 -0
- package/src/vfxgen/vfxSettings.js +384 -0
- package/src/vfxgen/vfxSystem.js +449 -0
- package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
- package/src/villagegen/index.js +4 -0
- package/src/villagegen/stylizedVillage.js +490 -0
- package/src/villagegen/villageArchetypes.js +160 -0
- package/src/villagegen/villageNames.js +40 -0
- package/src/villagegen/villageSites.js +105 -0
- package/src/water/sceneOverrideLayers.js +23 -0
- package/src/water/water.js +5 -0
- package/src/water/waterBreakerSystem.js +15 -1
- package/src/water/waterCurrentField.js +447 -0
- package/src/water/waterMaterial.js +12 -0
- package/src/water/waterNearshorePhase.js +320 -0
- package/src/water/waterScenePasses.js +83 -30
- package/src/water/waterSettings.js +325 -13
- package/src/water/waterShoreMaterial.js +322 -0
- package/src/water/waterShoreStateField.js +605 -0
- package/src/water/waterSurface.js +797 -28
- package/src/weather/index.js +6 -0
- package/src/weather/weatherPrecipitation.js +221 -0
- package/src/weather/weatherPresets.js +258 -0
- package/src/weather/weatherSettings.js +269 -0
- package/src/weather/weatherSystem.js +871 -0
- package/src/worldMinimap.js +62 -0
- package/src/worldPresets.js +4 -1
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// The universal placement pipeline: any PropAsset into a world, correctly —
|
|
2
|
+
// grounded via heightAt + anchor, instanced per variant, hi/lo LOD swapped
|
|
3
|
+
// by TRUE 3D camera distance, collision circles registered, shadow flags
|
|
4
|
+
// set. Extends the scatter.js discipline (seeded, deterministic,
|
|
5
|
+
// heightAt-driven) rather than replacing it.
|
|
6
|
+
|
|
7
|
+
import * as THREE from 'three';
|
|
8
|
+
|
|
9
|
+
function mulberry32(seed) {
|
|
10
|
+
let state = (Math.trunc(seed) || 1) >>> 0;
|
|
11
|
+
return () => {
|
|
12
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
13
|
+
let t = state;
|
|
14
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
15
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
16
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ZERO_SCALE = new THREE.Matrix4().makeScale(0, 0, 0);
|
|
21
|
+
const scratchMatrix = new THREE.Matrix4();
|
|
22
|
+
const scratchPosition = new THREE.Vector3();
|
|
23
|
+
const scratchQuaternion = new THREE.Quaternion();
|
|
24
|
+
const scratchScale = new THREE.Vector3();
|
|
25
|
+
const scratchEuler = new THREE.Euler();
|
|
26
|
+
const scratchNormal = new THREE.Vector3();
|
|
27
|
+
const UP = new THREE.Vector3(0, 1, 0);
|
|
28
|
+
|
|
29
|
+
// Bake a built prop group into flat [{ geometry, material }] with mesh
|
|
30
|
+
// transforms applied — what instancing needs. Geometries are consumed
|
|
31
|
+
// (each asset.build() returns fresh ones).
|
|
32
|
+
function bakeSources(object3D) {
|
|
33
|
+
object3D.updateWorldMatrix(true, true);
|
|
34
|
+
const sources = [];
|
|
35
|
+
object3D.traverse((child) => {
|
|
36
|
+
if (!child.isMesh) return;
|
|
37
|
+
const geometry = child.geometry;
|
|
38
|
+
if (!child.matrixWorld.equals(scratchMatrix.identity())) {
|
|
39
|
+
geometry.applyMatrix4(child.matrixWorld);
|
|
40
|
+
}
|
|
41
|
+
sources.push({
|
|
42
|
+
castShadow: child.castShadow,
|
|
43
|
+
geometry,
|
|
44
|
+
material: child.material,
|
|
45
|
+
receiveShadow: child.receiveShadow,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
return sources;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function slopeQuaternion(heightAt, x, z, maxTilt, out) {
|
|
52
|
+
if (typeof heightAt !== 'function' || maxTilt <= 0) return out.identity();
|
|
53
|
+
const step = 0.6;
|
|
54
|
+
const dx = (heightAt(x + step, z) - heightAt(x - step, z)) / (2 * step);
|
|
55
|
+
const dz = (heightAt(x, z + step) - heightAt(x, z - step)) / (2 * step);
|
|
56
|
+
scratchNormal.set(-dx, 1, -dz).normalize();
|
|
57
|
+
out.setFromUnitVectors(UP, scratchNormal);
|
|
58
|
+
// Clamp the tilt so props never lie flat on cliffs.
|
|
59
|
+
const angle = 2 * Math.acos(Math.min(Math.abs(out.w), 1));
|
|
60
|
+
if (angle > maxTilt) out.slerp(scratchQuaternion.identity(), 1 - maxTilt / angle);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Instanced renderer for one PropAsset over many placements. Variants are
|
|
66
|
+
* distinct seeded builds; hi/lo pools swap on an interval by true 3D camera
|
|
67
|
+
* distance (aerial cameras demote everything, per AGENTS.md).
|
|
68
|
+
*/
|
|
69
|
+
export class PropInstances extends THREE.Group {
|
|
70
|
+
constructor({
|
|
71
|
+
asset,
|
|
72
|
+
placements = [],
|
|
73
|
+
variants = 4,
|
|
74
|
+
updateInterval = 0.4,
|
|
75
|
+
seed = 1,
|
|
76
|
+
} = {}) {
|
|
77
|
+
super();
|
|
78
|
+
this.name = `PropInstances ${asset?.type ?? 'asset'}`;
|
|
79
|
+
this.placements = placements;
|
|
80
|
+
this.updateInterval = updateInterval;
|
|
81
|
+
this._timer = updateInterval;
|
|
82
|
+
this._camera = null;
|
|
83
|
+
|
|
84
|
+
const variantCount = Math.max(1, Math.min(variants, Math.max(placements.length, 1)));
|
|
85
|
+
const random = mulberry32(seed * 2246822519 + 3);
|
|
86
|
+
this._builds = [];
|
|
87
|
+
for (let index = 0; index < variantCount; index += 1) {
|
|
88
|
+
const variantSeed = Math.floor(random() * 0xffffffff);
|
|
89
|
+
const built = asset.build(variantSeed);
|
|
90
|
+
this._builds.push({
|
|
91
|
+
anchor: built.anchor ?? 0,
|
|
92
|
+
footprint: built.footprint ?? { radius: 0.3 },
|
|
93
|
+
hi: bakeSources(built.object3D),
|
|
94
|
+
lo: built.lod?.far ? bakeSources(built.lod.far) : null,
|
|
95
|
+
lodDistance: built.lod?.distance ?? 55,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Assign placements to variants and precompose matrices.
|
|
100
|
+
this._entries = placements.map((placement, index) => {
|
|
101
|
+
const variant = (placement.seed ?? index) % variantCount;
|
|
102
|
+
const build = this._builds[variant];
|
|
103
|
+
const yaw = placement.yaw ?? 0;
|
|
104
|
+
const scale = placement.scale ?? 1;
|
|
105
|
+
scratchEuler.set(0, yaw, 0, 'YXZ');
|
|
106
|
+
scratchQuaternion.setFromEuler(scratchEuler);
|
|
107
|
+
if (placement.tiltQuaternion) scratchQuaternion.premultiply(placement.tiltQuaternion);
|
|
108
|
+
scratchPosition.set(
|
|
109
|
+
placement.x,
|
|
110
|
+
(placement.y ?? 0) + build.anchor * scale,
|
|
111
|
+
placement.z,
|
|
112
|
+
);
|
|
113
|
+
scratchScale.setScalar(scale);
|
|
114
|
+
return {
|
|
115
|
+
matrix: new THREE.Matrix4().compose(scratchPosition, scratchQuaternion, scratchScale),
|
|
116
|
+
variant,
|
|
117
|
+
x: placement.x,
|
|
118
|
+
y: placement.y ?? 0,
|
|
119
|
+
z: placement.z,
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// One InstancedMesh per (variant, source, lod-level).
|
|
124
|
+
this._pools = [];
|
|
125
|
+
for (let variant = 0; variant < this._builds.length; variant += 1) {
|
|
126
|
+
const entries = this._entries.filter((entry) => entry.variant === variant);
|
|
127
|
+
const build = this._builds[variant];
|
|
128
|
+
const makePool = (sources, level) => sources.map((source) => {
|
|
129
|
+
const instanced = new THREE.InstancedMesh(
|
|
130
|
+
source.geometry,
|
|
131
|
+
source.material,
|
|
132
|
+
Math.max(entries.length, 1),
|
|
133
|
+
);
|
|
134
|
+
instanced.name = `${this.name}-${variant}-${level}`;
|
|
135
|
+
instanced.castShadow = source.castShadow;
|
|
136
|
+
instanced.receiveShadow = source.receiveShadow;
|
|
137
|
+
// Above-water dressing: skip the refraction grab (the expensive
|
|
138
|
+
// half of the water cost at prop counts) but keep the reflection —
|
|
139
|
+
// shoreline lanterns SHOULD mirror in the lake.
|
|
140
|
+
instanced.userData.waterGrabExclude = true;
|
|
141
|
+
instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
142
|
+
for (let index = 0; index < Math.max(entries.length, 1); index += 1) {
|
|
143
|
+
instanced.setMatrixAt(index, ZERO_SCALE);
|
|
144
|
+
}
|
|
145
|
+
instanced.count = Math.max(entries.length, 1);
|
|
146
|
+
instanced.frustumCulled = false; // instances span arbitrary area; bounds go stale on swaps
|
|
147
|
+
this.add(instanced);
|
|
148
|
+
return instanced;
|
|
149
|
+
});
|
|
150
|
+
this._pools.push({
|
|
151
|
+
entries,
|
|
152
|
+
hi: makePool(build.hi, 'hi'),
|
|
153
|
+
lo: build.lo ? makePool(build.lo, 'lo') : null,
|
|
154
|
+
lodDistanceSq: build.lodDistance ** 2,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
this._assign(null); // everything hi until a camera shows up
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_assign(camera) {
|
|
161
|
+
for (const pool of this._pools) {
|
|
162
|
+
for (let index = 0; index < pool.entries.length; index += 1) {
|
|
163
|
+
const entry = pool.entries[index];
|
|
164
|
+
let useHi = true;
|
|
165
|
+
if (camera && pool.lo) {
|
|
166
|
+
const dx = entry.x - camera.position.x;
|
|
167
|
+
const dy = entry.y - camera.position.y;
|
|
168
|
+
const dz = entry.z - camera.position.z;
|
|
169
|
+
useHi = dx * dx + dy * dy + dz * dz < pool.lodDistanceSq;
|
|
170
|
+
}
|
|
171
|
+
for (const mesh of pool.hi) mesh.setMatrixAt(index, useHi ? entry.matrix : ZERO_SCALE);
|
|
172
|
+
if (pool.lo) {
|
|
173
|
+
for (const mesh of pool.lo) mesh.setMatrixAt(index, useHi ? ZERO_SCALE : entry.matrix);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const mesh of pool.hi) mesh.instanceMatrix.needsUpdate = true;
|
|
177
|
+
if (pool.lo) for (const mesh of pool.lo) mesh.instanceMatrix.needsUpdate = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Call per frame (cheap: reassigns on an interval, not every frame). */
|
|
182
|
+
update(delta = 0.016, camera = null) {
|
|
183
|
+
if (camera) this._camera = camera;
|
|
184
|
+
this._timer += delta;
|
|
185
|
+
if (this._timer < this.updateInterval) return;
|
|
186
|
+
this._timer = 0;
|
|
187
|
+
this._assign(this._camera);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** World-frame collision circles for every placement. */
|
|
191
|
+
footprintCircles() {
|
|
192
|
+
const circles = [];
|
|
193
|
+
for (const pool of this._pools) {
|
|
194
|
+
const build = this._builds[this._pools.indexOf(pool)];
|
|
195
|
+
for (const entry of pool.entries) {
|
|
196
|
+
const footprint = build.footprint;
|
|
197
|
+
if (footprint.circles) {
|
|
198
|
+
const position = new THREE.Vector3();
|
|
199
|
+
const quaternion = new THREE.Quaternion();
|
|
200
|
+
const scale = new THREE.Vector3();
|
|
201
|
+
entry.matrix.decompose(position, quaternion, scale);
|
|
202
|
+
for (const circle of footprint.circles) {
|
|
203
|
+
scratchPosition.set(circle.x, 0, circle.z).applyQuaternion(quaternion).multiply(scale);
|
|
204
|
+
circles.push({
|
|
205
|
+
radius: circle.radius * scale.x,
|
|
206
|
+
x: position.x + scratchPosition.x,
|
|
207
|
+
z: position.z + scratchPosition.z,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
const scale = entry.matrix.getMaxScaleOnAxis();
|
|
212
|
+
circles.push({ radius: (footprint.radius ?? 0.3) * scale, x: entry.x, z: entry.z });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return circles;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
dispose() {
|
|
220
|
+
this.parent?.remove(this);
|
|
221
|
+
for (const pool of this._pools) {
|
|
222
|
+
for (const mesh of [...pool.hi, ...(pool.lo ?? [])]) {
|
|
223
|
+
mesh.geometry.dispose();
|
|
224
|
+
mesh.dispose();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function resolvePlacement(position, heightAt, random, { yawMode, scaleRange, alignToSlope }) {
|
|
231
|
+
const x = Number(position.x ?? position[0]) || 0;
|
|
232
|
+
const z = Number(position.z ?? position[1]) || 0;
|
|
233
|
+
const y = typeof heightAt === 'function' ? (Number(heightAt(x, z)) || 0) : Number(position.y) || 0;
|
|
234
|
+
const yaw = position.yaw ?? (
|
|
235
|
+
yawMode === 'random' ? random() * Math.PI * 2
|
|
236
|
+
: Number(yawMode) || 0
|
|
237
|
+
);
|
|
238
|
+
const scale = scaleRange
|
|
239
|
+
? scaleRange[0] + (scaleRange[1] - scaleRange[0]) * random()
|
|
240
|
+
: 1;
|
|
241
|
+
const placement = { scale, seed: Math.floor(random() * 0xffffffff), x, y, yaw, z };
|
|
242
|
+
if (alignToSlope) {
|
|
243
|
+
placement.tiltQuaternion = slopeQuaternion(
|
|
244
|
+
heightAt, x, z,
|
|
245
|
+
Number(alignToSlope) === 1 ? 0.35 : Number(alignToSlope) || 0.35,
|
|
246
|
+
new THREE.Quaternion(),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return placement;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Explicit placement: `positions` are `{x, z, yaw?}` (or `[x, z]`) — y comes
|
|
254
|
+
* from `heightAt`. Registers collision, parents the instances, returns
|
|
255
|
+
* `{ root, placements, blockers, update, dispose }`.
|
|
256
|
+
*/
|
|
257
|
+
export function placeProps({
|
|
258
|
+
asset,
|
|
259
|
+
positions = [],
|
|
260
|
+
heightAt = null,
|
|
261
|
+
collision = null,
|
|
262
|
+
parent = null,
|
|
263
|
+
seed = 1,
|
|
264
|
+
variants = 4,
|
|
265
|
+
yaw = 'random',
|
|
266
|
+
scaleRange = null,
|
|
267
|
+
alignToSlope = false,
|
|
268
|
+
} = {}) {
|
|
269
|
+
if (!asset?.build) throw new Error('placeProps needs a PropAsset ({ build }).');
|
|
270
|
+
const random = mulberry32(seed * 340573321 + 7);
|
|
271
|
+
const placements = positions.map((position) => resolvePlacement(position, heightAt, random, {
|
|
272
|
+
alignToSlope,
|
|
273
|
+
scaleRange,
|
|
274
|
+
yawMode: yaw,
|
|
275
|
+
}));
|
|
276
|
+
const instances = new PropInstances({ asset, placements, seed, variants });
|
|
277
|
+
const blockers = instances.footprintCircles();
|
|
278
|
+
collision?.addCircles?.(blockers);
|
|
279
|
+
parent?.add?.(instances);
|
|
280
|
+
return {
|
|
281
|
+
blockers,
|
|
282
|
+
dispose: () => instances.dispose(),
|
|
283
|
+
placements,
|
|
284
|
+
root: instances,
|
|
285
|
+
update: (delta, camera) => instances.update(delta, camera),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Density scatter in a disc — `scatterForest` for props. Same masks, same
|
|
291
|
+
* determinism; adds min spacing and per-placement yaw/scale jitter.
|
|
292
|
+
*/
|
|
293
|
+
export function scatterProps({
|
|
294
|
+
asset,
|
|
295
|
+
center = { x: 0, z: 0 },
|
|
296
|
+
radius = 30,
|
|
297
|
+
density = 0.02,
|
|
298
|
+
count = null,
|
|
299
|
+
minSpacing = 2,
|
|
300
|
+
seed = 1,
|
|
301
|
+
heightAt = null,
|
|
302
|
+
mask = null,
|
|
303
|
+
collision = null,
|
|
304
|
+
parent = null,
|
|
305
|
+
variants = 4,
|
|
306
|
+
scaleRange = [0.9, 1.1],
|
|
307
|
+
alignToSlope = false,
|
|
308
|
+
} = {}) {
|
|
309
|
+
const origin = center?.position ?? center ?? { x: 0, z: 0 };
|
|
310
|
+
const random = mulberry32(seed * 1181783497 + 5);
|
|
311
|
+
const target = count ?? Math.round((Number(density) || 0) * Math.PI * radius * radius);
|
|
312
|
+
const spacingSq = Math.max(minSpacing, 0) ** 2;
|
|
313
|
+
const accepted = [];
|
|
314
|
+
let attempts = target * 8;
|
|
315
|
+
while (accepted.length < target && attempts > 0) {
|
|
316
|
+
attempts -= 1;
|
|
317
|
+
const distance = radius * Math.sqrt(random());
|
|
318
|
+
const angle = random() * Math.PI * 2;
|
|
319
|
+
const x = (Number(origin.x) || 0) + Math.cos(angle) * distance;
|
|
320
|
+
const z = (Number(origin.z) || 0) + Math.sin(angle) * distance;
|
|
321
|
+
if (typeof mask === 'function' && !mask(x, z)) continue;
|
|
322
|
+
if (spacingSq > 0 && accepted.some((p) => (p.x - x) ** 2 + (p.z - z) ** 2 < spacingSq)) continue;
|
|
323
|
+
accepted.push({ x, z });
|
|
324
|
+
}
|
|
325
|
+
return placeProps({
|
|
326
|
+
alignToSlope,
|
|
327
|
+
asset,
|
|
328
|
+
collision,
|
|
329
|
+
heightAt,
|
|
330
|
+
parent,
|
|
331
|
+
positions: accepted,
|
|
332
|
+
scaleRange,
|
|
333
|
+
seed,
|
|
334
|
+
variants,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Placement along a spline (01's `paths.splines[n]`, or any THREE curve):
|
|
340
|
+
* fences and walls build continuously; point props (lanterns, signposts)
|
|
341
|
+
* repeat at `spacing` with optional perpendicular offset per side.
|
|
342
|
+
*
|
|
343
|
+
* placeAlongSpline({ asset: fence, spline: paths.splines[0], offset: 2.4,
|
|
344
|
+
* heightAt: paths.heightAt, collision: world.collision, parent });
|
|
345
|
+
*/
|
|
346
|
+
export function placeAlongSpline({
|
|
347
|
+
asset,
|
|
348
|
+
spline,
|
|
349
|
+
spacing = 6,
|
|
350
|
+
jitter = 0,
|
|
351
|
+
offset = 2.2,
|
|
352
|
+
sides = 1,
|
|
353
|
+
start = 0.02,
|
|
354
|
+
end = 0.98,
|
|
355
|
+
face = 'path',
|
|
356
|
+
seed = 1,
|
|
357
|
+
heightAt = null,
|
|
358
|
+
mask = null,
|
|
359
|
+
collision = null,
|
|
360
|
+
parent = null,
|
|
361
|
+
variants = 4,
|
|
362
|
+
scaleRange = null,
|
|
363
|
+
} = {}) {
|
|
364
|
+
if (!asset) throw new Error('placeAlongSpline needs a PropAsset.');
|
|
365
|
+
if (typeof spline?.getPointAt !== 'function') {
|
|
366
|
+
throw new Error('placeAlongSpline needs a curve with getPointAt (e.g. paths.splines[0]).');
|
|
367
|
+
}
|
|
368
|
+
const random = mulberry32(seed * 2654435761 + 13);
|
|
369
|
+
const length = spline.getLength();
|
|
370
|
+
const span = Math.max((end - start) * length, 0.1);
|
|
371
|
+
const steps = Math.max(Math.floor(span / Math.max(spacing, 0.5)), 1);
|
|
372
|
+
const sideList = Array.isArray(sides) ? sides : (sides === 2 ? [-1, 1] : [1]);
|
|
373
|
+
|
|
374
|
+
const samplesFor = (side) => {
|
|
375
|
+
const points = [];
|
|
376
|
+
for (let index = 0; index <= steps; index += 1) {
|
|
377
|
+
const jitterAlong = index > 0 && index < steps ? (random() - 0.5) * jitter * spacing * 0.5 : 0;
|
|
378
|
+
const t = Math.min(Math.max(start + ((index * spacing + jitterAlong) / length), 0), end);
|
|
379
|
+
const point = spline.getPointAt(t);
|
|
380
|
+
const tangent = spline.getTangentAt(t);
|
|
381
|
+
const sideX = -tangent.z;
|
|
382
|
+
const sideZ = tangent.x;
|
|
383
|
+
const norm = Math.hypot(sideX, sideZ) || 1;
|
|
384
|
+
const x = point.x + (sideX / norm) * offset * side;
|
|
385
|
+
const z = point.z + (sideZ / norm) * offset * side;
|
|
386
|
+
const y = typeof heightAt === 'function' ? (Number(heightAt(x, z)) || 0) : point.y;
|
|
387
|
+
points.push({
|
|
388
|
+
heading: Math.atan2(tangent.x, tangent.z),
|
|
389
|
+
masked: typeof mask === 'function' && !mask(x, z),
|
|
390
|
+
x,
|
|
391
|
+
y,
|
|
392
|
+
z,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
return points;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const root = new THREE.Group();
|
|
399
|
+
root.name = `PropsAlongSpline ${asset.type ?? ''}`;
|
|
400
|
+
const blockers = [];
|
|
401
|
+
const updaters = [];
|
|
402
|
+
|
|
403
|
+
for (const side of sideList) {
|
|
404
|
+
const samples = samplesFor(side);
|
|
405
|
+
if (asset.buildAlong) {
|
|
406
|
+
// Linear runs break where the mask rejects (a fence stops at the
|
|
407
|
+
// river bank instead of marching into it).
|
|
408
|
+
const runs = [];
|
|
409
|
+
let current = [];
|
|
410
|
+
for (const sample of samples) {
|
|
411
|
+
if (sample.masked) {
|
|
412
|
+
if (current.length > 1) runs.push(current);
|
|
413
|
+
current = [];
|
|
414
|
+
} else current.push(sample);
|
|
415
|
+
}
|
|
416
|
+
if (current.length > 1) runs.push(current);
|
|
417
|
+
for (const run of runs) {
|
|
418
|
+
const { object3D, footprints } = asset.buildAlong(run, { seed: seed + side * 31 });
|
|
419
|
+
root.add(object3D);
|
|
420
|
+
blockers.push(...footprints);
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
const positions = samples.filter((sample) => !sample.masked).map((sample) => ({
|
|
424
|
+
x: sample.x,
|
|
425
|
+
// face the walkway ('path'), march with it ('along'), or spin free
|
|
426
|
+
yaw: face === 'along' ? sample.heading
|
|
427
|
+
: face === 'random' ? random() * Math.PI * 2
|
|
428
|
+
: sample.heading + (side > 0 ? -Math.PI / 2 : Math.PI / 2) + Math.PI,
|
|
429
|
+
z: sample.z,
|
|
430
|
+
}));
|
|
431
|
+
const placed = placeProps({
|
|
432
|
+
asset,
|
|
433
|
+
collision: null, // collected below so callers get one blocker list
|
|
434
|
+
heightAt,
|
|
435
|
+
parent: root,
|
|
436
|
+
positions,
|
|
437
|
+
scaleRange,
|
|
438
|
+
seed: seed + side * 97,
|
|
439
|
+
variants,
|
|
440
|
+
yaw: 'positions',
|
|
441
|
+
});
|
|
442
|
+
blockers.push(...placed.blockers);
|
|
443
|
+
updaters.push(placed.update);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
collision?.addCircles?.(blockers);
|
|
447
|
+
parent?.add?.(root);
|
|
448
|
+
return {
|
|
449
|
+
blockers,
|
|
450
|
+
dispose() {
|
|
451
|
+
root.parent?.remove(root);
|
|
452
|
+
root.traverse((object) => {
|
|
453
|
+
if (object.isMesh) object.geometry?.dispose();
|
|
454
|
+
});
|
|
455
|
+
},
|
|
456
|
+
root,
|
|
457
|
+
update: (delta, camera) => { for (const update of updaters) update(delta, camera); },
|
|
458
|
+
};
|
|
459
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Built-in prop presets. Every preset doubles as a catalog entry source:
|
|
2
|
+
// scripts/generate-catalog-thumbs.mjs walks this list, and the catalog
|
|
3
|
+
// (workstream 05) turns each into `{ id, cluster, recipe, thumbnail, spawn,
|
|
4
|
+
// tags }` automatically — content accretes from day one.
|
|
5
|
+
|
|
6
|
+
import { createPropRecipeDocument } from './propSettings.js';
|
|
7
|
+
|
|
8
|
+
const preset = (id, label, tags, settings, description = '') => Object.freeze({
|
|
9
|
+
description,
|
|
10
|
+
id,
|
|
11
|
+
label,
|
|
12
|
+
recipe: Object.freeze(createPropRecipeDocument(settings, { name: label })),
|
|
13
|
+
tags: Object.freeze(tags),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const BUILT_IN_PROP_PRESETS = Object.freeze([
|
|
17
|
+
// --- wave 1: path dressing ------------------------------------------------
|
|
18
|
+
preset('prop/fence/ranch', 'Ranch fence', ['prop', 'fence', 'wave-1', 'linear'], {
|
|
19
|
+
asset: { seed: 41, type: 'fence', variant: 'ranch' },
|
|
20
|
+
}, 'Two-rail weathered ranch fence — the default road liner.'),
|
|
21
|
+
preset('prop/fence/rope', 'Rope fence', ['prop', 'fence', 'wave-1', 'linear'], {
|
|
22
|
+
asset: { seed: 43, type: 'fence', variant: 'rope' },
|
|
23
|
+
shape: { sag: 0.55 },
|
|
24
|
+
}, 'Posts with sagging rope — shrine approaches and garden edges.'),
|
|
25
|
+
preset('prop/lantern/stone-toro', 'Stone tōrō', ['prop', 'lantern', 'lighting', 'shrine', 'wave-1'], {
|
|
26
|
+
asset: { seed: 7, type: 'lantern', variant: 'stoneToro' },
|
|
27
|
+
}, 'The classic shrine-path stone lantern with a warm lit core.'),
|
|
28
|
+
preset('prop/lantern/chochin', 'Hanging chōchin', ['prop', 'lantern', 'lighting', 'village', 'wave-1'], {
|
|
29
|
+
asset: { seed: 11, type: 'lantern', variant: 'chochin' },
|
|
30
|
+
}, 'Paper lantern hanging from a post arm — street and stall dressing.'),
|
|
31
|
+
preset('prop/signpost/crossroads', 'Crossroads signpost', ['prop', 'signpost', 'road', 'wave-1'], {
|
|
32
|
+
asset: { seed: 17, type: 'signpost', variant: 'crossroads' },
|
|
33
|
+
}, 'Four direction boards on a crooked post.'),
|
|
34
|
+
preset('prop/stairs/stone', 'Stone stairs', ['prop', 'stairs', 'stone', 'wave-1'], {
|
|
35
|
+
asset: { seed: 23, type: 'stoneStairs', variant: 'straight' },
|
|
36
|
+
}, 'Free-standing worn step run for garden slopes.'),
|
|
37
|
+
preset('prop/milestone/road-stone', 'Road stone', ['prop', 'milestone', 'road', 'wave-1'], {
|
|
38
|
+
asset: { seed: 29, type: 'milestone', variant: 'roadStone' },
|
|
39
|
+
}, 'Mossy way marker.'),
|
|
40
|
+
preset('prop/milestone/jizo', 'Jizō marker', ['prop', 'milestone', 'shrine', 'wave-1'], {
|
|
41
|
+
asset: { seed: 31, type: 'milestone', variant: 'jizo' },
|
|
42
|
+
}, 'Small stone guardian with a red bib.'),
|
|
43
|
+
|
|
44
|
+
// --- wave 2: rural set ------------------------------------------------------
|
|
45
|
+
preset('prop/well/roofed', 'Roofed well', ['prop', 'well', 'village', 'wave-2'], {
|
|
46
|
+
asset: { seed: 37, type: 'well', variant: 'roofed' },
|
|
47
|
+
}, 'Stone ring, winch beam, bucket, gable roof — village-square anchor.'),
|
|
48
|
+
preset('prop/crates/market', 'Market crates', ['prop', 'crates', 'village', 'market', 'wave-2'], {
|
|
49
|
+
asset: { seed: 41, type: 'crateStack', variant: 'mixed' },
|
|
50
|
+
shape: { count: 5, stackiness: 0.6 },
|
|
51
|
+
}, 'Mixed crate-and-barrel stack for docks and stalls.'),
|
|
52
|
+
preset('prop/firewood/stacked', 'Firewood stack', ['prop', 'firewood', 'village', 'wave-2'], {
|
|
53
|
+
asset: { seed: 43, type: 'firewood', variant: 'stacked' },
|
|
54
|
+
}, 'Neat split-log pile with pale cut faces.'),
|
|
55
|
+
preset('prop/torii/vermilion', 'Vermilion torii', ['prop', 'torii', 'shrine', 'gate', 'wave-2'], {
|
|
56
|
+
asset: { seed: 47, type: 'torii', variant: 'myojin' },
|
|
57
|
+
}, 'Curved-lintel shrine gate, vermilion with black caps.'),
|
|
58
|
+
preset('prop/pier/fishing', 'Fishing pier', ['prop', 'pier', 'water', 'wave-2'], {
|
|
59
|
+
asset: { seed: 53, type: 'pier', variant: 'straight' },
|
|
60
|
+
}, 'Plank dock on posts — place at a shore point, reaching over water.'),
|
|
61
|
+
|
|
62
|
+
// --- wave 3: garden / urban prep ---------------------------------------------
|
|
63
|
+
preset('prop/wall/dry-stone', 'Dry stone wall', ['prop', 'wall', 'garden', 'wave-3', 'linear'], {
|
|
64
|
+
asset: { seed: 59, type: 'stoneWall', variant: 'dry' },
|
|
65
|
+
}, 'Stacked field wall with mossy base stones.'),
|
|
66
|
+
preset('prop/bench/plank', 'Plank bench', ['prop', 'bench', 'garden', 'village', 'wave-3'], {
|
|
67
|
+
asset: { seed: 61, type: 'bench', variant: 'plank' },
|
|
68
|
+
}, 'Simple two-leg plank seat.'),
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
// The studio look: what `call_me_sensei` worlds dress their paths with.
|
|
72
|
+
export const CALL_ME_SENSEI_PROP_SET = Object.freeze({
|
|
73
|
+
fence: 'prop/fence/ranch',
|
|
74
|
+
lantern: 'prop/lantern/stone-toro',
|
|
75
|
+
milestone: 'prop/milestone/jizo',
|
|
76
|
+
signpost: 'prop/signpost/crossroads',
|
|
77
|
+
torii: 'prop/torii/vermilion',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export function findPropPreset(id) {
|
|
81
|
+
return BUILT_IN_PROP_PRESETS.find((entry) => entry.id === id) ?? null;
|
|
82
|
+
}
|