@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,44 @@
1
+ // Buildings as PropAssets: a building is "just" a big prop with a
2
+ // multi-circle footprint — which is exactly why the placement pipeline,
3
+ // villages, and the catalog need no building-specific code paths.
4
+
5
+ import { createBuildingSettings } from './buildingSettings.js';
6
+ import { createBuildingFromRecipe, buildingRecipeFromSettings, BUILDING_RECIPE_SCHEMA } from './buildingRecipe.js';
7
+
8
+ const LOD_DISTANCE = { cottage: 140, farmhouse: 160, shed: 90, shrine: 180, watchtower: 220 };
9
+
10
+ /**
11
+ * `buildingAsset(recipeOrSettings)` → the 02 PropAsset contract.
12
+ * `build(seed)` re-rolls the seed only (window rhythm, chimney position);
13
+ * the recipe's proportions stay fixed — villages instance cousins, not
14
+ * clones.
15
+ */
16
+ export function buildingAsset(recipeOrSettings = {}) {
17
+ const settings = recipeOrSettings?.schema === BUILDING_RECIPE_SCHEMA
18
+ ? createBuildingSettings({ ...recipeOrSettings.options, type: recipeOrSettings.type })
19
+ : createBuildingSettings(recipeOrSettings ?? {});
20
+ return {
21
+ build(seed = settings.seed) {
22
+ const buildSettings = { ...settings, seed };
23
+ const hi = createBuildingFromRecipe(buildSettings, { detail: 'hi' });
24
+ const lo = createBuildingFromRecipe(buildSettings, { detail: 'lo' });
25
+ return {
26
+ anchor: 0, // grammar origin is ground contact; the skirt is buried below
27
+ door: hi.plan.door,
28
+ footprint: { circles: hi.plan.footprintCircles },
29
+ lod: {
30
+ distance: LOD_DISTANCE[settings.type] ?? 150,
31
+ far: lo.object3D,
32
+ },
33
+ object3D: hi.object3D,
34
+ plan: hi.plan,
35
+ stats: hi.stats,
36
+ };
37
+ },
38
+ linear: false,
39
+ recipe: buildingRecipeFromSettings(settings),
40
+ settings,
41
+ type: settings.type,
42
+ variant: settings.type,
43
+ };
44
+ }
@@ -0,0 +1,311 @@
1
+ // The shape grammar: settings + seed → a PLAN (pure data, no THREE). Every
2
+ // stage is deterministic, and the grammar invariants (door reachable and on
3
+ // an exterior wall, windows never intersecting beams, roofs always
4
+ // overhanging walls) are properties of the plan — which is why the 1000-seed
5
+ // invariant suite runs in milliseconds without building any geometry.
6
+
7
+ function mulberry32(seed) {
8
+ let state = (Math.trunc(seed) || 1) >>> 0;
9
+ return () => {
10
+ state = (state + 0x6d2b79f5) >>> 0;
11
+ let t = state;
12
+ t = Math.imul(t ^ (t >>> 15), t | 1);
13
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
14
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
15
+ };
16
+ }
17
+
18
+ // Footprint construction: axis-aligned rect union whose perimeter we can
19
+ // walk explicitly (general polygon union is overkill for village-grade
20
+ // buildings). CCW viewed from above; outward normal of a CCW segment
21
+ // (dx, dz) is (dz, -dx).
22
+ function buildFootprint({ kind, width, depth, wingRatio }, random) {
23
+ const w = width;
24
+ const d = depth;
25
+ const rects = [{ x0: -w / 2, x1: w / 2, z0: -d / 2, z1: d / 2 }];
26
+ let perimeter;
27
+ if (kind === 'L') {
28
+ const ww = Math.max(w * wingRatio, 2);
29
+ const wd = Math.max(d * wingRatio, 2);
30
+ rects.push({ x0: w / 2 - ww, x1: w / 2, z0: d / 2, z1: d / 2 + wd });
31
+ perimeter = [
32
+ [-w / 2, -d / 2], [w / 2, -d / 2],
33
+ [w / 2, d / 2 + wd], [w / 2 - ww, d / 2 + wd],
34
+ [w / 2 - ww, d / 2], [-w / 2, d / 2],
35
+ ];
36
+ } else if (kind === 'T') {
37
+ const ww = Math.max(w * wingRatio * 0.8, 2);
38
+ const wd = Math.max(d * wingRatio, 2);
39
+ const cx = (random() - 0.5) * (w - ww) * 0.4; // wing wanders off-center a bit
40
+ rects.push({ x0: cx - ww / 2, x1: cx + ww / 2, z0: d / 2, z1: d / 2 + wd });
41
+ perimeter = [
42
+ [-w / 2, -d / 2], [w / 2, -d / 2],
43
+ [w / 2, d / 2], [cx + ww / 2, d / 2],
44
+ [cx + ww / 2, d / 2 + wd], [cx - ww / 2, d / 2 + wd],
45
+ [cx - ww / 2, d / 2], [-w / 2, d / 2],
46
+ ];
47
+ } else {
48
+ perimeter = [
49
+ [-w / 2, -d / 2], [w / 2, -d / 2],
50
+ [w / 2, d / 2], [-w / 2, d / 2],
51
+ ];
52
+ }
53
+ const walls = perimeter.map((point, index) => {
54
+ const next = perimeter[(index + 1) % perimeter.length];
55
+ const dx = next[0] - point[0];
56
+ const dz = next[1] - point[1];
57
+ const length = Math.hypot(dx, dz);
58
+ return {
59
+ ax: point[0],
60
+ az: point[1],
61
+ bx: next[0],
62
+ bz: next[1],
63
+ length,
64
+ nx: dz / (length || 1),
65
+ nz: -dx / (length || 1),
66
+ };
67
+ });
68
+ return { perimeter, rects, walls };
69
+ }
70
+
71
+ // Bay layout along one wall: beam columns at bay edges, windows strictly
72
+ // inside bays with a margin — the "windows never intersect beams" invariant
73
+ // holds by construction.
74
+ function layoutBays(wall, settings, random, { door = null, floor = 0 }) {
75
+ const { facade } = settings;
76
+ const bayCount = Math.max(Math.round(wall.length / facade.bayWidth), 1);
77
+ const bays = [];
78
+ for (let index = 0; index < bayCount; index += 1) {
79
+ const u0 = index / bayCount;
80
+ const u1 = (index + 1) / bayCount;
81
+ const bayLength = (u1 - u0) * wall.length;
82
+ const isDoorBay = door !== null && index === door.bayIndex;
83
+ let window = null;
84
+ const roll = random();
85
+ if (!isDoorBay && roll < facade.windowChance) {
86
+ const margin = 0.16; // beam clearance in meters
87
+ const maxWidth = bayLength - margin * 2;
88
+ const width = Math.min(facade.windowWidth, maxWidth * 0.8);
89
+ if (width > 0.3) {
90
+ const y0 = floor === 0 ? 0.95 : 0.75;
91
+ window = {
92
+ height: Math.min(facade.windowHeight, settings.massing.floorHeight - y0 - 0.45),
93
+ u: (u0 + u1) / 2,
94
+ width,
95
+ y0,
96
+ };
97
+ }
98
+ }
99
+ bays.push({ door: isDoorBay ? door : null, u0, u1, window });
100
+ }
101
+ return bays;
102
+ }
103
+
104
+ /**
105
+ * settings (+ its seed) → plan. See module header; the plan is everything
106
+ * the mesher and the invariant tests need.
107
+ */
108
+ export function resolveBuildingPlan(settings) {
109
+ const random = mulberry32(settings.seed * 2654435761 + 17);
110
+ const footprint = buildFootprint(settings.footprint, random);
111
+ const { massing, roof } = settings;
112
+
113
+ // Floors: inset shrinks the working rects; clamp so the top floor never
114
+ // degenerates (invariant 4).
115
+ const minSpan = Math.min(
116
+ settings.footprint.width,
117
+ settings.footprint.depth,
118
+ );
119
+ const maxInset = (minSpan * 0.3) / Math.max(massing.floors, 1);
120
+ const inset = Math.min(massing.inset, maxInset);
121
+ const floors = [];
122
+ for (let level = 0; level < massing.floors; level += 1) {
123
+ floors.push({
124
+ inset: inset * level,
125
+ level,
126
+ y0: level * massing.floorHeight,
127
+ y1: (level + 1) * massing.floorHeight,
128
+ });
129
+ }
130
+ const wallTop = floors[floors.length - 1].y1;
131
+ const topInset = inset * (massing.floors - 1);
132
+
133
+ // Door: the longest ground-floor wall; centered in its middle bay. The
134
+ // wall is exterior by construction (every perimeter segment is).
135
+ let doorWallIndex = 0;
136
+ for (let index = 1; index < footprint.walls.length; index += 1) {
137
+ if (footprint.walls[index].length > footprint.walls[doorWallIndex].length) {
138
+ doorWallIndex = index;
139
+ }
140
+ }
141
+ const doorWall = footprint.walls[doorWallIndex];
142
+ const doorBayCount = Math.max(Math.round(doorWall.length / settings.facade.bayWidth), 1);
143
+ const door = {
144
+ bayIndex: Math.floor(doorBayCount / 2),
145
+ height: Math.min(settings.facade.doorHeight, massing.floorHeight - 0.3),
146
+ u: (Math.floor(doorBayCount / 2) + 0.5) / doorBayCount,
147
+ width: Math.min(settings.facade.doorWidth, doorWall.length * 0.5),
148
+ };
149
+
150
+ // Facades: per floor per wall. Upper floors of inset massing reuse the
151
+ // same wall directions with shrunk extents (the mesher applies inset).
152
+ const facades = [];
153
+ for (const floor of floors) {
154
+ footprint.walls.forEach((wall, wallIndex) => {
155
+ facades.push({
156
+ bays: layoutBays(wall, settings, random, {
157
+ door: floor.level === 0 && wallIndex === doorWallIndex ? door : null,
158
+ floor: floor.level,
159
+ }),
160
+ floor: floor.level,
161
+ wallIndex,
162
+ });
163
+ });
164
+ }
165
+
166
+ // Roof per rect: the main rect carries the ridge along its longer axis;
167
+ // wings get their own smaller roof of the same kind.
168
+ const roofs = footprint.rects.map((rect, index) => {
169
+ const spanX = rect.x1 - rect.x0;
170
+ const spanZ = rect.z1 - rect.z0;
171
+ const ridgeAxis = spanX >= spanZ ? 'x' : 'z';
172
+ const halfSpan = (ridgeAxis === 'x' ? spanZ : spanX) / 2;
173
+ // seeded proportion jitter: cousins, not clones — and the only seed
174
+ // variation some spartan types (shrine) have
175
+ const riseJitter = 0.95 + random() * 0.1;
176
+ return {
177
+ curvature: roof.curvature,
178
+ kind: roof.kind,
179
+ overhang: Math.max(roof.overhang * (0.97 + random() * 0.06), 0.25), // invariant 3: floor applied last
180
+ rect,
181
+ ridgeAxis,
182
+ ridgeDecor: roof.ridgeDecor,
183
+ rise: Math.max((halfSpan + roof.overhang) * roof.pitch, 0.4) * riseJitter,
184
+ wing: index > 0,
185
+ };
186
+ });
187
+
188
+ // Trim: chimney near the main ridge end for homely types.
189
+ const wantsChimney = settings.type === 'cottage' || settings.type === 'farmhouse';
190
+ const mainRect = footprint.rects[0];
191
+ const chimney = wantsChimney
192
+ ? {
193
+ x: roofs[0].ridgeAxis === 'x'
194
+ ? mainRect.x0 + (mainRect.x1 - mainRect.x0) * (0.22 + random() * 0.12)
195
+ : (mainRect.x0 + mainRect.x1) / 2 + (random() - 0.5) * 0.6,
196
+ z: roofs[0].ridgeAxis === 'z'
197
+ ? mainRect.z0 + (mainRect.z1 - mainRect.z0) * (0.22 + random() * 0.12)
198
+ : (mainRect.z0 + mainRect.z1) / 2 + (random() - 0.5) * 0.6,
199
+ }
200
+ : null;
201
+
202
+ // Collision: one circle per footprint rect (two for long rects).
203
+ const footprintCircles = [];
204
+ for (const rect of footprint.rects) {
205
+ const cx = (rect.x0 + rect.x1) / 2;
206
+ const cz = (rect.z0 + rect.z1) / 2;
207
+ const spanX = rect.x1 - rect.x0;
208
+ const spanZ = rect.z1 - rect.z0;
209
+ const radius = Math.min(spanX, spanZ) * 0.62;
210
+ if (Math.max(spanX, spanZ) > Math.min(spanX, spanZ) * 1.5) {
211
+ const along = spanX >= spanZ ? 'x' : 'z';
212
+ const reach = (Math.max(spanX, spanZ) / 2 - radius * 0.7);
213
+ for (const side of [-1, 1]) {
214
+ footprintCircles.push({
215
+ radius,
216
+ x: cx + (along === 'x' ? side * reach : 0),
217
+ z: cz + (along === 'z' ? side * reach : 0),
218
+ });
219
+ }
220
+ } else {
221
+ footprintCircles.push({ radius, x: cx, z: cz });
222
+ }
223
+ }
224
+
225
+ const bounds = {
226
+ maxX: Math.max(...footprint.rects.map((rect) => rect.x1)),
227
+ maxZ: Math.max(...footprint.rects.map((rect) => rect.z1)),
228
+ minX: Math.min(...footprint.rects.map((rect) => rect.x0)),
229
+ minZ: Math.min(...footprint.rects.map((rect) => rect.z0)),
230
+ };
231
+
232
+ return {
233
+ bounds,
234
+ chimney,
235
+ door: {
236
+ ...door,
237
+ // world-frame door anchor for villages: position + outward normal
238
+ nx: doorWall.nx,
239
+ nz: doorWall.nz,
240
+ wallIndex: doorWallIndex,
241
+ x: doorWall.ax + (doorWall.bx - doorWall.ax) * door.u,
242
+ z: doorWall.az + (doorWall.bz - doorWall.az) * door.u,
243
+ },
244
+ facades,
245
+ floors,
246
+ footprint,
247
+ footprintCircles,
248
+ roofs,
249
+ seed: settings.seed,
250
+ settings,
251
+ topInset,
252
+ type: settings.type,
253
+ wallTop,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Grammar invariant checks — run across 1000 seeds in the verify script.
259
+ * Returns a list of violation strings (empty = clean).
260
+ */
261
+ export function checkPlanInvariants(plan) {
262
+ const violations = [];
263
+ const { door, facades, footprint, roofs, settings } = plan;
264
+
265
+ // 1. Door exists on an exterior wall, fits it, sits on the ground floor.
266
+ const doorWall = footprint.walls[door.wallIndex];
267
+ if (!doorWall) violations.push('door wall missing');
268
+ else if (door.width > doorWall.length * 0.8) violations.push('door wider than its wall');
269
+ const doorFacade = facades.find((facade) => facade.floor === 0 && facade.wallIndex === door.wallIndex);
270
+ if (!doorFacade || !doorFacade.bays.some((bay) => bay.door)) {
271
+ violations.push('door bay missing from ground facade');
272
+ }
273
+
274
+ // 2. Windows never intersect beams: window extent ⊂ its bay minus margin.
275
+ for (const facade of facades) {
276
+ const wall = footprint.walls[facade.wallIndex];
277
+ for (const bay of facade.bays) {
278
+ if (!bay.window) continue;
279
+ const bayLength = (bay.u1 - bay.u0) * wall.length;
280
+ const windowU0 = bay.window.u - (bay.window.width / 2) / wall.length;
281
+ const windowU1 = bay.window.u + (bay.window.width / 2) / wall.length;
282
+ const marginU = 0.1 / wall.length;
283
+ if (windowU0 < bay.u0 + marginU || windowU1 > bay.u1 - marginU) {
284
+ violations.push(`window escapes its bay on wall ${facade.wallIndex} floor ${facade.floor}`);
285
+ }
286
+ if (bay.window.y0 + bay.window.height > settings.massing.floorHeight - 0.2) {
287
+ violations.push('window intersects the floor beam above');
288
+ }
289
+ }
290
+ }
291
+
292
+ // 3. Roofs always overhang walls.
293
+ for (const roofSpec of roofs) {
294
+ if (roofSpec.overhang < 0.25 - 1e-9) violations.push('roof overhang collapsed');
295
+ }
296
+
297
+ // 4. Massing sanity: top floor keeps positive span after inset.
298
+ const minSpan = Math.min(settings.footprint.width, settings.footprint.depth);
299
+ if (plan.topInset * 2 > minSpan * 0.8) violations.push('floor inset degenerates the top floor');
300
+
301
+ // 5. Collision covers every rect center.
302
+ for (const rect of footprint.rects) {
303
+ const cx = (rect.x0 + rect.x1) / 2;
304
+ const cz = (rect.z0 + rect.z1) / 2;
305
+ const covered = plan.footprintCircles.some(
306
+ (circle) => (circle.x - cx) ** 2 + (circle.z - cz) ** 2 <= circle.radius ** 2,
307
+ );
308
+ if (!covered) violations.push('footprint circle misses a rect center');
309
+ }
310
+ return violations;
311
+ }