@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,451 @@
|
|
|
1
|
+
// Plan → geometry. One merged BufferGeometry per material role (wall /
|
|
2
|
+
// beam / roof / trim / door) keeps any building at ≤ 6 draw calls; villages
|
|
3
|
+
// instance repeated recipes on top of that. No interiors, no CSG: windows
|
|
4
|
+
// and doors are surface-mounted frames and panels — the stylized read at a
|
|
5
|
+
// fraction of the cost, and nothing to go wrong across 1000 seeds.
|
|
6
|
+
|
|
7
|
+
import * as THREE from 'three';
|
|
8
|
+
|
|
9
|
+
import { createPropRandom, mergePainted } from '../propgen/propParts.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
|
+
// Per-role painted-geometry collector; building palettes are explicit RGB
|
|
18
|
+
// per part (five roles), so this is PartsBuilder's cousin with free colors.
|
|
19
|
+
class RoleBuilder {
|
|
20
|
+
constructor(variation, paintRandom) {
|
|
21
|
+
this.variation = variation;
|
|
22
|
+
this.random = paintRandom;
|
|
23
|
+
this.roles = new Map();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
paint(geometry, rgb, shade) {
|
|
27
|
+
if (!geometry.attributes.normal) geometry.computeVertexNormals();
|
|
28
|
+
const normal = geometry.attributes.normal;
|
|
29
|
+
const count = geometry.attributes.position.count;
|
|
30
|
+
const colors = new Float32Array(count * 3);
|
|
31
|
+
for (let index = 0; index < count; index += 1) {
|
|
32
|
+
const drift = 1 + (this.random() * 2 - 1) * this.variation;
|
|
33
|
+
const upward = Math.max(0, normal.getY(index));
|
|
34
|
+
const value = drift * shade * (1 + upward * 0.12);
|
|
35
|
+
colors[index * 3] = Math.min(rgb[0] * value, 1);
|
|
36
|
+
colors[index * 3 + 1] = Math.min(rgb[1] * value, 1);
|
|
37
|
+
colors[index * 3 + 2] = Math.min(rgb[2] * value, 1);
|
|
38
|
+
}
|
|
39
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
40
|
+
return geometry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
add(role, geometry, rgb, shade = 1) {
|
|
44
|
+
this.paint(geometry, rgb, shade);
|
|
45
|
+
const bucket = this.roles.get(role);
|
|
46
|
+
if (bucket) bucket.push(geometry);
|
|
47
|
+
else this.roles.set(role, [geometry]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
box(role, rgb, shade, w, h, d, position, rotation = [0, 0, 0]) {
|
|
51
|
+
const geometry = new THREE.BoxGeometry(w, h, d);
|
|
52
|
+
scratchEuler.set(rotation[0], rotation[1], rotation[2], 'YXZ');
|
|
53
|
+
scratchQuaternion.setFromEuler(scratchEuler);
|
|
54
|
+
scratchPosition.set(position[0], position[1], position[2]);
|
|
55
|
+
scratchScale.set(1, 1, 1);
|
|
56
|
+
scratchMatrix.compose(scratchPosition, scratchQuaternion, scratchScale);
|
|
57
|
+
geometry.applyMatrix4(scratchMatrix);
|
|
58
|
+
this.add(role, geometry, rgb, shade);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Quad from 4 corners (CCW facing the viewer), normal computed flat. */
|
|
62
|
+
quad(role, rgb, shade, p0, p1, p2, p3) {
|
|
63
|
+
const geometry = new THREE.BufferGeometry();
|
|
64
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute([
|
|
65
|
+
...p0, ...p1, ...p2, ...p0, ...p2, ...p3,
|
|
66
|
+
], 3));
|
|
67
|
+
geometry.setAttribute('uv', new THREE.Float32BufferAttribute([
|
|
68
|
+
0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1,
|
|
69
|
+
], 2));
|
|
70
|
+
geometry.computeVertexNormals();
|
|
71
|
+
this.add(role, geometry, rgb, shade);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
triangle(role, rgb, shade, p0, p1, p2) {
|
|
75
|
+
const geometry = new THREE.BufferGeometry();
|
|
76
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute([...p0, ...p1, ...p2], 3));
|
|
77
|
+
geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 0.5, 1], 2));
|
|
78
|
+
geometry.computeVertexNormals();
|
|
79
|
+
this.add(role, geometry, rgb, shade);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
build() {
|
|
83
|
+
const result = {};
|
|
84
|
+
for (const [role, geometries] of this.roles) {
|
|
85
|
+
result[role] = mergePainted(geometries);
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Inset a rectilinear CCW perimeter: each vertex moves along the sum of its
|
|
92
|
+
// two adjacent outward normals (exact for 90° corners).
|
|
93
|
+
function insetWalls(walls, inset) {
|
|
94
|
+
if (inset <= 0) return walls;
|
|
95
|
+
const count = walls.length;
|
|
96
|
+
const points = walls.map((wall) => [wall.ax, wall.az]);
|
|
97
|
+
const moved = points.map((point, index) => {
|
|
98
|
+
const prev = walls[(index + count - 1) % count];
|
|
99
|
+
const next = walls[index];
|
|
100
|
+
return [
|
|
101
|
+
point[0] - (prev.nx + next.nx) * inset,
|
|
102
|
+
point[1] - (prev.nz + next.nz) * inset,
|
|
103
|
+
];
|
|
104
|
+
});
|
|
105
|
+
return walls.map((wall, index) => {
|
|
106
|
+
const a = moved[index];
|
|
107
|
+
const b = moved[(index + 1) % count];
|
|
108
|
+
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
109
|
+
return { ...wall, ax: a[0], az: a[1], bx: b[0], bz: b[1], length };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const GLASS = [0.22, 0.31, 0.38];
|
|
114
|
+
|
|
115
|
+
function buildWallsAndFacades(builder, plan, detail) {
|
|
116
|
+
const { settings, floors, footprint } = plan;
|
|
117
|
+
const { palette, facade, massing } = settings;
|
|
118
|
+
const hi = detail === 'hi';
|
|
119
|
+
const thickness = 0.22;
|
|
120
|
+
const shrineOpenWall = settings.type === 'shrine' ? plan.door.wallIndex : -1;
|
|
121
|
+
|
|
122
|
+
for (const floor of floors) {
|
|
123
|
+
const walls = insetWalls(footprint.walls, floor.inset);
|
|
124
|
+
for (let wallIndex = 0; wallIndex < walls.length; wallIndex += 1) {
|
|
125
|
+
const wall = walls[wallIndex];
|
|
126
|
+
const facadeSpec = plan.facades.find(
|
|
127
|
+
(entry) => entry.floor === floor.level && entry.wallIndex === wallIndex,
|
|
128
|
+
);
|
|
129
|
+
const leanAt = (y) => massing.wallLean * y;
|
|
130
|
+
const openColumns = floor.level === 0 && wallIndex === shrineOpenWall;
|
|
131
|
+
|
|
132
|
+
const outer = (u, y) => [
|
|
133
|
+
wall.ax + (wall.bx - wall.ax) * u + wall.nx * (thickness / 2 + leanAt(y)),
|
|
134
|
+
y,
|
|
135
|
+
wall.az + (wall.bz - wall.az) * u + wall.nz * (thickness / 2 + leanAt(y)),
|
|
136
|
+
];
|
|
137
|
+
const inner = (u, y) => [
|
|
138
|
+
wall.ax + (wall.bx - wall.ax) * u - wall.nx * (thickness / 2 - leanAt(y)),
|
|
139
|
+
y,
|
|
140
|
+
wall.az + (wall.bz - wall.az) * u - wall.nz * (thickness / 2 - leanAt(y)),
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
if (openColumns) {
|
|
144
|
+
// Shrine front: columns at bay edges instead of a wall — the open
|
|
145
|
+
// hall read. The door panel becomes the inner sanctum face.
|
|
146
|
+
const bays = facadeSpec?.bays ?? [];
|
|
147
|
+
const columnRadius = 0.14;
|
|
148
|
+
const edges = new Set([0, 1]);
|
|
149
|
+
for (const bay of bays) { edges.add(bay.u0); edges.add(bay.u1); }
|
|
150
|
+
for (const u of edges) {
|
|
151
|
+
const [x, , z] = outer(u, 0);
|
|
152
|
+
const geometry = new THREE.CylinderGeometry(columnRadius, columnRadius * 1.1, floor.y1 - floor.y0, 8);
|
|
153
|
+
geometry.translate(x, (floor.y0 + floor.y1) / 2, z);
|
|
154
|
+
builder.add('beam', geometry, palette.beam, 1);
|
|
155
|
+
}
|
|
156
|
+
// header beam across the top
|
|
157
|
+
builder.quad('beam', palette.beam, 0.95,
|
|
158
|
+
outer(0, floor.y1 - 0.28), outer(0, floor.y1),
|
|
159
|
+
outer(1, floor.y1), outer(1, floor.y1 - 0.28));
|
|
160
|
+
// inner sanctum wall, recessed
|
|
161
|
+
builder.quad('wall', palette.wall, 0.86,
|
|
162
|
+
inner(0.05, floor.y0), inner(0.05, floor.y1),
|
|
163
|
+
inner(0.95, floor.y1), inner(0.95, floor.y0));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Outer + inner faces (leaning trapezoids); corners share positions so
|
|
168
|
+
// adjacent walls seal.
|
|
169
|
+
builder.quad('wall', palette.wall, 1,
|
|
170
|
+
outer(0, floor.y0), outer(0, floor.y1), outer(1, floor.y1), outer(1, floor.y0));
|
|
171
|
+
builder.quad('wall', palette.wall, 0.82,
|
|
172
|
+
inner(1, floor.y0), inner(1, floor.y1), inner(0, floor.y1), inner(0, floor.y0));
|
|
173
|
+
|
|
174
|
+
if (!facadeSpec) continue;
|
|
175
|
+
const faceOffset = thickness / 2 + 0.035;
|
|
176
|
+
const facePoint = (u, y) => [
|
|
177
|
+
wall.ax + (wall.bx - wall.ax) * u + wall.nx * (faceOffset + leanAt(y)),
|
|
178
|
+
y,
|
|
179
|
+
wall.az + (wall.bz - wall.az) * u + wall.nz * (faceOffset + leanAt(y)),
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
// Timber framing: floor bands + bay posts (hi only — the lo silhouette
|
|
183
|
+
// doesn't need them).
|
|
184
|
+
if (hi && facade.beams > 0.05) {
|
|
185
|
+
const bandHeight = 0.16 * facade.beams + 0.06;
|
|
186
|
+
for (const y of [floor.y0 + 0.02, floor.y1 - 0.08]) {
|
|
187
|
+
const [ax, , az] = facePoint(0, y);
|
|
188
|
+
const [bx, , bz] = facePoint(1, y);
|
|
189
|
+
builder.box('beam', palette.beam, 1,
|
|
190
|
+
0.07, bandHeight, wall.length + 0.02,
|
|
191
|
+
[(ax + bx) / 2, y + bandHeight / 2, (az + bz) / 2],
|
|
192
|
+
[0, Math.atan2(bx - ax, bz - az), 0]);
|
|
193
|
+
}
|
|
194
|
+
for (const bay of facadeSpec.bays) {
|
|
195
|
+
for (const u of bay === facadeSpec.bays[0] ? [bay.u0, bay.u1] : [bay.u1]) {
|
|
196
|
+
const yMid = (floor.y0 + floor.y1) / 2;
|
|
197
|
+
const [x, , z] = facePoint(u, yMid);
|
|
198
|
+
builder.box('beam', palette.beam, 0.95,
|
|
199
|
+
0.11, floor.y1 - floor.y0, 0.11,
|
|
200
|
+
[x, yMid, z],
|
|
201
|
+
[0, Math.atan2(wall.bx - wall.ax, wall.bz - wall.az), 0]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Windows and the door — surface-mounted on the outer face.
|
|
207
|
+
for (const bay of facadeSpec.bays) {
|
|
208
|
+
const yaw = Math.atan2(wall.bx - wall.ax, wall.bz - wall.az);
|
|
209
|
+
if (bay.window && hi) {
|
|
210
|
+
const { u, width, y0: wy0, height } = bay.window;
|
|
211
|
+
const yCenter = floor.y0 + wy0 + height / 2;
|
|
212
|
+
const [x, , z] = facePoint(u, yCenter);
|
|
213
|
+
builder.box('beam', palette.beam, 0.9, 0.06, height + 0.14, width + 0.14, [x, yCenter, z], [0, yaw, 0]);
|
|
214
|
+
builder.box('trim', GLASS, 1, 0.08, height, width, [x, yCenter, z], [0, yaw, 0]);
|
|
215
|
+
// sill
|
|
216
|
+
const [sx, , sz] = facePoint(u, floor.y0 + wy0 - 0.05);
|
|
217
|
+
builder.box('trim', palette.trim, 1, 0.12, 0.07, width + 0.2, [sx, floor.y0 + wy0 - 0.05, sz], [0, yaw, 0]);
|
|
218
|
+
}
|
|
219
|
+
if (bay.door) {
|
|
220
|
+
const { u, width, height } = bay.door;
|
|
221
|
+
const yCenter = floor.y0 + height / 2;
|
|
222
|
+
const [x, , z] = facePoint(u, yCenter);
|
|
223
|
+
if (hi) {
|
|
224
|
+
builder.box('beam', palette.beam, 0.92, 0.09, height + 0.12, width + 0.16, [x, yCenter, z], [0, yaw, 0]);
|
|
225
|
+
}
|
|
226
|
+
builder.box('door', palette.door, 1, 0.1, height, width, [x, yCenter, z], [0, yaw, 0]);
|
|
227
|
+
if (hi) {
|
|
228
|
+
// stone threshold step
|
|
229
|
+
const [tx, , tz] = facePoint(u, 0);
|
|
230
|
+
builder.box('trim', palette.trim, 0.95, 0.5, 0.14, width + 0.3,
|
|
231
|
+
[tx + wall.nx * 0.2, 0.07, tz + wall.nz * 0.2], [0, yaw, 0]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Roof slabs: top + underside + eave fascia. Pagoda curvature segments the
|
|
240
|
+
// slope and sweeps the eave upward.
|
|
241
|
+
function buildRoof(builder, plan, roofSpec, detail) {
|
|
242
|
+
const { settings } = plan;
|
|
243
|
+
const { palette } = settings;
|
|
244
|
+
const hi = detail === 'hi';
|
|
245
|
+
const { rect, overhang, rise, ridgeAxis, kind, curvature } = roofSpec;
|
|
246
|
+
const baseY = plan.wallTop;
|
|
247
|
+
const slabThickness = 0.14;
|
|
248
|
+
|
|
249
|
+
// Work in ridge-local coordinates: `a` along the ridge, `b` across it.
|
|
250
|
+
const along = ridgeAxis === 'x'
|
|
251
|
+
? { max: rect.x1 + overhang, min: rect.x0 - overhang }
|
|
252
|
+
: { max: rect.z1 + overhang, min: rect.z0 - overhang };
|
|
253
|
+
const across = ridgeAxis === 'x'
|
|
254
|
+
? { max: rect.z1 + overhang, mid: (rect.z0 + rect.z1) / 2, min: rect.z0 - overhang }
|
|
255
|
+
: { max: rect.x1 + overhang, mid: (rect.x0 + rect.x1) / 2, min: rect.x0 - overhang };
|
|
256
|
+
const point = (a, b, y) => (ridgeAxis === 'x' ? [a, y, b] : [b, y, a]);
|
|
257
|
+
const halfSpan = (across.max - across.min) / 2;
|
|
258
|
+
const eaveY = baseY - 0.08;
|
|
259
|
+
const ridgeY = eaveY + rise;
|
|
260
|
+
|
|
261
|
+
const slopeQuads = (bFrom, bTo, yFrom, yTo, aMin = along.min, aMax = along.max) => {
|
|
262
|
+
const segments = kind === 'pagoda' && hi ? 4 : 1;
|
|
263
|
+
// Winding depends on which way `b` runs AND which world axis it maps to
|
|
264
|
+
// — one slope of every pair would otherwise face down (invisible at lo
|
|
265
|
+
// detail, wrongly-shaded at hi).
|
|
266
|
+
const flip = (bTo > bFrom) === (ridgeAxis === 'x');
|
|
267
|
+
const topQuad = (p0, p1, p2, p3) => (flip
|
|
268
|
+
? builder.quad('roof', palette.roof, 1, p3, p2, p1, p0)
|
|
269
|
+
: builder.quad('roof', palette.roof, 1, p0, p1, p2, p3));
|
|
270
|
+
const underQuad = (p0, p1, p2, p3) => (flip
|
|
271
|
+
? builder.quad('roof', palette.roof, 0.72, p3, p2, p1, p0)
|
|
272
|
+
: builder.quad('roof', palette.roof, 0.72, p0, p1, p2, p3));
|
|
273
|
+
for (let segment = 0; segment < segments; segment += 1) {
|
|
274
|
+
const t0 = segment / segments;
|
|
275
|
+
const t1 = (segment + 1) / segments;
|
|
276
|
+
// concave sweep: steep at the ridge, flaring flat toward the eave
|
|
277
|
+
const ease = (t) => (curvature > 0.01 ? 1 - (1 - t) ** (1 + curvature * 1.6) : t);
|
|
278
|
+
const b0 = bFrom + (bTo - bFrom) * t0;
|
|
279
|
+
const b1 = bFrom + (bTo - bFrom) * t1;
|
|
280
|
+
const y0 = yFrom + (yTo - yFrom) * ease(t0);
|
|
281
|
+
const y1 = yFrom + (yTo - yFrom) * ease(t1);
|
|
282
|
+
topQuad(
|
|
283
|
+
point(aMin, b0, y0), point(aMax, b0, y0),
|
|
284
|
+
point(aMax, b1, y1), point(aMin, b1, y1));
|
|
285
|
+
if (hi) {
|
|
286
|
+
underQuad(
|
|
287
|
+
point(aMax, b0, y0 - slabThickness), point(aMin, b0, y0 - slabThickness),
|
|
288
|
+
point(aMin, b1, y1 - slabThickness), point(aMax, b1, y1 - slabThickness));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
let capAlong = { max: along.max, min: along.min };
|
|
294
|
+
if (kind === 'shed') {
|
|
295
|
+
slopeQuads(across.min, across.max, ridgeY, eaveY);
|
|
296
|
+
// fascia on the high edge
|
|
297
|
+
if (hi) {
|
|
298
|
+
builder.quad('roof', palette.roof, 0.8,
|
|
299
|
+
point(along.min, across.min, ridgeY - slabThickness), point(along.max, across.min, ridgeY - slabThickness),
|
|
300
|
+
point(along.max, across.min, ridgeY), point(along.min, across.min, ridgeY));
|
|
301
|
+
}
|
|
302
|
+
} else if (kind === 'hip') {
|
|
303
|
+
const hipInset = Math.min(halfSpan, (along.max - along.min) * 0.28);
|
|
304
|
+
capAlong = { max: along.max - hipInset, min: along.min + hipInset };
|
|
305
|
+
slopeQuads(across.mid, across.max, ridgeY, eaveY, along.min + hipInset, along.max - hipInset);
|
|
306
|
+
slopeQuads(across.mid, across.min, ridgeY, eaveY, along.min + hipInset, along.max - hipInset);
|
|
307
|
+
for (const [aEdge, aRidge] of [[along.min, along.min + hipInset], [along.max, along.max - hipInset]]) {
|
|
308
|
+
const outwardFirst = aEdge === along.min; // wind each end to face outward
|
|
309
|
+
if (outwardFirst) {
|
|
310
|
+
builder.triangle('roof', palette.roof, 0.94,
|
|
311
|
+
point(aEdge, across.min, eaveY), point(aEdge, across.max, eaveY),
|
|
312
|
+
point(aRidge, across.mid, ridgeY));
|
|
313
|
+
} else {
|
|
314
|
+
builder.triangle('roof', palette.roof, 0.94,
|
|
315
|
+
point(aEdge, across.max, eaveY), point(aEdge, across.min, eaveY),
|
|
316
|
+
point(aRidge, across.mid, ridgeY));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
// gable / pagoda: two slopes + gable-end infill at the wall line. The
|
|
321
|
+
// infill rim follows the roof UNDERSIDE (including the pagoda ease
|
|
322
|
+
// curve) so no sky sliver opens between wall and roof.
|
|
323
|
+
slopeQuads(across.mid, across.max, ridgeY, eaveY);
|
|
324
|
+
slopeQuads(across.mid, across.min, ridgeY, eaveY);
|
|
325
|
+
const wallAcross = ridgeAxis === 'x'
|
|
326
|
+
? { max: rect.z1, min: rect.z0 }
|
|
327
|
+
: { max: rect.x1, min: rect.x0 };
|
|
328
|
+
const wallAlong = ridgeAxis === 'x'
|
|
329
|
+
? { max: rect.x1, min: rect.x0 }
|
|
330
|
+
: { max: rect.z1, min: rect.z0 };
|
|
331
|
+
const ease = (t) => (curvature > 0.01 ? 1 - (1 - t) ** (1 + curvature * 1.6) : t);
|
|
332
|
+
const undersideAt = (b) => {
|
|
333
|
+
const t = Math.min(Math.abs(b - across.mid) / halfSpan, 1);
|
|
334
|
+
return ridgeY + (eaveY - ridgeY) * ease(t) - slabThickness - 0.015;
|
|
335
|
+
};
|
|
336
|
+
const rimSegments = kind === 'pagoda' && hi ? 4 : 1;
|
|
337
|
+
for (const aEnd of [wallAlong.min, wallAlong.max]) {
|
|
338
|
+
const flip = aEnd === wallAlong.min; // both gables must face OUTWARD
|
|
339
|
+
const tri = (p0, p1, p2) => (flip
|
|
340
|
+
? builder.triangle('wall', palette.wall, 0.95, p0, p2, p1)
|
|
341
|
+
: builder.triangle('wall', palette.wall, 0.95, p0, p1, p2));
|
|
342
|
+
const rim = [];
|
|
343
|
+
for (let index = 0; index <= rimSegments * 2; index += 1) {
|
|
344
|
+
const b = wallAcross.min + ((wallAcross.max - wallAcross.min) * index) / (rimSegments * 2);
|
|
345
|
+
rim.push([b, undersideAt(b)]);
|
|
346
|
+
}
|
|
347
|
+
const bottomY = baseY - 0.05;
|
|
348
|
+
const bottomMid = point(aEnd, (wallAcross.min + wallAcross.max) / 2, bottomY);
|
|
349
|
+
for (let index = 0; index < rim.length - 1; index += 1) {
|
|
350
|
+
tri(
|
|
351
|
+
bottomMid,
|
|
352
|
+
point(aEnd, rim[index][0], rim[index][1]),
|
|
353
|
+
point(aEnd, rim[index + 1][0], rim[index + 1][1]));
|
|
354
|
+
}
|
|
355
|
+
tri(
|
|
356
|
+
point(aEnd, wallAcross.min, bottomY),
|
|
357
|
+
point(aEnd, rim[0][0], rim[0][1]),
|
|
358
|
+
bottomMid);
|
|
359
|
+
tri(
|
|
360
|
+
bottomMid,
|
|
361
|
+
point(aEnd, rim[rim.length - 1][0], rim[rim.length - 1][1]),
|
|
362
|
+
point(aEnd, wallAcross.max, bottomY));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Ridge cap + finials.
|
|
367
|
+
if (hi && roofSpec.ridgeDecor > 0.05 && kind !== 'shed') {
|
|
368
|
+
const capY = ridgeY + 0.05;
|
|
369
|
+
const capLength = Math.max(capAlong.max - capAlong.min, 0.3) + 0.2;
|
|
370
|
+
const capMid = (capAlong.min + capAlong.max) / 2;
|
|
371
|
+
builder.box('roof', palette.roof, 0.7,
|
|
372
|
+
ridgeAxis === 'x' ? capLength : 0.24,
|
|
373
|
+
0.14,
|
|
374
|
+
ridgeAxis === 'x' ? 0.24 : capLength,
|
|
375
|
+
ridgeAxis === 'x' ? [capMid, capY, across.mid] : [across.mid, capY, capMid]);
|
|
376
|
+
if (roofSpec.ridgeDecor > 0.4) {
|
|
377
|
+
for (const aEnd of [capAlong.min + 0.1, capAlong.max - 0.1]) {
|
|
378
|
+
const position = ridgeAxis === 'x' ? [aEnd, capY + 0.16, across.mid] : [across.mid, capY + 0.16, aEnd];
|
|
379
|
+
builder.box('roof', palette.roof, 0.62, 0.16, 0.3, 0.16, position);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Builds the merged role geometries for a plan. `detail` 'hi' keeps timber,
|
|
387
|
+
* window frames, ridge decoration; 'lo' is walls + roof + chimney silhouette.
|
|
388
|
+
* Returns `{ geometries: { wall, beam, roof, trim, door }, stats }`.
|
|
389
|
+
*/
|
|
390
|
+
export function meshBuildingPlan(plan, { detail = 'hi' } = {}) {
|
|
391
|
+
const { settings } = plan;
|
|
392
|
+
const { palette, facade } = settings;
|
|
393
|
+
const paintRandom = createPropRandom(plan.seed ^ 0x51ab);
|
|
394
|
+
const builder = new RoleBuilder(palette.variation, paintRandom);
|
|
395
|
+
const hi = detail === 'hi';
|
|
396
|
+
|
|
397
|
+
// Foundation skirt: outset stone box per rect, buried 1.3 m — buildings
|
|
398
|
+
// sit on slopes (≤ ~20°) without floating corners.
|
|
399
|
+
for (const rect of plan.footprint.rects) {
|
|
400
|
+
const width = rect.x1 - rect.x0 + 0.24;
|
|
401
|
+
const depth = rect.z1 - rect.z0 + 0.24;
|
|
402
|
+
const top = Math.max(facade.baseHeight, 0.12);
|
|
403
|
+
builder.box('trim', palette.trim, 0.95, width, top + 1.3, depth,
|
|
404
|
+
[(rect.x0 + rect.x1) / 2, top - (top + 1.3) / 2, (rect.z0 + rect.z1) / 2]);
|
|
405
|
+
}
|
|
406
|
+
// Shrine veranda: a wider platform + its own buried skirt (the platform
|
|
407
|
+
// outreach would otherwise hover on any slope) + entry steps.
|
|
408
|
+
if (settings.type === 'shrine' && hi) {
|
|
409
|
+
const rect = plan.footprint.rects[0];
|
|
410
|
+
builder.box('trim', palette.trim, 1.02,
|
|
411
|
+
rect.x1 - rect.x0 + 1.6, 0.22, rect.z1 - rect.z0 + 1.6,
|
|
412
|
+
[(rect.x0 + rect.x1) / 2, facade.baseHeight - 0.11, (rect.z0 + rect.z1) / 2]);
|
|
413
|
+
builder.box('trim', palette.trim, 0.88,
|
|
414
|
+
rect.x1 - rect.x0 + 1.4, facade.baseHeight + 1.1, rect.z1 - rect.z0 + 1.4,
|
|
415
|
+
[(rect.x0 + rect.x1) / 2, (facade.baseHeight - 0.22 - 1.3) / 2, (rect.z0 + rect.z1) / 2]);
|
|
416
|
+
const door = plan.door;
|
|
417
|
+
const steps = Math.max(Math.round(facade.baseHeight / 0.18), 2);
|
|
418
|
+
for (let step = 0; step < steps; step += 1) {
|
|
419
|
+
const t = (step + 1) / steps;
|
|
420
|
+
builder.box('trim', palette.trim, 0.9 + step * 0.03,
|
|
421
|
+
1.6, 0.18, 0.34,
|
|
422
|
+
[
|
|
423
|
+
door.x + door.nx * ((rect.z1 - rect.z0) * 0 + 0.9 + (1 - t) * 0.34 * steps * 0.5),
|
|
424
|
+
facade.baseHeight * t - 0.09,
|
|
425
|
+
door.z + door.nz * (0.9 + (1 - t) * 0.34 * steps * 0.5),
|
|
426
|
+
],
|
|
427
|
+
[0, Math.atan2(door.nx, door.nz), 0]);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
buildWallsAndFacades(builder, plan, detail);
|
|
432
|
+
for (const roofSpec of plan.roofs) buildRoof(builder, plan, roofSpec, detail);
|
|
433
|
+
|
|
434
|
+
if (plan.chimney) {
|
|
435
|
+
const mainRoof = plan.roofs[0];
|
|
436
|
+
const chimneyTop = plan.wallTop + mainRoof.rise + 0.7;
|
|
437
|
+
builder.box('trim', palette.trim, 1, 0.5, chimneyTop, 0.5,
|
|
438
|
+
[plan.chimney.x, chimneyTop / 2, plan.chimney.z]);
|
|
439
|
+
if (hi) {
|
|
440
|
+
builder.box('trim', palette.trim, 0.85, 0.7, 0.12, 0.7,
|
|
441
|
+
[plan.chimney.x, chimneyTop + 0.06, plan.chimney.z]);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const geometries = builder.build();
|
|
446
|
+
let triangles = 0;
|
|
447
|
+
for (const geometry of Object.values(geometries)) {
|
|
448
|
+
if (geometry?.index) triangles += geometry.index.count / 3;
|
|
449
|
+
}
|
|
450
|
+
return { geometries, stats: { triangles: Math.round(triangles) } };
|
|
451
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Built-in building presets — one per type plus studio flavors; each is a
|
|
2
|
+
// catalog entry source (id, tags, recipe) like the prop presets.
|
|
3
|
+
|
|
4
|
+
import { buildingRecipeFromSettings } from './buildingRecipe.js';
|
|
5
|
+
|
|
6
|
+
const preset = (id, label, tags, settings, description = '') => Object.freeze({
|
|
7
|
+
description,
|
|
8
|
+
id,
|
|
9
|
+
label,
|
|
10
|
+
recipe: Object.freeze(buildingRecipeFromSettings(settings)),
|
|
11
|
+
tags: Object.freeze(tags),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const BUILT_IN_BUILDING_PRESETS = Object.freeze([
|
|
15
|
+
preset('building/cottage/default', 'Cottage', ['building', 'cottage', 'village', 'wave-1'], {
|
|
16
|
+
seed: 12, type: 'cottage',
|
|
17
|
+
}, 'Timber-framed cottage with a chimney — the village workhorse.'),
|
|
18
|
+
preset('building/shed/default', 'Shed', ['building', 'shed', 'village', 'wave-1'], {
|
|
19
|
+
seed: 5, type: 'shed',
|
|
20
|
+
}, 'Single-slope outbuilding.'),
|
|
21
|
+
preset('building/farmhouse/default', 'Farmhouse', ['building', 'farmhouse', 'village', 'wave-2'], {
|
|
22
|
+
seed: 21, type: 'farmhouse',
|
|
23
|
+
}, 'L-plan two-floor farmhouse.'),
|
|
24
|
+
preset('building/watchtower/default', 'Watchtower', ['building', 'tower', 'wave-2'], {
|
|
25
|
+
seed: 33, type: 'watchtower',
|
|
26
|
+
}, 'Four floors stepping inward under a capped hip roof.'),
|
|
27
|
+
preset('building/shrine/default', 'Hillside shrine', ['building', 'shrine', 'wave-3'], {
|
|
28
|
+
seed: 47, type: 'shrine',
|
|
29
|
+
}, 'Open-fronted hall on a stone veranda, curved deep-overhang roof.'),
|
|
30
|
+
preset('building/cottage/call-me-sensei', 'Sensei cottage', ['building', 'cottage', 'village', 'call-me-sensei'], {
|
|
31
|
+
massing: { wallLean: 0.02 },
|
|
32
|
+
palette: {
|
|
33
|
+
beam: [0.28, 0.19, 0.12],
|
|
34
|
+
door: [0.55, 0.28, 0.12],
|
|
35
|
+
roof: [0.5, 0.32, 0.42],
|
|
36
|
+
wall: [0.88, 0.8, 0.64],
|
|
37
|
+
},
|
|
38
|
+
roof: { overhang: 0.7, pitch: 1, ridgeDecor: 0.3 },
|
|
39
|
+
seed: 7,
|
|
40
|
+
type: 'cottage',
|
|
41
|
+
}, 'The studio cottage: oversized plum roof, honeyed plaster, strong lean.'),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export function findBuildingPreset(id) {
|
|
45
|
+
return BUILT_IN_BUILDING_PRESETS.find((entry) => entry.id === id) ?? null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Building recipe layer — mirrors treeRecipe.js: settings ⇄ recipe
|
|
2
|
+
// (`{ schema: 'buildingRecipe', version, type, options }`) ⇄ deterministic
|
|
3
|
+
// rebuild. Building settings are already flat panel groups, so the mapping
|
|
4
|
+
// is direct; the recipe's `options` are the grouped settings plus seed.
|
|
5
|
+
|
|
6
|
+
import * as THREE from 'three';
|
|
7
|
+
|
|
8
|
+
import { getDebrisDetailTexture } from '../debrisgen/debrisTextures.js';
|
|
9
|
+
import {
|
|
10
|
+
BUILDING_TYPES,
|
|
11
|
+
cloneBuildingSettings,
|
|
12
|
+
createBuildingSettings,
|
|
13
|
+
} from './buildingSettings.js';
|
|
14
|
+
import { resolveBuildingPlan } from './buildingGrammar.js';
|
|
15
|
+
import { meshBuildingPlan } from './buildingMesh.js';
|
|
16
|
+
|
|
17
|
+
export const BUILDING_RECIPE_SCHEMA = 'buildingRecipe';
|
|
18
|
+
export const BUILDING_RECIPE_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
export function buildingRecipeFromSettings(settings) {
|
|
21
|
+
const normalized = createBuildingSettings(settings);
|
|
22
|
+
const { type, seed, ...groups } = normalized;
|
|
23
|
+
return {
|
|
24
|
+
options: { seed, ...groups },
|
|
25
|
+
schema: BUILDING_RECIPE_SCHEMA,
|
|
26
|
+
type,
|
|
27
|
+
version: BUILDING_RECIPE_VERSION,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildingSettingsFromRecipe(recipe) {
|
|
32
|
+
return createBuildingSettings({ ...recipe?.options, type: recipe?.type });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateBuildingRecipeDocument(input) {
|
|
36
|
+
const errors = [];
|
|
37
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
38
|
+
return { errors: ['Recipe document must be an object.'], ok: false };
|
|
39
|
+
}
|
|
40
|
+
if (input.schema !== BUILDING_RECIPE_SCHEMA) errors.push(`schema must be "${BUILDING_RECIPE_SCHEMA}".`);
|
|
41
|
+
if (!Number.isInteger(input.version) || input.version < 1 || input.version > BUILDING_RECIPE_VERSION) {
|
|
42
|
+
errors.push(`version must be an integer between 1 and ${BUILDING_RECIPE_VERSION}.`);
|
|
43
|
+
}
|
|
44
|
+
if (!BUILDING_TYPES[input.type]) {
|
|
45
|
+
errors.push(`type must be one of ${Object.keys(BUILDING_TYPES).join(', ')}.`);
|
|
46
|
+
}
|
|
47
|
+
if (!input.options || typeof input.options !== 'object' || Array.isArray(input.options)) {
|
|
48
|
+
errors.push('options must be an object.');
|
|
49
|
+
}
|
|
50
|
+
return { errors, ok: errors.length === 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Role materials are shared per (roughness bucket) — every building in a
|
|
54
|
+
// scene reuses the same five materials, so a whole village adds ~5 programs.
|
|
55
|
+
const materialCache = new Map();
|
|
56
|
+
function roleMaterial(role) {
|
|
57
|
+
let material = materialCache.get(role);
|
|
58
|
+
if (material) return material;
|
|
59
|
+
const family = role === 'roof' || role === 'trim'
|
|
60
|
+
? { kind: 'stone', style: 'speckle' }
|
|
61
|
+
: { kind: 'wood', style: 'grain' };
|
|
62
|
+
material = new THREE.MeshStandardMaterial({
|
|
63
|
+
map: getDebrisDetailTexture(family.kind, 7, family.style, 1),
|
|
64
|
+
metalness: 0,
|
|
65
|
+
roughness: role === 'roof' ? 0.85 : 0.92,
|
|
66
|
+
vertexColors: true,
|
|
67
|
+
});
|
|
68
|
+
material.name = `Building ${role}`;
|
|
69
|
+
material.userData.envRole = 'standard';
|
|
70
|
+
materialCache.set(role, material);
|
|
71
|
+
return material;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Deterministic rebuild: recipe (or bare settings) → `{ object3D, plan,
|
|
76
|
+
* stats }`. Same recipe → identical building, forever.
|
|
77
|
+
*/
|
|
78
|
+
export function createBuildingFromRecipe(recipeOrSettings, { detail = 'hi' } = {}) {
|
|
79
|
+
const settings = recipeOrSettings?.schema === BUILDING_RECIPE_SCHEMA
|
|
80
|
+
? buildingSettingsFromRecipe(recipeOrSettings)
|
|
81
|
+
: createBuildingSettings(recipeOrSettings ?? {});
|
|
82
|
+
const plan = resolveBuildingPlan(settings);
|
|
83
|
+
const { geometries, stats } = meshBuildingPlan(plan, { detail });
|
|
84
|
+
const root = new THREE.Group();
|
|
85
|
+
root.name = `Building ${settings.type} ${settings.seed}${detail === 'lo' ? ' (lo)' : ''}`;
|
|
86
|
+
for (const [role, geometry] of Object.entries(geometries)) {
|
|
87
|
+
if (!geometry) continue;
|
|
88
|
+
const mesh = new THREE.Mesh(geometry, roleMaterial(role));
|
|
89
|
+
mesh.name = `Building-${role}`;
|
|
90
|
+
mesh.castShadow = true;
|
|
91
|
+
mesh.receiveShadow = true;
|
|
92
|
+
root.add(mesh);
|
|
93
|
+
}
|
|
94
|
+
root.userData.buildingRecipe = buildingRecipeFromSettings(settings);
|
|
95
|
+
return { object3D: root, plan, settings, stats };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function cloneDefaultBuildingSettings() {
|
|
99
|
+
return cloneBuildingSettings();
|
|
100
|
+
}
|