@developerz.ai/ts23d 0.0.0-stage → 0.1.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 (524) hide show
  1. package/.claude/agents/bug-hunter.md +21 -0
  2. package/.claude/agents/docs-author.md +13 -0
  3. package/.claude/agents/dx-auditor.md +14 -0
  4. package/.claude/agents/primitive-author.md +22 -0
  5. package/.claude/agents/recipe-author.md +36 -0
  6. package/.claude/agents/visual-reviewer.md +23 -0
  7. package/.claude/commands/feature.md +28 -0
  8. package/.claude/commands/new-recipe.md +19 -0
  9. package/.claude/commands/release.md +14 -0
  10. package/.claude/commands/verify.md +9 -0
  11. package/.claude/skills/author-recipe/SKILL.md +93 -0
  12. package/CHANGELOG.md +79 -0
  13. package/CLAUDE.md +85 -0
  14. package/LICENSE +21 -0
  15. package/README.md +380 -2
  16. package/dist/chunk-11v28977.js +1575 -0
  17. package/dist/chunk-11v28977.js.map +30 -0
  18. package/dist/chunk-1ypfs7ga.js +3571 -0
  19. package/dist/chunk-1ypfs7ga.js.map +42 -0
  20. package/dist/chunk-4ddzsqtc.js +464 -0
  21. package/dist/chunk-4ddzsqtc.js.map +19 -0
  22. package/dist/chunk-a8fxakcj.js +19 -0
  23. package/dist/chunk-a8fxakcj.js.map +11 -0
  24. package/dist/chunk-gcyp9b4d.js +755 -0
  25. package/dist/chunk-gcyp9b4d.js.map +22 -0
  26. package/dist/chunk-kgvbpqqw.js +44 -0
  27. package/dist/chunk-kgvbpqqw.js.map +10 -0
  28. package/dist/chunk-kvcep7ft.js +1919 -0
  29. package/dist/chunk-kvcep7ft.js.map +43 -0
  30. package/dist/chunk-ravbb06q.js +89 -0
  31. package/dist/chunk-ravbb06q.js.map +11 -0
  32. package/dist/chunk-w1zyhj40.js +1031 -0
  33. package/dist/chunk-w1zyhj40.js.map +21 -0
  34. package/dist/chunk-wtqhq529.js +2532 -0
  35. package/dist/chunk-wtqhq529.js.map +43 -0
  36. package/dist/chunk-xnt2sdhf.js +836 -0
  37. package/dist/chunk-xnt2sdhf.js.map +17 -0
  38. package/dist/cli/args.d.ts +24 -0
  39. package/dist/cli/bin.d.ts +2 -0
  40. package/dist/cli/bin.js +307 -0
  41. package/dist/cli/bin.js.map +14 -0
  42. package/dist/cli/load.d.ts +2 -0
  43. package/dist/cli/manifest.d.ts +49 -0
  44. package/dist/cli/render-cmds.d.ts +12 -0
  45. package/dist/cli/run.d.ts +1 -0
  46. package/dist/core/asset.d.ts +10 -0
  47. package/dist/core/definition.d.ts +148 -0
  48. package/dist/core/errors.d.ts +17 -0
  49. package/dist/core/family.d.ts +23 -0
  50. package/dist/core/index.d.ts +9 -0
  51. package/dist/core/index.js +69 -0
  52. package/dist/core/index.js.map +9 -0
  53. package/dist/core/mesh.d.ts +35 -0
  54. package/dist/core/part.d.ts +114 -0
  55. package/dist/core/png.d.ts +1 -0
  56. package/dist/core/rng.d.ts +12 -0
  57. package/dist/core/transform.d.ts +20 -0
  58. package/dist/core/vec.d.ts +11 -0
  59. package/dist/core/world.d.ts +16 -0
  60. package/dist/export/bin-writer.d.ts +16 -0
  61. package/dist/export/container.d.ts +3 -0
  62. package/dist/export/glb.d.ts +38 -0
  63. package/dist/export/index.d.ts +4 -0
  64. package/dist/export/index.js +26 -0
  65. package/dist/export/index.js.map +9 -0
  66. package/dist/export/lights.d.ts +8 -0
  67. package/dist/export/materials.d.ts +23 -0
  68. package/dist/export/merge-pivots.d.ts +6 -0
  69. package/dist/export/metadata.d.ts +19 -0
  70. package/dist/export/motion.d.ts +9 -0
  71. package/dist/export/optimize-parts.d.ts +26 -0
  72. package/dist/export/primitive.d.ts +3 -0
  73. package/dist/export/tangents.d.ts +2 -0
  74. package/dist/export/textures.d.ts +22 -0
  75. package/dist/export/version.d.ts +2 -0
  76. package/dist/grammar/index.d.ts +2 -0
  77. package/dist/grammar/index.js +16 -0
  78. package/dist/grammar/index.js.map +9 -0
  79. package/dist/grammar/split.d.ts +21 -0
  80. package/dist/grammar/whyte.d.ts +5 -0
  81. package/dist/index.d.ts +11 -0
  82. package/dist/index.js +411 -0
  83. package/dist/index.js.map +9 -0
  84. package/dist/kinds/building.d.ts +44 -0
  85. package/dist/kinds/faces.d.ts +27 -0
  86. package/dist/kinds/head-armour.d.ts +9 -0
  87. package/dist/kinds/head-beard.d.ts +6 -0
  88. package/dist/kinds/head-bust.d.ts +4 -0
  89. package/dist/kinds/head-extras.d.ts +15 -0
  90. package/dist/kinds/head-face.d.ts +6 -0
  91. package/dist/kinds/head-geom.d.ts +7 -0
  92. package/dist/kinds/head-hair.d.ts +4 -0
  93. package/dist/kinds/head-layout.d.ts +29 -0
  94. package/dist/kinds/head-mesh.d.ts +27 -0
  95. package/dist/kinds/head-params.d.ts +108 -0
  96. package/dist/kinds/head-presets.d.ts +19 -0
  97. package/dist/kinds/head-rim.d.ts +10 -0
  98. package/dist/kinds/head.d.ts +39 -0
  99. package/dist/kinds/hull-lines.d.ts +65 -0
  100. package/dist/kinds/hull-skin.d.ts +6 -0
  101. package/dist/kinds/hull.d.ts +14 -0
  102. package/dist/kinds/index.d.ts +16 -0
  103. package/dist/kinds/index.js +78 -0
  104. package/dist/kinds/index.js.map +9 -0
  105. package/dist/kinds/mass.d.ts +15 -0
  106. package/dist/kinds/openings.d.ts +30 -0
  107. package/dist/kinds/planned.d.ts +6 -0
  108. package/dist/kinds/roof-chimney.d.ts +16 -0
  109. package/dist/kinds/roof-shape.d.ts +64 -0
  110. package/dist/kinds/roof.d.ts +31 -0
  111. package/dist/kinds/vegetation-fronds.d.ts +15 -0
  112. package/dist/kinds/vegetation-impostor.d.ts +9 -0
  113. package/dist/kinds/vegetation-leaves.d.ts +15 -0
  114. package/dist/kinds/vegetation-mesh.d.ts +21 -0
  115. package/dist/kinds/vegetation-params.d.ts +90 -0
  116. package/dist/kinds/vegetation-presets.d.ts +20 -0
  117. package/dist/kinds/vegetation-skeleton.d.ts +20 -0
  118. package/dist/kinds/vegetation.d.ts +42 -0
  119. package/dist/kinds/vehicle.d.ts +42 -0
  120. package/dist/material/generator.d.ts +4 -0
  121. package/dist/material/index.d.ts +4 -0
  122. package/dist/material/index.js +38 -0
  123. package/dist/material/index.js.map +9 -0
  124. package/dist/material/paint/bond.d.ts +3 -0
  125. package/dist/material/paint/canvas.d.ts +32 -0
  126. package/dist/material/paint/cell.d.ts +4 -0
  127. package/dist/material/paint/cells-round.d.ts +5 -0
  128. package/dist/material/paint/cells.d.ts +7 -0
  129. package/dist/material/paint/compose.d.ts +5 -0
  130. package/dist/material/paint/concrete.d.ts +3 -0
  131. package/dist/material/paint/courses.d.ts +31 -0
  132. package/dist/material/paint/decode-png.d.ts +6 -0
  133. package/dist/material/paint/default-sheet.d.ts +2 -0
  134. package/dist/material/paint/foliage-broad.d.ts +5 -0
  135. package/dist/material/paint/foliage-common.d.ts +35 -0
  136. package/dist/material/paint/foliage-compound.d.ts +4 -0
  137. package/dist/material/paint/foliage-conifer.d.ts +5 -0
  138. package/dist/material/paint/foliage-raster.d.ts +26 -0
  139. package/dist/material/paint/foliage.d.ts +3 -0
  140. package/dist/material/paint/masks.d.ts +3 -0
  141. package/dist/material/paint/noise.d.ts +9 -0
  142. package/dist/material/paint/normal.d.ts +8 -0
  143. package/dist/material/paint/orm.d.ts +8 -0
  144. package/dist/material/paint/planks.d.ts +3 -0
  145. package/dist/material/paint/plates.d.ts +3 -0
  146. package/dist/material/paint/shapes.d.ts +8 -0
  147. package/dist/material/paint/slate.d.ts +3 -0
  148. package/dist/material/paint/stamp.d.ts +3 -0
  149. package/dist/material/paint/strip.d.ts +3 -0
  150. package/dist/material/paint/surface.d.ts +5 -0
  151. package/dist/material/paint/types.d.ts +35 -0
  152. package/dist/material/paint/validate.d.ts +3 -0
  153. package/dist/material/palette.d.ts +12 -0
  154. package/dist/material/shader-godot.d.ts +2 -0
  155. package/dist/material/shader-three.d.ts +2 -0
  156. package/dist/material/shader.d.ts +46 -0
  157. package/dist/material/trim-sheet.d.ts +22 -0
  158. package/dist/neural/generator.d.ts +25 -0
  159. package/dist/neural/images.d.ts +17 -0
  160. package/dist/neural/index.d.ts +2 -0
  161. package/dist/neural/index.js +14 -0
  162. package/dist/neural/index.js.map +9 -0
  163. package/dist/passes/bake-ao.d.ts +17 -0
  164. package/dist/passes/grime.d.ts +15 -0
  165. package/dist/passes/index.d.ts +6 -0
  166. package/dist/passes/index.js +22 -0
  167. package/dist/passes/index.js.map +9 -0
  168. package/dist/passes/livery.d.ts +19 -0
  169. package/dist/passes/optimize-cache.d.ts +15 -0
  170. package/dist/passes/optimize-weld.d.ts +15 -0
  171. package/dist/passes/optimize.d.ts +28 -0
  172. package/dist/passes/ray-grid.d.ts +23 -0
  173. package/dist/passes/weighted-normals.d.ts +8 -0
  174. package/dist/primitives/beam.d.ts +17 -0
  175. package/dist/primitives/bevel-box.d.ts +13 -0
  176. package/dist/primitives/box.d.ts +16 -0
  177. package/dist/primitives/cell.d.ts +20 -0
  178. package/dist/primitives/check.d.ts +12 -0
  179. package/dist/primitives/closed.d.ts +11 -0
  180. package/dist/primitives/erosion.d.ts +22 -0
  181. package/dist/primitives/font-5x7.d.ts +3 -0
  182. package/dist/primitives/heightfield.d.ts +16 -0
  183. package/dist/primitives/index.d.ts +23 -0
  184. package/dist/primitives/index.js +81 -0
  185. package/dist/primitives/index.js.map +9 -0
  186. package/dist/primitives/lathe-facets.d.ts +8 -0
  187. package/dist/primitives/lathe.d.ts +26 -0
  188. package/dist/primitives/lattice.d.ts +30 -0
  189. package/dist/primitives/loft.d.ts +15 -0
  190. package/dist/primitives/mc-table.d.ts +2 -0
  191. package/dist/primitives/member.d.ts +19 -0
  192. package/dist/primitives/noise.d.ts +34 -0
  193. package/dist/primitives/planar.d.ts +10 -0
  194. package/dist/primitives/prism.d.ts +13 -0
  195. package/dist/primitives/sculpt-edit.d.ts +56 -0
  196. package/dist/primitives/sculpt-tree.d.ts +23 -0
  197. package/dist/primitives/sculpt.d.ts +27 -0
  198. package/dist/primitives/sdf-grid.d.ts +17 -0
  199. package/dist/primitives/sdf-ops.d.ts +37 -0
  200. package/dist/primitives/sdf.d.ts +21 -0
  201. package/dist/primitives/strip-uv.d.ts +11 -0
  202. package/dist/primitives/surface-nets.d.ts +7 -0
  203. package/dist/primitives/sweep.d.ts +19 -0
  204. package/dist/primitives/terrain-color.d.ts +30 -0
  205. package/dist/primitives/terrain-grid.d.ts +36 -0
  206. package/dist/primitives/text-layout.d.ts +22 -0
  207. package/dist/primitives/text.d.ts +27 -0
  208. package/dist/primitives/tiled.d.ts +22 -0
  209. package/dist/primitives/transport.d.ts +17 -0
  210. package/dist/primitives/triangulate.d.ts +2 -0
  211. package/dist/primitives/value-noise.d.ts +10 -0
  212. package/dist/qa/checks.d.ts +11 -0
  213. package/dist/qa/clash.d.ts +2 -0
  214. package/dist/qa/floating.d.ts +5 -0
  215. package/dist/qa/gates.d.ts +12 -0
  216. package/dist/qa/geometry.d.ts +21 -0
  217. package/dist/qa/index.d.ts +12 -0
  218. package/dist/qa/index.js +78 -0
  219. package/dist/qa/index.js.map +9 -0
  220. package/dist/qa/inside.d.ts +12 -0
  221. package/dist/qa/inspect.d.ts +18 -0
  222. package/dist/qa/lods.d.ts +12 -0
  223. package/dist/qa/measures.d.ts +4 -0
  224. package/dist/qa/occlusion.d.ts +3 -0
  225. package/dist/qa/sanity.d.ts +7 -0
  226. package/dist/qa/types.d.ts +12 -0
  227. package/dist/render/beauty.d.ts +40 -0
  228. package/dist/render/camera.d.ts +33 -0
  229. package/dist/render/depth-map.d.ts +38 -0
  230. package/dist/render/font.d.ts +1 -0
  231. package/dist/render/ground.d.ts +13 -0
  232. package/dist/render/index.d.ts +10 -0
  233. package/dist/render/index.js +47 -0
  234. package/dist/render/index.js.map +9 -0
  235. package/dist/render/lighting.d.ts +20 -0
  236. package/dist/render/lod-strip.d.ts +5 -0
  237. package/dist/render/metrics.d.ts +37 -0
  238. package/dist/render/options.d.ts +28 -0
  239. package/dist/render/photo-match.d.ts +19 -0
  240. package/dist/render/raster.d.ts +25 -0
  241. package/dist/render/resolve.d.ts +11 -0
  242. package/dist/render/review-prompt.d.ts +2 -0
  243. package/dist/render/review-warnings.d.ts +34 -0
  244. package/dist/render/review.d.ts +24 -0
  245. package/dist/render/scene.d.ts +18 -0
  246. package/dist/render/shade.d.ts +22 -0
  247. package/dist/render/sheet.d.ts +15 -0
  248. package/dist/render/texture.d.ts +15 -0
  249. package/dist/render/thumbnail.d.ts +8 -0
  250. package/dist/render/vis-buffer.d.ts +18 -0
  251. package/dist/testing/assert.d.ts +21 -0
  252. package/dist/testing/compiled.d.ts +15 -0
  253. package/dist/testing/hash.d.ts +1 -0
  254. package/dist/testing/index.d.ts +4 -0
  255. package/dist/testing/index.js +134 -0
  256. package/dist/testing/index.js.map +13 -0
  257. package/dist/testing/snapshot.d.ts +13 -0
  258. package/docs/README.md +52 -0
  259. package/docs/architecture/README.md +68 -0
  260. package/docs/architecture/adding-a-kind.md +44 -0
  261. package/docs/architecture/adding-a-primitive.md +49 -0
  262. package/docs/architecture/errors.md +47 -0
  263. package/docs/architecture/glb-production.md +214 -0
  264. package/docs/architecture/gltf-capabilities.md +80 -0
  265. package/docs/decisions/001-recipe-compiler-over-ai-meshes.md +27 -0
  266. package/docs/decisions/002-typescript-not-json.md +22 -0
  267. package/docs/decisions/003-shared-trim-sheet.md +21 -0
  268. package/docs/decisions/004-z-up-metres.md +21 -0
  269. package/docs/decisions/005-zero-runtime-deps.md +21 -0
  270. package/docs/decisions/006-single-package-subpath-exports.md +20 -0
  271. package/docs/decisions/007-cpu-contact-sheets.md +21 -0
  272. package/docs/decisions/008-surface-materials.md +21 -0
  273. package/docs/decisions/009-gltf-front-axis.md +21 -0
  274. package/docs/decisions/README.md +15 -0
  275. package/docs/idea/01-vision.md +117 -0
  276. package/docs/idea/02-dsl.md +343 -0
  277. package/docs/idea/03-ir.md +97 -0
  278. package/docs/idea/04-materials.md +114 -0
  279. package/docs/idea/05-agent-loop.md +141 -0
  280. package/docs/idea/06-qa.md +101 -0
  281. package/docs/idea/07-what-fits.md +45 -0
  282. package/docs/idea/08-neural-adapters.md +78 -0
  283. package/docs/idea/09-kinds.md +105 -0
  284. package/docs/landscape.md +49 -0
  285. package/docs/plans/roadmap.md +127 -0
  286. package/examples/build-with-bun.ts +41 -0
  287. package/llms.txt +47 -0
  288. package/package.json +99 -4
  289. package/recipes/README.md +38 -0
  290. package/recipes/asteroid/README.md +46 -0
  291. package/recipes/asteroid/asteroid.ts +336 -0
  292. package/recipes/boulders/README.md +28 -0
  293. package/recipes/boulders/boulders.ts +117 -0
  294. package/recipes/boxcar/README.md +46 -0
  295. package/recipes/boxcar/boxcar.ts +916 -0
  296. package/recipes/cargo_container_scifi/README.md +52 -0
  297. package/recipes/cargo_container_scifi/cargo_container_scifi.ts +461 -0
  298. package/recipes/dropship/README.md +84 -0
  299. package/recipes/dropship/dropship.ts +1144 -0
  300. package/recipes/eiffel_tower/README.md +110 -0
  301. package/recipes/eiffel_tower/eiffel_tower.ts +781 -0
  302. package/recipes/goblin_head/README.md +62 -0
  303. package/recipes/goblin_head/goblin_head.ts +36 -0
  304. package/recipes/human_head/README.md +64 -0
  305. package/recipes/human_head/human_head.ts +37 -0
  306. package/recipes/loco_440/README.md +32 -0
  307. package/recipes/loco_440/loco_440.ts +1404 -0
  308. package/recipes/material_swatches/README.md +21 -0
  309. package/recipes/material_swatches/material_swatches.ts +183 -0
  310. package/recipes/mountain_range/README.md +45 -0
  311. package/recipes/mountain_range/mountain_range.ts +128 -0
  312. package/recipes/oak/README.md +30 -0
  313. package/recipes/oak/oak.ts +19 -0
  314. package/recipes/orc_head/README.md +66 -0
  315. package/recipes/orc_head/orc_head.ts +39 -0
  316. package/recipes/palm/README.md +30 -0
  317. package/recipes/palm/palm.ts +19 -0
  318. package/recipes/pine/README.md +29 -0
  319. package/recipes/pine/pine.ts +19 -0
  320. package/recipes/signal_gantry/README.md +36 -0
  321. package/recipes/signal_gantry/signal_gantry.ts +522 -0
  322. package/recipes/station_halt/README.md +39 -0
  323. package/recipes/station_halt/station_halt.ts +722 -0
  324. package/recipes/t34/README.md +44 -0
  325. package/recipes/t34/t34.ts +1068 -0
  326. package/recipes/tiger_1/README.md +75 -0
  327. package/recipes/tiger_1/tiger_1.ts +1370 -0
  328. package/recipes/water_tower/README.md +40 -0
  329. package/recipes/water_tower/water_tower.ts +542 -0
  330. package/src/cli/args.ts +112 -0
  331. package/src/cli/bin.ts +7 -0
  332. package/src/cli/load.ts +17 -0
  333. package/src/cli/manifest.ts +89 -0
  334. package/src/cli/render-cmds.ts +66 -0
  335. package/src/cli/run.ts +168 -0
  336. package/src/core/asset.ts +128 -0
  337. package/src/core/definition.ts +149 -0
  338. package/src/core/errors.ts +45 -0
  339. package/src/core/family.ts +50 -0
  340. package/src/core/index.ts +66 -0
  341. package/src/core/mesh.ts +114 -0
  342. package/src/core/part.ts +130 -0
  343. package/src/core/png.ts +49 -0
  344. package/src/core/rng.ts +49 -0
  345. package/src/core/transform.ts +90 -0
  346. package/src/core/vec.ts +26 -0
  347. package/src/core/world.ts +80 -0
  348. package/src/export/bin-writer.ts +79 -0
  349. package/src/export/container.ts +35 -0
  350. package/src/export/glb.ts +200 -0
  351. package/src/export/index.ts +18 -0
  352. package/src/export/lights.ts +49 -0
  353. package/src/export/materials.ts +126 -0
  354. package/src/export/merge-pivots.ts +146 -0
  355. package/src/export/metadata.ts +59 -0
  356. package/src/export/motion.ts +70 -0
  357. package/src/export/optimize-parts.ts +80 -0
  358. package/src/export/primitive.ts +35 -0
  359. package/src/export/tangents.ts +75 -0
  360. package/src/export/textures.ts +88 -0
  361. package/src/export/version.ts +6 -0
  362. package/src/grammar/index.ts +4 -0
  363. package/src/grammar/split.ts +87 -0
  364. package/src/grammar/whyte.ts +20 -0
  365. package/src/index.ts +14 -0
  366. package/src/kinds/building.ts +153 -0
  367. package/src/kinds/faces.ts +138 -0
  368. package/src/kinds/head-armour.ts +151 -0
  369. package/src/kinds/head-beard.ts +133 -0
  370. package/src/kinds/head-bust.ts +221 -0
  371. package/src/kinds/head-extras.ts +150 -0
  372. package/src/kinds/head-face.ts +222 -0
  373. package/src/kinds/head-geom.ts +31 -0
  374. package/src/kinds/head-hair.ts +152 -0
  375. package/src/kinds/head-layout.ts +63 -0
  376. package/src/kinds/head-mesh.ts +157 -0
  377. package/src/kinds/head-params.ts +153 -0
  378. package/src/kinds/head-presets.ts +152 -0
  379. package/src/kinds/head-rim.ts +128 -0
  380. package/src/kinds/head.ts +171 -0
  381. package/src/kinds/hull-lines.ts +160 -0
  382. package/src/kinds/hull-skin.ts +83 -0
  383. package/src/kinds/hull.ts +209 -0
  384. package/src/kinds/index.ts +63 -0
  385. package/src/kinds/mass.ts +110 -0
  386. package/src/kinds/openings.ts +141 -0
  387. package/src/kinds/planned.ts +12 -0
  388. package/src/kinds/roof-chimney.ts +100 -0
  389. package/src/kinds/roof-shape.ts +230 -0
  390. package/src/kinds/roof.ts +250 -0
  391. package/src/kinds/vegetation-fronds.ts +126 -0
  392. package/src/kinds/vegetation-impostor.ts +118 -0
  393. package/src/kinds/vegetation-leaves.ts +168 -0
  394. package/src/kinds/vegetation-mesh.ts +174 -0
  395. package/src/kinds/vegetation-params.ts +147 -0
  396. package/src/kinds/vegetation-presets.ts +184 -0
  397. package/src/kinds/vegetation-skeleton.ts +168 -0
  398. package/src/kinds/vegetation.ts +199 -0
  399. package/src/kinds/vehicle.ts +144 -0
  400. package/src/material/generator.ts +56 -0
  401. package/src/material/index.ts +29 -0
  402. package/src/material/paint/bond.ts +60 -0
  403. package/src/material/paint/canvas.ts +91 -0
  404. package/src/material/paint/cell.ts +39 -0
  405. package/src/material/paint/cells-round.ts +119 -0
  406. package/src/material/paint/cells.ts +111 -0
  407. package/src/material/paint/compose.ts +53 -0
  408. package/src/material/paint/concrete.ts +62 -0
  409. package/src/material/paint/courses.ts +85 -0
  410. package/src/material/paint/decode-png.ts +100 -0
  411. package/src/material/paint/default-sheet.ts +61 -0
  412. package/src/material/paint/foliage-broad.ts +126 -0
  413. package/src/material/paint/foliage-common.ts +93 -0
  414. package/src/material/paint/foliage-compound.ts +135 -0
  415. package/src/material/paint/foliage-conifer.ts +143 -0
  416. package/src/material/paint/foliage-raster.ts +196 -0
  417. package/src/material/paint/foliage.ts +23 -0
  418. package/src/material/paint/masks.ts +44 -0
  419. package/src/material/paint/noise.ts +52 -0
  420. package/src/material/paint/normal.ts +52 -0
  421. package/src/material/paint/orm.ts +95 -0
  422. package/src/material/paint/planks.ts +56 -0
  423. package/src/material/paint/plates.ts +72 -0
  424. package/src/material/paint/shapes.ts +28 -0
  425. package/src/material/paint/slate.ts +52 -0
  426. package/src/material/paint/stamp.ts +40 -0
  427. package/src/material/paint/strip.ts +42 -0
  428. package/src/material/paint/surface.ts +60 -0
  429. package/src/material/paint/types.ts +51 -0
  430. package/src/material/paint/validate.ts +58 -0
  431. package/src/material/palette.ts +43 -0
  432. package/src/material/shader-godot.ts +77 -0
  433. package/src/material/shader-three.ts +103 -0
  434. package/src/material/shader.ts +100 -0
  435. package/src/material/trim-sheet.ts +100 -0
  436. package/src/neural/generator.ts +37 -0
  437. package/src/neural/images.ts +27 -0
  438. package/src/neural/index.ts +10 -0
  439. package/src/passes/bake-ao.ts +183 -0
  440. package/src/passes/grime.ts +37 -0
  441. package/src/passes/index.ts +8 -0
  442. package/src/passes/livery.ts +66 -0
  443. package/src/passes/optimize-cache.ts +175 -0
  444. package/src/passes/optimize-weld.ts +147 -0
  445. package/src/passes/optimize.ts +85 -0
  446. package/src/passes/ray-grid.ts +237 -0
  447. package/src/passes/weighted-normals.ts +125 -0
  448. package/src/primitives/beam.ts +60 -0
  449. package/src/primitives/bevel-box.ts +140 -0
  450. package/src/primitives/box.ts +90 -0
  451. package/src/primitives/cell.ts +115 -0
  452. package/src/primitives/check.ts +49 -0
  453. package/src/primitives/closed.ts +74 -0
  454. package/src/primitives/erosion.ts +183 -0
  455. package/src/primitives/font-5x7.ts +117 -0
  456. package/src/primitives/heightfield.ts +101 -0
  457. package/src/primitives/index.ts +56 -0
  458. package/src/primitives/lathe-facets.ts +45 -0
  459. package/src/primitives/lathe.ts +174 -0
  460. package/src/primitives/lattice.ts +131 -0
  461. package/src/primitives/loft.ts +174 -0
  462. package/src/primitives/mc-table.ts +41 -0
  463. package/src/primitives/member.ts +130 -0
  464. package/src/primitives/noise.ts +157 -0
  465. package/src/primitives/planar.ts +55 -0
  466. package/src/primitives/prism.ts +85 -0
  467. package/src/primitives/sculpt-edit.ts +212 -0
  468. package/src/primitives/sculpt-tree.ts +226 -0
  469. package/src/primitives/sculpt.ts +80 -0
  470. package/src/primitives/sdf-grid.ts +131 -0
  471. package/src/primitives/sdf-ops.ts +145 -0
  472. package/src/primitives/sdf.ts +83 -0
  473. package/src/primitives/strip-uv.ts +26 -0
  474. package/src/primitives/surface-nets.ts +161 -0
  475. package/src/primitives/sweep.ts +162 -0
  476. package/src/primitives/terrain-color.ts +85 -0
  477. package/src/primitives/terrain-grid.ts +137 -0
  478. package/src/primitives/text-layout.ts +85 -0
  479. package/src/primitives/text.ts +197 -0
  480. package/src/primitives/tiled.ts +93 -0
  481. package/src/primitives/transport.ts +85 -0
  482. package/src/primitives/triangulate.ts +42 -0
  483. package/src/primitives/value-noise.ts +62 -0
  484. package/src/qa/checks.ts +110 -0
  485. package/src/qa/clash.ts +177 -0
  486. package/src/qa/floating.ts +63 -0
  487. package/src/qa/gates.ts +126 -0
  488. package/src/qa/geometry.ts +187 -0
  489. package/src/qa/index.ts +28 -0
  490. package/src/qa/inside.ts +114 -0
  491. package/src/qa/inspect.ts +107 -0
  492. package/src/qa/lods.ts +97 -0
  493. package/src/qa/measures.ts +67 -0
  494. package/src/qa/occlusion.ts +166 -0
  495. package/src/qa/sanity.ts +137 -0
  496. package/src/qa/types.ts +17 -0
  497. package/src/render/beauty.ts +115 -0
  498. package/src/render/camera.ts +142 -0
  499. package/src/render/depth-map.ts +191 -0
  500. package/src/render/font.ts +76 -0
  501. package/src/render/ground.ts +87 -0
  502. package/src/render/index.ts +34 -0
  503. package/src/render/lighting.ts +95 -0
  504. package/src/render/lod-strip.ts +34 -0
  505. package/src/render/metrics.ts +147 -0
  506. package/src/render/options.ts +81 -0
  507. package/src/render/photo-match.ts +28 -0
  508. package/src/render/raster.ts +143 -0
  509. package/src/render/resolve.ts +109 -0
  510. package/src/render/review-prompt.ts +59 -0
  511. package/src/render/review-warnings.ts +92 -0
  512. package/src/render/review.ts +103 -0
  513. package/src/render/scene.ts +119 -0
  514. package/src/render/shade.ts +199 -0
  515. package/src/render/sheet.ts +88 -0
  516. package/src/render/texture.ts +97 -0
  517. package/src/render/thumbnail.ts +75 -0
  518. package/src/render/vis-buffer.ts +119 -0
  519. package/src/testing/assert.ts +115 -0
  520. package/src/testing/compiled.ts +31 -0
  521. package/src/testing/hash.ts +14 -0
  522. package/src/testing/index.ts +14 -0
  523. package/src/testing/snapshot.ts +37 -0
  524. package/src/types/gltf-validator.d.ts +13 -0
@@ -0,0 +1,1370 @@
1
+ // Tiger I, mid production (Henschel, Oct–Nov 1943, Eastern Front): factory Zimmerit, low cast
2
+ // cupola with 7 periscopes, single centred Bosch light, spare links on the turret sides, S-mine
3
+ // dischargers; no Feifel filters, no turret smoke dischargers. Real: hull 6.316 m (8.45 m with gun)
4
+ // × 3.56 m (battle tracks) × 3.00 m; 8 stations at 515 mm, 800 mm wheels, 725 mm track.
5
+
6
+ import {
7
+ applyAffine,
8
+ beam,
9
+ box,
10
+ type Check,
11
+ clear,
12
+ cylinder,
13
+ defineAsset,
14
+ frame,
15
+ grime,
16
+ hex,
17
+ type Lod,
18
+ lathe,
19
+ type Mesh,
20
+ MeshBuilder,
21
+ merge,
22
+ noOverlap,
23
+ place,
24
+ planar,
25
+ prism,
26
+ type Rng,
27
+ symmetric,
28
+ touches,
29
+ translate,
30
+ type Vec2,
31
+ type Vec3,
32
+ } from '@developerz.ai/ts23d';
33
+
34
+ const dunkelgelb = hex('#b89d63');
35
+ const rotbraun = hex('#6e3b2a');
36
+ const olivgruen = hex('#55603a');
37
+ const paintDark = hex('#8a744a');
38
+ const rubber = hex('#262422');
39
+ const steel = hex('#4d4943');
40
+ const trackSteel = hex('#4a4038');
41
+ const black = hex('#161616');
42
+ const white = hex('#e8e6dc');
43
+ const wood = hex('#7a5a3a');
44
+ const rust = hex('#5a3a2a');
45
+
46
+ // Running gear (metres; hull frame +X front, Z up). Station pitch 515 mm (tiger1.info).
47
+ const TRACK_Y = 1.4175;
48
+ const TRACK_W = 0.725;
49
+ const LINK_T = 0.06;
50
+ const WHEEL_R = 0.4;
51
+ const WHEEL_Z = 0.46;
52
+ const STATIONS = Array.from({ length: 8 }, (_, i) => 1.8025 - i * 0.515);
53
+ const SPROCKET = { x: 2.62, z: 0.64, r: 0.4 };
54
+ const IDLER = { x: -2.68, z: 0.66, r: 0.36 };
55
+ /** Guide horns sit on the centre of the original 520 mm track, 102.5 mm inboard. */
56
+ const HORN_Y = -0.1025;
57
+ const HULL_FRONT = 3.158;
58
+ const HULL_REAR = -3.158;
59
+ const DECK = 1.8;
60
+ const PANNIER_Z = 1.16;
61
+ const SIDE_Y = 1.61;
62
+ /** The turret sits on a ring collar above the deck, so deck stowage clears its traverse. */
63
+ const RING = 0.08;
64
+ /** Plate angles from vertical (Jentz armour table via armorsite; recipes/tiger_1/README.md). */
65
+ const TAN9 = Math.tan((9 * Math.PI) / 180);
66
+ const TAN25 = Math.tan((25 * Math.PI) / 180);
67
+ const TAN81 = Math.tan((81 * Math.PI) / 180);
68
+ const BELLY = 0.47;
69
+ const REAR_LOW = 0.95;
70
+ /** Rear plate: 80 mm at 9°, foot at the hull's rearmost point, top leaning in toward the deck. */
71
+ const rearX = (z: number): number => HULL_REAR + (z - REAR_LOW) * TAN9;
72
+ const NOSE_TOP: Vec2 = [HULL_FRONT, 1.2];
73
+ const NOSE_FOOT: Vec2 = [HULL_FRONT - 0.3 * TAN25, 0.9];
74
+ const GLACIS_TOP: Vec2 = [HULL_FRONT - 0.1 * TAN81, 1.3];
75
+ /** Driver's front plate: 100 mm at 9°. */
76
+ const frontX = (z: number): number => GLACIS_TOP[0] - (z - GLACIS_TOP[1]) * TAN9;
77
+ const TURRET_AT: Vec3 = [0.15, 0, DECK + RING];
78
+
79
+ type Circle = { readonly x: number; readonly z: number; readonly r: number };
80
+ type Station = { readonly p: Vec2; readonly pitch: number; readonly len: number };
81
+
82
+ // ---------------------------------------------------------------- track belt
83
+
84
+ /** Clockwise (x right, z up) convex hull of points: walking it, outward is on the left. */
85
+ function hullCw(pts: Vec2[]): Vec2[] {
86
+ const s = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
87
+ const cr = (o: Vec2, a: Vec2, b: Vec2): number =>
88
+ (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
89
+ const half = (list: Vec2[]): Vec2[] => {
90
+ const h: Vec2[] = [];
91
+ for (const p of list) {
92
+ while (h.length >= 2 && cr(h[h.length - 2] as Vec2, h[h.length - 1] as Vec2, p) <= 0) h.pop();
93
+ h.push(p);
94
+ }
95
+ h.pop();
96
+ return h;
97
+ };
98
+ return [...half(s), ...half([...s].reverse())].reverse();
99
+ }
100
+
101
+ /** Evenly re-sample a closed polyline into n stations with their pitch (for place()). */
102
+ function resample(poly: readonly Vec2[], n: number): Station[] {
103
+ const seg = poly.map((a, i) => {
104
+ const b = poly[(i + 1) % poly.length] as Vec2;
105
+ return Math.hypot(b[0] - a[0], b[1] - a[1]);
106
+ });
107
+ const total = seg.reduce((s, l) => s + l, 0);
108
+ const out: Station[] = [];
109
+ let i = 0;
110
+ let acc = 0;
111
+ for (let k = 0; k < n; k++) {
112
+ const d = (k / n) * total;
113
+ while (acc + (seg[i] ?? 0) < d && i < poly.length - 1) acc += seg[i++] ?? 0;
114
+ const a = poly[i] as Vec2;
115
+ const b = poly[(i + 1) % poly.length] as Vec2;
116
+ const t = (d - acc) / (seg[i] || 1);
117
+ out.push({
118
+ p: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
119
+ pitch: Math.atan2(-(b[1] - a[1]), b[0] - a[0]),
120
+ len: total / n,
121
+ });
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /** The track's centre line: a belt around sprocket, idler and road wheels, sagging on top. */
127
+ function beltLine(wheels: readonly Circle[], ends: readonly Circle[], sag: number): Vec2[] {
128
+ const off = LINK_T / 2;
129
+ const pts: Vec2[] = [];
130
+ for (const c of [...wheels, ...ends])
131
+ for (let i = 0; i < 72; i++) {
132
+ const t = (i / 72) * Math.PI * 2;
133
+ pts.push([c.x + (c.r + off) * Math.cos(t), c.z + (c.r + off) * Math.sin(t)]);
134
+ }
135
+ const dense = resample(hullCw(pts), 600);
136
+ const x0 = Math.min(...ends.map((c) => c.x));
137
+ const x1 = Math.max(...ends.map((c) => c.x));
138
+ const env = (x: number): number =>
139
+ Math.max(
140
+ 0,
141
+ ...wheels.map((w) =>
142
+ Math.abs(x - w.x) < w.r + off ? w.z + Math.sqrt((w.r + off) ** 2 - (x - w.x) ** 2) : 0,
143
+ ),
144
+ );
145
+ return dense.map(({ p, pitch }) => {
146
+ const topRun = Math.cos(pitch) > 0.8 && p[1] > WHEEL_Z;
147
+ if (!topRun || p[0] <= x0 || p[0] >= x1) return p;
148
+ const drop = sag * Math.sin((Math.PI * (p[0] - x0)) / (x1 - x0));
149
+ return [p[0], Math.max(env(p[0]), p[1] - drop)];
150
+ });
151
+ }
152
+
153
+ /** One cast Kgs 63/725 link in local space: X along the run, Y across, Z outward. */
154
+ function trackLink(pitch: number): Mesh {
155
+ return merge([
156
+ box({
157
+ size: [pitch * 0.86, TRACK_W, LINK_T * 0.6],
158
+ at: [0, 0, -LINK_T * 0.3],
159
+ color: trackSteel,
160
+ }),
161
+ box({ size: [0.035, TRACK_W * 0.94, LINK_T * 0.45], at: [0, 0, 0], color: trackSteel }),
162
+ box({
163
+ size: [pitch * 0.6, 0.025, 0.08],
164
+ at: [0, HORN_Y + 0.025, -LINK_T * 0.3 - 0.08],
165
+ color: steel,
166
+ }),
167
+ box({
168
+ size: [pitch * 0.6, 0.025, 0.08],
169
+ at: [0, HORN_Y - 0.025, -LINK_T * 0.3 - 0.08],
170
+ color: steel,
171
+ }),
172
+ ]);
173
+ }
174
+
175
+ function trackMesh(lod: Lod, line: readonly Vec2[], y: number): Mesh {
176
+ if (lod === 2) {
177
+ // Outer skin of the belt: push the centre line out by half a link along its normal.
178
+ const ring = resample(line, 22).map(
179
+ (s): Vec2 => [
180
+ s.p[0] + (Math.sin(s.pitch) * LINK_T) / 2,
181
+ s.p[1] + (Math.cos(s.pitch) * LINK_T) / 2,
182
+ ],
183
+ );
184
+ return translate(prism({ profile: ring, depth: TRACK_W, plane: 'xz', color: trackSteel }), [
185
+ 0,
186
+ y,
187
+ 0,
188
+ ]);
189
+ }
190
+ const perimeter = resample(line, 1)[0]?.len ?? 1;
191
+ const stations = resample(line, Math.round(perimeter / (lod === 0 ? 0.13 : 0.32)));
192
+ const len = stations[0]?.len ?? 0.13;
193
+ const unit =
194
+ lod === 0
195
+ ? trackLink(len)
196
+ : box({ size: [len * 1.04, TRACK_W, LINK_T], at: [0, 0, -LINK_T / 2], color: trackSteel });
197
+ return merge(stations.map((s) => place(unit, { at: [s.p[0], y, s.p[1]], pitch: s.pitch })));
198
+ }
199
+
200
+ // ---------------------------------------------------------------- wheels
201
+
202
+ /** One steel disc with a solid rubber tyre, centred on `y` (axis Y), dished toward ±Y faces. */
203
+ function disc(lod: Lod, r: number, w: number, y: number, bolts: boolean): Mesh {
204
+ const segs = [20, 12, 8][lod] ?? 8;
205
+ const h = w / 2;
206
+ const tyre = lathe({
207
+ profile: [
208
+ [r - 0.05, y - h],
209
+ [r - 0.008, y - h],
210
+ [r, y - h + 0.012],
211
+ [r, y + h - 0.012],
212
+ [r - 0.008, y + h],
213
+ [r - 0.05, y + h],
214
+ ],
215
+ segments: segs,
216
+ axis: 'y',
217
+ color: rubber,
218
+ });
219
+ const face: Vec2[] = [
220
+ [r - 0.05, h - 0.004],
221
+ [r * 0.75, h - 0.025],
222
+ [r * 0.38, h - 0.03],
223
+ [r * 0.26, h + 0.006],
224
+ [r * 0.12, h + 0.01],
225
+ [0, h + 0.01],
226
+ ];
227
+ const back = face.map(([fr, fz]) => [fr, -fz] as Vec2).reverse();
228
+ const parts = [
229
+ tyre,
230
+ lathe({
231
+ profile: [...back, ...face].map(([fr, fz]) => [fr, fz + y] as Vec2),
232
+ segments: segs,
233
+ axis: 'y',
234
+ color: dunkelgelb,
235
+ }),
236
+ ];
237
+ if (bolts) {
238
+ const bolt = box({ size: [0.03, 0.012, 0.03], at: [0, 0, -0.015], color: paintDark });
239
+ for (let i = 0; i < 8; i++) {
240
+ const a = (i / 8) * Math.PI * 2;
241
+ parts.push(
242
+ place(bolt, { at: [Math.cos(a) * r * 0.2, y + h + 0.004, Math.sin(a) * r * 0.2] }),
243
+ );
244
+ }
245
+ }
246
+ return merge(parts);
247
+ }
248
+
249
+ /**
250
+ * Schachtellaufwerk station, left side (+Y outboard). Type A: a centre pair straddling the guide
251
+ * horns plus the outermost wheel; type B: an inner wheel plus an outer one. Layers never overlap
252
+ * axially, so neighbouring stations' wheels interleave without touching.
253
+ */
254
+ function station(lod: Lod, typeA: boolean): Mesh {
255
+ if (typeA) {
256
+ const outer = disc(lod, WHEEL_R, 0.09, 0.27, lod === 0);
257
+ if (lod > 0) return outer;
258
+ return merge([
259
+ outer,
260
+ disc(lod, WHEEL_R, 0.08, HORN_Y - 0.083, false),
261
+ disc(lod, WHEEL_R, 0.08, HORN_Y + 0.083, false),
262
+ ]);
263
+ }
264
+ const outer = disc(lod, WHEEL_R, 0.09, 0.12, lod === 0);
265
+ return lod > 0 ? outer : merge([outer, disc(lod, WHEEL_R, 0.08, -0.3, false)]);
266
+ }
267
+
268
+ /** Front drive sprocket: two 18-tooth rings straddling the horns, on a dished hub. */
269
+ function sprocket(lod: Lod, r: number): Mesh {
270
+ const segs = [24, 12, 8][lod] ?? 8;
271
+ const ring = (y: number): Mesh =>
272
+ lathe({
273
+ profile: [
274
+ [0.12, y - 0.03],
275
+ [r - 0.07, y - 0.03],
276
+ [r - 0.06, y],
277
+ [r - 0.07, y + 0.03],
278
+ [0.12, y + 0.03],
279
+ ],
280
+ segments: segs,
281
+ axis: 'y',
282
+ color: paintDark,
283
+ });
284
+ const hub = lathe({
285
+ profile: [
286
+ [0.2, -0.32],
287
+ [0.22, -0.28],
288
+ [0.22, 0.08],
289
+ [0.2, 0.12],
290
+ [0.1, 0.15],
291
+ [0, 0.15],
292
+ ],
293
+ segments: segs,
294
+ axis: 'y',
295
+ color: dunkelgelb,
296
+ });
297
+ const parts = [ring(HORN_Y + 0.12), ring(HORN_Y - 0.12), hub];
298
+ if (lod === 0) {
299
+ const tooth = box({ size: [0.07, 0.06, 0.1], color: steel });
300
+ for (const y of [HORN_Y + 0.12, HORN_Y - 0.12])
301
+ for (let i = 0; i < 18; i++) {
302
+ const a = (i / 18) * Math.PI * 2;
303
+ parts.push(
304
+ place(tooth, { at: [Math.sin(a) * (r - 0.08), y, Math.cos(a) * (r - 0.08)], pitch: a }),
305
+ );
306
+ }
307
+ const bolt = box({ size: [0.03, 0.02, 0.03], at: [0, 0, -0.015], color: steel });
308
+ for (let i = 0; i < 10; i++) {
309
+ const a = (i / 10) * Math.PI * 2;
310
+ parts.push(place(bolt, { at: [Math.cos(a) * 0.15, 0.16, Math.sin(a) * 0.15] }));
311
+ }
312
+ }
313
+ return merge(parts);
314
+ }
315
+
316
+ /** Rear idler: a dished double disc straddling the horns. */
317
+ const idler = (lod: Lod): Mesh =>
318
+ merge([
319
+ disc(lod, IDLER.r, 0.1, HORN_Y + 0.13, lod === 0),
320
+ disc(lod, IDLER.r, 0.1, HORN_Y - 0.13, false),
321
+ ]);
322
+
323
+ // ---------------------------------------------------------------- finish passes
324
+
325
+ /** Mud in world space for a part authored around its own pivot. */
326
+ const muddy = (m: Mesh, at: Vec3 = [0, 0, 0], strength = 0.5): Mesh =>
327
+ translate(grime(translate(m, at), { ground: 1.3, groundStrength: strength }), [
328
+ -at[0],
329
+ -at[1],
330
+ -at[2],
331
+ ]);
332
+
333
+ interface Blob {
334
+ readonly c: Vec3;
335
+ readonly r: Vec3;
336
+ readonly color: Vec3;
337
+ }
338
+
339
+ /** Three-tone camouflage: soft ellipsoids seeded from the rng, sprayed over the paint only. */
340
+ function camoBlobs(rng: Rng): Blob[] {
341
+ const out: Blob[] = [];
342
+ for (let i = 0; i < 22; i++) {
343
+ out.push({
344
+ c: [rng.range(-3.4, 5.2), rng.range(-1.9, 1.9), rng.range(0.4, 3.0)],
345
+ r: [rng.range(0.25, 0.7), rng.range(0.35, 1.2), rng.range(0.2, 0.45)],
346
+ color: i % 2 === 0 ? olivgruen : rotbraun,
347
+ });
348
+ }
349
+ return out;
350
+ }
351
+
352
+ /** Split long edges until every triangle is under `max` metres, so vertex-colour camo has room. */
353
+ function tessellate(m: Mesh, max: number): Mesh {
354
+ const b = new MeshBuilder();
355
+ type V = { p: Vec3; n: Vec3; uv: Vec2; color: Vec3 };
356
+ const read = (i: number): V => ({
357
+ p: [m.positions[i * 3] ?? 0, m.positions[i * 3 + 1] ?? 0, m.positions[i * 3 + 2] ?? 0],
358
+ n: [m.normals[i * 3] ?? 0, m.normals[i * 3 + 1] ?? 0, m.normals[i * 3 + 2] ?? 1],
359
+ uv: [m.uvs[i * 2] ?? 0, m.uvs[i * 2 + 1] ?? 0],
360
+ color: [m.colors[i * 3] ?? 0, m.colors[i * 3 + 1] ?? 0, m.colors[i * 3 + 2] ?? 0],
361
+ });
362
+ const mid = (x: V, y: V): V => ({
363
+ p: [(x.p[0] + y.p[0]) / 2, (x.p[1] + y.p[1]) / 2, (x.p[2] + y.p[2]) / 2],
364
+ n: x.n,
365
+ uv: [(x.uv[0] + y.uv[0]) / 2, (x.uv[1] + y.uv[1]) / 2],
366
+ color: x.color,
367
+ });
368
+ const d = (x: V, y: V): number => Math.hypot(x.p[0] - y.p[0], x.p[1] - y.p[1], x.p[2] - y.p[2]);
369
+ const split = (x: V, y: V, z: V): void => {
370
+ const [e0, e1, e2] = [d(x, y), d(y, z), d(z, x)];
371
+ const top = Math.max(e0, e1, e2);
372
+ if (top <= max) {
373
+ b.tri(b.vertex(x), b.vertex(y), b.vertex(z));
374
+ return;
375
+ }
376
+ if (top === e0) {
377
+ const q = mid(x, y);
378
+ split(x, q, z);
379
+ split(q, y, z);
380
+ } else if (top === e1) {
381
+ const q = mid(y, z);
382
+ split(x, y, q);
383
+ split(x, q, z);
384
+ } else {
385
+ const q = mid(z, x);
386
+ split(x, y, q);
387
+ split(q, y, z);
388
+ }
389
+ };
390
+ for (let t = 0; t < m.indices.length; t += 3)
391
+ split(read(m.indices[t] ?? 0), read(m.indices[t + 1] ?? 0), read(m.indices[t + 2] ?? 0));
392
+ return b.build();
393
+ }
394
+
395
+ function camo(m: Mesh, blobs: readonly Blob[], at: Vec3 = [0, 0, 0]): Mesh {
396
+ const colors = new Float32Array(m.colors);
397
+ const isPaint = (i: number): boolean =>
398
+ [0, 1, 2].every((k) => Math.abs((m.colors[i * 3 + k] ?? 0) - (dunkelgelb[k] ?? 0)) < 1e-4);
399
+ for (let i = 0; i < m.positions.length / 3; i++) {
400
+ if (!isPaint(i)) continue;
401
+ const p: Vec3 = [
402
+ (m.positions[i * 3] ?? 0) + at[0],
403
+ (m.positions[i * 3 + 1] ?? 0) + at[1],
404
+ (m.positions[i * 3 + 2] ?? 0) + at[2],
405
+ ];
406
+ for (const b of blobs) {
407
+ const d = Math.hypot(
408
+ (p[0] - b.c[0]) / b.r[0],
409
+ (p[1] - b.c[1]) / b.r[1],
410
+ (p[2] - b.c[2]) / b.r[2],
411
+ );
412
+ if (d >= 1) continue;
413
+ const w = d < 0.75 ? 1 : 1 - (d - 0.75) / 0.25;
414
+ for (let k = 0; k < 3; k++)
415
+ colors[i * 3 + k] = (colors[i * 3 + k] ?? 0) * (1 - w) + (b.color[k] ?? 0) * w;
416
+ }
417
+ }
418
+ return { ...m, colors };
419
+ }
420
+
421
+ type WallPoint = { readonly p: Vec2; readonly n: Vec2 };
422
+
423
+ /**
424
+ * Factory Zimmerit: bands of vertical ridges along a wall given in plan (point + outward normal).
425
+ * LOD0 geometry only; becomes a trim-sheet strip once the material generator lands.
426
+ */
427
+ function zimmerit(
428
+ path: readonly WallPoint[],
429
+ z0: number,
430
+ z1: number,
431
+ lean = 0,
432
+ pitch = 0.045,
433
+ ): Mesh {
434
+ const band = 0.16;
435
+ const ridges: Mesh[] = [];
436
+ const bands = Math.max(1, Math.floor((z1 - z0) / band));
437
+ const bh = (z1 - z0) / bands;
438
+ const ridge = box({
439
+ size: [0.012, 0.016, bh - 0.025],
440
+ at: [0.006, 0, 0],
441
+ color: dunkelgelb,
442
+ omit: ['back', 'bottom'],
443
+ });
444
+ for (let i = 0; i + 1 < path.length; i++) {
445
+ const a = path[i] as WallPoint;
446
+ const b = path[i + 1] as WallPoint;
447
+ const len = Math.hypot(b.p[0] - a.p[0], b.p[1] - a.p[1]);
448
+ const count = Math.max(1, Math.floor(len / pitch));
449
+ for (let k = 0; k < count; k++) {
450
+ const t = (k + 0.5) / count;
451
+ const x = a.p[0] + (b.p[0] - a.p[0]) * t;
452
+ const y = a.p[1] + (b.p[1] - a.p[1]) * t;
453
+ const yaw = Math.atan2(a.n[1] + (b.n[1] - a.n[1]) * t, a.n[0] + (b.n[0] - a.n[0]) * t);
454
+ for (let j = 0; j < bands; j++) {
455
+ // Sloped plates lean inward by `lean` m per m of height; ridges tilt with them.
456
+ const zb = z0 + j * bh + 0.0125;
457
+ const inward = lean * (zb - z0);
458
+ ridges.push(
459
+ place(ridge, {
460
+ at: [x - Math.cos(yaw) * inward, y - Math.sin(yaw) * inward, zb],
461
+ yaw,
462
+ pitch: -Math.atan(lean),
463
+ }),
464
+ );
465
+ }
466
+ }
467
+ }
468
+ return merge(ridges);
469
+ }
470
+
471
+ const straight = (a: Vec2, b: Vec2, n: Vec2): WallPoint[] => [
472
+ { p: a, n },
473
+ { p: b, n },
474
+ ];
475
+
476
+ // ---------------------------------------------------------------- small details
477
+
478
+ function cable(points: readonly Vec3[], d: number, color: Vec3): Mesh {
479
+ return merge(
480
+ points
481
+ .slice(1)
482
+ .map((to, i) => beam({ from: points[i] as Vec3, to, width: d, segments: 5, color })),
483
+ );
484
+ }
485
+
486
+ /** A cable eye: a small ring (lathe of a circular section) facing ±Y. */
487
+ const eye = (at: Vec3): Mesh =>
488
+ lathe({
489
+ profile: Array.from({ length: 7 }, (_, i): Vec2 => {
490
+ const a = (i / 6) * Math.PI * 2;
491
+ return [0.05 + 0.014 * Math.cos(a), 0.014 * Math.sin(a)];
492
+ }),
493
+ segments: 10,
494
+ axis: 'y',
495
+ at,
496
+ color: steel,
497
+ });
498
+
499
+ /** A flat polygon on a side wall facing ±Y: (u, v) = (along the wall toward the front, up). */
500
+ function sideDecal(
501
+ poly: readonly Vec2[],
502
+ s: number,
503
+ x: number,
504
+ y: number,
505
+ z: number,
506
+ c: Vec3,
507
+ ): Mesh {
508
+ // CCW as seen from outside: for +Y walls U = -X, for -Y walls U = +X.
509
+ return planar({ points: poly.map(([u, v]): Vec3 => [x - s * u, y, z + v]), color: c });
510
+ }
511
+
512
+ const cross = (l: number, t: number): Vec2[] => [
513
+ [l, -t],
514
+ [l, t],
515
+ [t, t],
516
+ [t, l],
517
+ [-t, l],
518
+ [-t, t],
519
+ [-l, t],
520
+ [-l, -t],
521
+ [-t, -t],
522
+ [-t, -l],
523
+ [t, -l],
524
+ [t, -t],
525
+ ];
526
+
527
+ /** Seven-segment digit as rectangles (u right, v up), cell h/2 wide × h tall. */
528
+ function digitRects(d: number, h: number): Vec2[][] {
529
+ const seg = [
530
+ [0, 2, 1, 2],
531
+ [1, 1, 1, 2],
532
+ [1, 0, 1, 1],
533
+ [0, 0, 1, 0],
534
+ [0, 0, 0, 1],
535
+ [0, 1, 0, 2],
536
+ [0, 1, 1, 1],
537
+ ];
538
+ const on = ['1111110', '0110000', '1101101', '1111001'][d] ?? '1111110';
539
+ const t = 0.045 * h;
540
+ const out: Vec2[][] = [];
541
+ seg.forEach(([x0 = 0, y0 = 0, x1 = 0, y1 = 0], i) => {
542
+ if (on[i] !== '1') return;
543
+ const [ua, va] = [Math.min(x0, x1) * h * 0.5, Math.min(y0, y1) * h * 0.5];
544
+ const [ub, vb] = [Math.max(x0, x1) * h * 0.5, Math.max(y0, y1) * h * 0.5];
545
+ out.push([
546
+ [ua - t, va - t],
547
+ [ub + t, va - t],
548
+ [ub + t, vb + t],
549
+ [ua - t, vb + t],
550
+ ]);
551
+ });
552
+ return out;
553
+ }
554
+
555
+ // ---------------------------------------------------------------- the asset
556
+
557
+ /** Run a check only at LODs where `part` exists (running gear is dropped at LOD2). */
558
+ const whenBuilt =
559
+ (part: string, check: Check): Check =>
560
+ (asset) =>
561
+ asset.parts.some((p) => p.name === part && p.mesh) ? check(asset) : [];
562
+
563
+ export const tiger_1 = defineAsset({
564
+ name: 'tiger_1',
565
+ kind: 'vehicle',
566
+ description:
567
+ 'Tiger I mid production (late 1943) — Zimmerit, three-tone camo, interleaved wheels, link-by-link track, full stowage',
568
+ tags: ['military', 'tracked', 'ww2', 'hero'],
569
+ sources: [
570
+ 'https://en.wikipedia.org/wiki/Tiger_I (specifications: 6.316 m hull, 8.45 m with gun, 3.56 m, 3.0 m)',
571
+ 'https://tiger1.info/EN/RoadWheelSpacing.html (800 mm wheels, 515 mm station pitch, 725 mm track)',
572
+ 'https://tankmuseum.org/tiger-wheels/ (800 mm steel wheels with solid rubber tyres, 24 per side)',
573
+ 'https://tamiyausa.com/shop/135-military-miniature/german-tiger-i-mid-production (mid production Jul 1943–Jan 1944: new cupola, single headlight, Zimmerit from Sep 1943)',
574
+ 'Jentz & Doyle, Germany’s Tiger Tanks: D.W. to Tiger I (Schiffer 2000), stowage and tool layout',
575
+ ],
576
+ real: {
577
+ length: 8.45,
578
+ width: 3.56,
579
+ height: 3.0,
580
+ measures: [
581
+ {
582
+ label: 'hull length',
583
+ axis: 'x',
584
+ value: 6.316,
585
+ parts: ['hull'],
586
+ source: 'https://en.wikipedia.org/wiki/Tiger_I',
587
+ },
588
+ {
589
+ label: 'road wheel diameter',
590
+ axis: 'z',
591
+ value: 0.8,
592
+ parts: ['wheel_l_0'],
593
+ source: 'https://tankmuseum.org/tiger-wheels/',
594
+ },
595
+ {
596
+ label: 'track width',
597
+ axis: 'y',
598
+ value: 0.725,
599
+ parts: ['track_l'],
600
+ source: 'https://tiger1.info/EN/RoadWheelSpacing.html',
601
+ },
602
+ {
603
+ label: 'track centres',
604
+ axis: 'y',
605
+ value: 2.835,
606
+ between: ['track_l', 'track_r'],
607
+ source: '3.56 m overall − 0.725 m track',
608
+ },
609
+ {
610
+ label: '8.8 cm KwK 36 L/56 barrel length',
611
+ axis: 'x',
612
+ value: 4.928,
613
+ parts: ['gun_barrel'],
614
+ source: '56 calibres × 88 mm',
615
+ },
616
+ {
617
+ label: 'wheel station pitch × 7',
618
+ axis: 'x',
619
+ value: 3.605,
620
+ between: ['wheel_l_0', 'wheel_l_7'],
621
+ source: 'https://tiger1.info/EN/RoadWheelSpacing.html',
622
+ },
623
+ ],
624
+ },
625
+ budget: { triangles: [110000, 18000, 800] },
626
+ checks: [
627
+ clear('track_*', 'hull'),
628
+ clear('track_*', 'fenders'),
629
+ clear('wheel_*', 'hull'),
630
+ clear('wheel_*', 'fenders'),
631
+ noOverlap('wheel_l_*'),
632
+ noOverlap('wheel_r_*'),
633
+ touches('turret_body', 'hull'),
634
+ whenBuilt('sprocket_l', touches('sprocket_l', 'hull')),
635
+ whenBuilt('idler_l', touches('idler_l', 'hull')),
636
+ symmetric('_l', '_r', 'y'),
637
+ ],
638
+ build(ctx) {
639
+ const lod = ctx.lod;
640
+ const segs = [20, 14, 8][lod] ?? 8;
641
+ const blobs = camoBlobs(ctx.rng.fork('camo'));
642
+ const paintJob = (m: Mesh, at: Vec3 = [0, 0, 0]): Mesh =>
643
+ lod === 2 ? m : camo(tessellate(m, lod === 0 ? 0.3 : 0.6), blobs, at);
644
+ const wheels: Circle[] = STATIONS.map((x) => ({ x, z: WHEEL_Z, r: WHEEL_R }));
645
+ const line = beltLine(wheels, [SPROCKET, IDLER], 0.1);
646
+
647
+ // ---- Running gear, per side ----
648
+ const stubs: Mesh[] = [];
649
+ for (const side of ['l', 'r'] as const) {
650
+ const s = side === 'l' ? 1 : -1;
651
+ const ty = s * TRACK_Y;
652
+ const flip = (m: Mesh): Mesh => (s > 0 ? m : place(m, { yaw: Math.PI }));
653
+ const track = `track_${side}`;
654
+ ctx.part(track, muddy(trackMesh(lod, line, ty), [0, 0, 0], 0.35));
655
+ // Final-drive housing and idler axle stub: what holds sprocket and idler to the hull.
656
+ stubs.push(
657
+ cylinder({
658
+ radius: 0.205,
659
+ length: 0.42,
660
+ segments: segs,
661
+ axis: 'y',
662
+ at: [SPROCKET.x, s * (TRACK_Y - 0.32 - 0.21), SPROCKET.z],
663
+ color: dunkelgelb,
664
+ }),
665
+ cylinder({
666
+ radius: 0.1,
667
+ length: 0.32,
668
+ segments: segs,
669
+ axis: 'y',
670
+ at: [IDLER.x, s * (TRACK_Y + HORN_Y - 0.19 - 0.16), IDLER.z],
671
+ color: dunkelgelb,
672
+ }),
673
+ );
674
+ if (lod === 2) continue;
675
+ STATIONS.forEach((x, i) => {
676
+ const at: Vec3 = [x, ty, WHEEL_Z];
677
+ ctx.part(`wheel_${side}_${i}`, muddy(paintJob(flip(station(lod, i % 2 === 0)), at), at), {
678
+ at,
679
+ motion: { kind: 'spin', axis: [0, 1, 0], rps: 0.8 },
680
+ clearOf: [track],
681
+ });
682
+ });
683
+ const sAt: Vec3 = [SPROCKET.x, ty, SPROCKET.z];
684
+ ctx.part(`sprocket_${side}`, muddy(paintJob(flip(sprocket(lod, SPROCKET.r)), sAt), sAt), {
685
+ at: sAt,
686
+ motion: { kind: 'spin', axis: [0, 1, 0], rps: 0.8 },
687
+ clearOf: [track],
688
+ });
689
+ const iAt: Vec3 = [IDLER.x, ty, IDLER.z];
690
+ ctx.part(`idler_${side}`, muddy(paintJob(flip(idler(lod)), iAt), iAt), {
691
+ at: iAt,
692
+ motion: { kind: 'spin', axis: [0, 1, 0], rps: 0.9 },
693
+ clearOf: [track],
694
+ });
695
+ }
696
+
697
+ // ---- Hull: tub between the tracks + panniers over them (side-profile prisms) ----
698
+ // Lower hull between the tracks, up to the pannier floor; the full-width upper hull sits on it
699
+ // (two solids sharing a face, so inside/outside tests stay exact).
700
+ const noseAt = (z: number): number =>
701
+ NOSE_FOOT[0] +
702
+ ((z - NOSE_FOOT[1]) / (NOSE_TOP[1] - NOSE_FOOT[1])) * (NOSE_TOP[0] - NOSE_FOOT[0]);
703
+ const tub: Vec2[] = [
704
+ [-2.8, BELLY],
705
+ [2.62, BELLY],
706
+ NOSE_FOOT,
707
+ [noseAt(PANNIER_Z), PANNIER_Z],
708
+ [rearX(PANNIER_Z), PANNIER_Z],
709
+ [HULL_REAR, REAR_LOW],
710
+ ];
711
+ const upper: Vec2[] = [
712
+ [rearX(PANNIER_Z), PANNIER_Z],
713
+ [noseAt(PANNIER_Z), PANNIER_Z],
714
+ NOSE_TOP,
715
+ GLACIS_TOP,
716
+ [frontX(DECK), DECK],
717
+ [rearX(DECK), DECK],
718
+ ];
719
+ const hull: Mesh[] = [
720
+ prism({ profile: tub, depth: 2.0, plane: 'xz', color: dunkelgelb, strip: 'iron' }),
721
+ prism({ profile: upper, depth: SIDE_Y * 2, plane: 'xz', color: dunkelgelb, strip: 'iron' }),
722
+ ...stubs,
723
+ ];
724
+ if (lod < 2) {
725
+ for (const s of [1, -1])
726
+ hull.push(
727
+ box({ size: [0.75, 0.62, 0.04], at: [-2.1, s * 0.62, DECK], color: paintDark }),
728
+ cylinder({
729
+ radius: 0.28,
730
+ length: 0.05,
731
+ segments: segs,
732
+ at: [-2.72, s * 0.6, DECK + 0.025],
733
+ color: paintDark,
734
+ }),
735
+ // Driver's and radio operator's hatches.
736
+ cylinder({
737
+ radius: 0.3,
738
+ length: 0.06,
739
+ segments: segs,
740
+ at: [1.95, s * 0.72, DECK + 0.03],
741
+ color: dunkelgelb,
742
+ }),
743
+ );
744
+ hull.push(
745
+ box({ size: [1.0, 0.7, 0.05], at: [-2.05, 0, DECK], color: paintDark }),
746
+ // Driver's visor (left) and bow MG ball mount (right) on the front plate.
747
+ box({ size: [0.1, 0.55, 0.2], at: [2.5, 0.72, 1.48], color: dunkelgelb }),
748
+ box({ size: [0.03, 0.4, 0.035], at: [2.555, 0.72, 1.56], color: black }),
749
+ lathe({
750
+ profile: [
751
+ [0.15, 0],
752
+ [0.16, 0.05],
753
+ [0.12, 0.13],
754
+ [0, 0.16],
755
+ ],
756
+ segments: segs,
757
+ axis: 'x',
758
+ at: [2.46, -0.72, 1.55],
759
+ color: dunkelgelb,
760
+ }),
761
+ beam({
762
+ from: [2.6, -0.72, 1.55],
763
+ to: [2.82, -0.72, 1.55],
764
+ width: 0.04,
765
+ segments: 6,
766
+ color: black,
767
+ }),
768
+ );
769
+ }
770
+ if (lod === 0) {
771
+ // Interlocking plate joints, tow shackles and couplings, grab handles, lifting eyes.
772
+ const handle = (x: number, y: number): Mesh =>
773
+ cable(
774
+ [
775
+ [x, y, DECK],
776
+ [x, y, DECK + 0.06],
777
+ [x - 0.18, y, DECK + 0.06],
778
+ [x - 0.18, y, DECK],
779
+ ],
780
+ 0.02,
781
+ steel,
782
+ );
783
+ for (const s of [1, -1]) {
784
+ hull.push(
785
+ box({
786
+ size: [0.1, 0.08, 0.3],
787
+ at: [HULL_FRONT - 0.05, s * 1.57, 1.25],
788
+ color: dunkelgelb,
789
+ }),
790
+ box({
791
+ size: [0.1, 0.08, 0.3],
792
+ at: [rearX(1.3) + 0.05, s * 1.57, 1.3],
793
+ color: dunkelgelb,
794
+ }),
795
+ box({ size: [0.16, 0.07, 0.14], at: [HULL_FRONT - 0.08, s * 0.85, 0.72], color: steel }),
796
+ box({ size: [0.16, 0.07, 0.14], at: [HULL_REAR + 0.08, s * 0.85, 0.78], color: steel }),
797
+ lathe({
798
+ profile: [
799
+ [0.05, 0],
800
+ [0.05, 0.03],
801
+ [0.025, 0.05],
802
+ [0, 0.05],
803
+ ],
804
+ segments: 8,
805
+ at: [1.2, s * 1.45, DECK],
806
+ color: steel,
807
+ }),
808
+ );
809
+ for (const x of [1.0, -0.4, -1.6]) hull.push(handle(x, s * 1.52));
810
+ }
811
+ // Zimmerit on the pannier sides, front plate, nose and rear plate.
812
+ hull.push(
813
+ zimmerit(straight([2.35, SIDE_Y], [-2.95, SIDE_Y], [0, 1]), PANNIER_Z, DECK),
814
+ zimmerit(straight([-2.95, -SIDE_Y], [2.35, -SIDE_Y], [0, -1]), PANNIER_Z, DECK),
815
+ zimmerit(straight([frontX(1.32), -1.55], [frontX(1.32), 1.55], [1, 0]), 1.32, 1.78, TAN9),
816
+ zimmerit(straight([noseAt(0.92), -0.98], [noseAt(0.92), 0.98], [1, 0]), 0.92, 1.18, -TAN25),
817
+ zimmerit(straight([rearX(1.2), 1.55], [rearX(1.2), -1.55], [-1, 0]), 1.2, 1.78, TAN9),
818
+ );
819
+ // Balkenkreuz on both pannier sides (white edges, black cross).
820
+ for (const s of [1, -1]) {
821
+ hull.push(
822
+ sideDecal(cross(0.26, 0.1), s, -0.9, s * (SIDE_Y + 0.016), 1.48, white),
823
+ sideDecal(cross(0.23, 0.06), s, -0.9, s * (SIDE_Y + 0.02), 1.48, black),
824
+ );
825
+ }
826
+ }
827
+ // Structure (closed plates + drive housings) vs dressing (Zimmerit, decals, fittings): the
828
+ // structural solid is what clearance checks test against, so it stays lean and watertight.
829
+ const structure = 2 + stubs.length;
830
+ ctx.part('hull', muddy(paintJob(merge(hull.slice(0, structure))), [0, 0, 0], 0.45));
831
+ if (hull.length > structure)
832
+ ctx.part('hull_detail', muddy(paintJob(merge(hull.slice(structure))), [0, 0, 0], 0.45));
833
+
834
+ // ---- Fenders: side sections out to the track edge, hinged front mudguards ----
835
+ const fenders: Mesh[] = [];
836
+ for (const s of [1, -1]) {
837
+ fenders.push(
838
+ box({
839
+ size: [5.9, 0.17, 0.025],
840
+ at: [-0.2, s * (SIDE_Y + 0.085), PANNIER_Z - 0.025],
841
+ color: dunkelgelb,
842
+ }),
843
+ planar({
844
+ points: [
845
+ [2.62, s * 1.06, PANNIER_Z],
846
+ [3.3, s * 1.06, 1.05],
847
+ [3.3, s * 1.78, 1.05],
848
+ [2.62, s * 1.78, PANNIER_Z],
849
+ ],
850
+ color: dunkelgelb,
851
+ doubleSided: true,
852
+ }),
853
+ );
854
+ if (lod === 0)
855
+ for (const x of [1.3, -0.2, -1.7])
856
+ fenders.push(
857
+ box({
858
+ size: [0.02, 0.17, 0.035],
859
+ at: [x, s * (SIDE_Y + 0.085), PANNIER_Z - 0.035],
860
+ color: paintDark,
861
+ }),
862
+ );
863
+ }
864
+ ctx.part('fenders', muddy(paintJob(merge(fenders)), [0, 0, 0], 0.4));
865
+
866
+ // ---- Particle sources: exhaust smoke, track dust ----
867
+ for (const [side, s] of [
868
+ ['l', 1],
869
+ ['r', -1],
870
+ ] as const) {
871
+ ctx.emitter(`exhaust_smoke_${side}`, {
872
+ kind: 'smoke',
873
+ at: [rearX(1.08) - 0.095, s * 0.55, 1.8],
874
+ dir: [-0.3, 0, 1],
875
+ rate: 14,
876
+ life: 2.5,
877
+ size: [0.25, 1.4],
878
+ color: hex('#3a3836'),
879
+ speed: 1.5,
880
+ spreadDeg: 15,
881
+ when: 'always',
882
+ });
883
+ ctx.emitter(`track_dust_${side}`, {
884
+ kind: 'dust',
885
+ at: [IDLER.x - 0.3, s * TRACK_Y, 0.15],
886
+ dir: [-1, 0, 0.4],
887
+ rate: 30,
888
+ life: 1.8,
889
+ size: [0.3, 1.8],
890
+ color: hex('#9a8a6a'),
891
+ speed: 1.2,
892
+ spreadDeg: 35,
893
+ when: 'moving',
894
+ });
895
+ }
896
+
897
+ // ---- Exhausts with sheet guards (soot), jack on the rear plate ----
898
+ if (lod < 2) {
899
+ const rear: Mesh[] = [];
900
+ for (const s of [1, -1]) {
901
+ rear.push(
902
+ lathe({
903
+ profile: [
904
+ [0.09, 0],
905
+ [0.09, 0.62],
906
+ [0.11, 0.66],
907
+ [0.07, 0.7],
908
+ ],
909
+ segments: segs,
910
+ at: [rearX(1.08) - 0.095, s * 0.55, 1.08],
911
+ color: rust,
912
+ }),
913
+ box({
914
+ size: [0.03, 0.34, 0.5],
915
+ at: [rearX(1.08) - 0.215, s * 0.55, 1.18],
916
+ color: paintDark,
917
+ }),
918
+ box({
919
+ size: [0.2, 0.025, 0.5],
920
+ at: [rearX(1.08) - 0.1, s * 0.73, 1.18],
921
+ color: paintDark,
922
+ }),
923
+ box({
924
+ size: [0.2, 0.025, 0.5],
925
+ at: [rearX(1.08) - 0.1, s * 0.37, 1.18],
926
+ color: paintDark,
927
+ }),
928
+ );
929
+ }
930
+ if (lod === 0)
931
+ rear.push(
932
+ cylinder({
933
+ radius: 0.08,
934
+ length: 0.55,
935
+ segments: 10,
936
+ axis: 'y',
937
+ at: [rearX(1.55) - 0.08, 0, 1.55],
938
+ color: steel,
939
+ }),
940
+ box({ size: [0.04, 0.06, 0.06], at: [rearX(1.52) - 0.02, 0.22, 1.52], color: steel }),
941
+ box({ size: [0.04, 0.06, 0.06], at: [rearX(1.52) - 0.02, -0.22, 1.52], color: steel }),
942
+ );
943
+ ctx.part(
944
+ 'exhausts',
945
+ grime(merge(rear), {
946
+ ground: 1.3,
947
+ groundStrength: 0.4,
948
+ zones: [
949
+ { at: [HULL_REAR - 0.1, 0.55, 1.8], radius: 0.7, strength: 0.7 },
950
+ { at: [HULL_REAR - 0.1, -0.55, 1.8], radius: 0.7, strength: 0.7 },
951
+ ],
952
+ }),
953
+ );
954
+ }
955
+
956
+ // ---- Stowage and tools on the deck ----
957
+ const tools: Mesh[] = [];
958
+ if (lod < 2) {
959
+ // Bosch headlight centred on the front plate (mid production), cleaning-rod tube.
960
+ tools.push(
961
+ box({ size: [0.1, 0.08, 0.06], at: [2.5, 0, 1.6], color: dunkelgelb }),
962
+ lathe({
963
+ profile: [
964
+ [0.04, 0],
965
+ [0.09, 0.04],
966
+ [0.09, 0.15],
967
+ [0, 0.16],
968
+ ],
969
+ segments: 12,
970
+ axis: 'x',
971
+ at: [2.5, 0, 1.72],
972
+ color: dunkelgelb,
973
+ }),
974
+ cylinder({
975
+ radius: 0.05,
976
+ length: 2.2,
977
+ segments: 8,
978
+ axis: 'x',
979
+ at: [-1.8, 1.47, DECK + 0.05],
980
+ color: dunkelgelb,
981
+ }),
982
+ );
983
+ }
984
+ if (lod === 0) {
985
+ tools.push(
986
+ // Shovel (left front), axe (right front), sledgehammer, crowbar, track tool.
987
+ beam({
988
+ from: [1.35, 1.25, DECK + 0.03],
989
+ to: [0.35, 1.25, DECK + 0.03],
990
+ width: 0.035,
991
+ segments: 5,
992
+ color: wood,
993
+ }),
994
+ box({ size: [0.3, 0.22, 0.02], at: [1.5, 1.25, DECK], color: steel }),
995
+ beam({
996
+ from: [1.35, -1.25, DECK + 0.03],
997
+ to: [0.5, -1.25, DECK + 0.03],
998
+ width: 0.04,
999
+ segments: 5,
1000
+ color: wood,
1001
+ }),
1002
+ box({ size: [0.18, 0.14, 0.035], at: [1.42, -1.25, DECK], color: steel }),
1003
+ beam({
1004
+ from: [-2.3, -1.25, DECK + 0.03],
1005
+ to: [-2.95, -1.25, DECK + 0.03],
1006
+ width: 0.04,
1007
+ segments: 5,
1008
+ color: wood,
1009
+ }),
1010
+ box({ size: [0.1, 0.18, 0.06], at: [-2.25, -1.25, DECK], color: steel }),
1011
+ beam({
1012
+ from: [-0.9, -1.45, DECK + 0.025],
1013
+ to: [-2.6, -1.45, DECK + 0.025],
1014
+ width: 0.035,
1015
+ color: steel,
1016
+ }),
1017
+ beam({
1018
+ from: [2.3, -1.1, DECK + 0.025],
1019
+ to: [2.3, -0.2, DECK + 0.025],
1020
+ width: 0.035,
1021
+ color: steel,
1022
+ }),
1023
+ box({ size: [0.08, 0.1, 0.06], at: [2.3, -1.12, DECK], color: steel }),
1024
+ // Wire cutters, starter crank, jack block.
1025
+ box({ size: [0.06, 0.4, 0.03], at: [2.3, 0.6, DECK], color: steel }),
1026
+ beam({
1027
+ from: [-2.95, -0.3, DECK + 0.025],
1028
+ to: [-2.95, -1.0, DECK + 0.025],
1029
+ width: 0.03,
1030
+ color: steel,
1031
+ }),
1032
+ box({ size: [0.35, 0.3, 0.2], at: [-2.85, 1.2, DECK], color: wood }),
1033
+ );
1034
+ // S-mine dischargers: five tubes round the deck edge.
1035
+ for (const [x, y] of [
1036
+ [2.3, 1.45],
1037
+ [2.3, -1.45],
1038
+ [-0.9, 1.48],
1039
+ [-0.9, -1.48],
1040
+ [-3.0, 0],
1041
+ ] as const)
1042
+ tools.push(
1043
+ lathe({
1044
+ profile: [
1045
+ [0.07, 0],
1046
+ [0.06, 0.04],
1047
+ [0.045, 0.05],
1048
+ [0.045, 0.16],
1049
+ ],
1050
+ segments: 8,
1051
+ at: [x, y, DECK],
1052
+ color: dunkelgelb,
1053
+ }),
1054
+ );
1055
+ // Tow cables along both pannier sides with eyes at the ends.
1056
+ for (const s of [1, -1]) {
1057
+ const y = s * (SIDE_Y + 0.035);
1058
+ tools.push(
1059
+ cable(
1060
+ [
1061
+ [2.1, y, 1.66],
1062
+ [-2.6, y, 1.66],
1063
+ [-2.75, y, 1.53],
1064
+ [-2.6, y, 1.4],
1065
+ [2.0, y, 1.4],
1066
+ ],
1067
+ 0.035,
1068
+ steel,
1069
+ ),
1070
+ eye([2.16, y, 1.66]),
1071
+ eye([2.06, y, 1.4]),
1072
+ box({ size: [0.04, 0.03, 0.08], at: [0.8, s * (SIDE_Y + 0.015), 1.62], color: steel }),
1073
+ box({ size: [0.04, 0.03, 0.08], at: [-1.6, s * (SIDE_Y + 0.015), 1.62], color: steel }),
1074
+ );
1075
+ }
1076
+ }
1077
+ if (tools.length > 0) ctx.part('stowage', paintJob(merge(tools)));
1078
+
1079
+ // ---- Turret: horseshoe plan prism, cupola, hatches, bin; traverses as one group ----
1080
+ ctx.group('turret', {
1081
+ at: TURRET_AT,
1082
+ motion: { kind: 'sway', axis: [0, 0, 1], degrees: 20, hz: 0.1 },
1083
+ });
1084
+ const arc = lod === 0 ? 18 : lod === 1 ? 10 : 6;
1085
+ const plan: Vec2[] = [
1086
+ [1.0, -1.15],
1087
+ [1.0, 1.15],
1088
+ ];
1089
+ for (let i = 0; i <= arc; i++) {
1090
+ const a = ((70 + (220 * i) / arc) * Math.PI) / 180;
1091
+ plan.push([-0.2 + 1.33 * Math.cos(a), 1.33 * Math.sin(a)]);
1092
+ }
1093
+ const turret: Mesh[] = [
1094
+ // Turret ring collar (1.79 m ring) standing on the deck.
1095
+ lathe({
1096
+ profile: [
1097
+ [0.95, -RING],
1098
+ [0.95, 0],
1099
+ ],
1100
+ segments: segs * 2,
1101
+ color: dunkelgelb,
1102
+ }),
1103
+ prism({
1104
+ profile: plan,
1105
+ depth: 0.85,
1106
+ plane: 'xy',
1107
+ centred: false,
1108
+ color: dunkelgelb,
1109
+ strip: 'iron',
1110
+ }),
1111
+ // Rear stowage bin (Rommelkiste).
1112
+ prism({
1113
+ profile: [
1114
+ [-1.5, 0.15],
1115
+ [-1.5, 0.62],
1116
+ [-1.95, 0.6],
1117
+ [-2.02, 0.4],
1118
+ [-1.95, 0.17],
1119
+ ],
1120
+ depth: 1.55,
1121
+ plane: 'xz',
1122
+ color: dunkelgelb,
1123
+ }),
1124
+ // Low cast cupola (mid production).
1125
+ lathe({
1126
+ profile: [
1127
+ [0.38, 0.84],
1128
+ [0.38, 1.04],
1129
+ [0.33, 1.08],
1130
+ [0.3, 1.1],
1131
+ ],
1132
+ segments: segs,
1133
+ at: [-0.55, 0.62, 0],
1134
+ color: dunkelgelb,
1135
+ caps: true,
1136
+ }),
1137
+ ];
1138
+ if (lod < 2) {
1139
+ turret.push(
1140
+ lathe({
1141
+ profile: [
1142
+ [0.3, 1.1],
1143
+ [0.31, 1.15],
1144
+ [0.24, 1.19],
1145
+ [0, 1.19],
1146
+ ],
1147
+ segments: segs,
1148
+ at: [-0.55, 0.62, 0],
1149
+ color: paintDark,
1150
+ }),
1151
+ cylinder({
1152
+ radius: 0.28,
1153
+ length: 0.06,
1154
+ segments: segs,
1155
+ at: [-0.2, -0.6, 0.88],
1156
+ color: paintDark,
1157
+ }),
1158
+ );
1159
+ }
1160
+ if (lod === 0) {
1161
+ // Seven periscope housings, AA ring, loader's periscope, ventilator, lifting lugs.
1162
+ for (let i = 0; i < 7; i++) {
1163
+ const a = (i / 7) * Math.PI * 2 + 0.3;
1164
+ turret.push(
1165
+ place(box({ size: [0.06, 0.13, 0.09], color: black }), {
1166
+ at: [-0.55 + Math.cos(a) * 0.37, 0.62 + Math.sin(a) * 0.37, 0.93],
1167
+ yaw: a,
1168
+ }),
1169
+ );
1170
+ }
1171
+ turret.push(
1172
+ lathe({
1173
+ profile: Array.from({ length: 7 }, (_, i): Vec2 => {
1174
+ const a = (i / 6) * Math.PI * 2;
1175
+ return [0.36 + 0.015 * Math.cos(a), 1.12 + 0.015 * Math.sin(a)];
1176
+ }),
1177
+ segments: segs,
1178
+ at: [-0.55, 0.62, 0],
1179
+ color: steel,
1180
+ }),
1181
+ box({ size: [0.15, 0.1, 0.1], at: [0.1, -0.6, 0.85], color: paintDark }),
1182
+ lathe({
1183
+ profile: [
1184
+ [0.12, 0.84],
1185
+ [0.12, 0.9],
1186
+ [0.08, 0.94],
1187
+ [0, 0.95],
1188
+ ],
1189
+ segments: 10,
1190
+ at: [0.55, 0, 0],
1191
+ color: paintDark,
1192
+ }),
1193
+ );
1194
+ for (const [x, y] of [
1195
+ [0.6, 0.85],
1196
+ [0.6, -0.85],
1197
+ [-1.2, 0],
1198
+ ] as const)
1199
+ turret.push(box({ size: [0.12, 0.04, 0.08], at: [x, y, 0.85], color: paintDark }));
1200
+ // Pistol port (left) and escape hatch (right rear).
1201
+ const hatchA = 4.2;
1202
+ turret.push(
1203
+ lathe({
1204
+ profile: [
1205
+ [0.1, 0],
1206
+ [0.1, 0.03],
1207
+ [0.06, 0.05],
1208
+ [0, 0.05],
1209
+ ],
1210
+ segments: 10,
1211
+ axis: 'y',
1212
+ at: [-0.4, 1.3, 0.42],
1213
+ color: paintDark,
1214
+ }),
1215
+ place(
1216
+ lathe({
1217
+ profile: [
1218
+ [0.25, 0],
1219
+ [0.25, 0.03],
1220
+ [0.2, 0.05],
1221
+ [0, 0.05],
1222
+ ],
1223
+ segments: segs,
1224
+ axis: 'x',
1225
+ color: paintDark,
1226
+ }),
1227
+ {
1228
+ at: [-0.2 + 1.33 * Math.cos(hatchA) - 0.01, 1.33 * Math.sin(hatchA), 0.42],
1229
+ yaw: hatchA,
1230
+ },
1231
+ ),
1232
+ );
1233
+ // Spare track links on both turret sides (flat stretch behind the front corners).
1234
+ const link = trackLink(0.13);
1235
+ for (const s of [1, -1]) {
1236
+ const a: Vec2 = [1.0, s * 1.15];
1237
+ const b: Vec2 = [-0.2 + 1.33 * Math.cos(1.22), s * 1.33 * Math.sin(1.22)];
1238
+ const along = Math.hypot(b[0] - a[0], b[1] - a[1]);
1239
+ const u: Vec3 = [(b[0] - a[0]) / along, (b[1] - a[1]) / along, 0];
1240
+ const n: Vec3 = [u[1], -u[0], 0];
1241
+ const out: Vec3 = n[1] * s < 0 ? [-n[0], -n[1], 0] : n;
1242
+ for (let k = 0; k < 4; k++) {
1243
+ const m: Vec3 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, 0.2 + k * 0.14];
1244
+ turret.push(
1245
+ applyAffine(
1246
+ link,
1247
+ frame([0, 0, 1], u, out, [m[0] + out[0] * 0.05, m[1] + out[1] * 0.05, m[2]]),
1248
+ ),
1249
+ );
1250
+ }
1251
+ }
1252
+ // Zimmerit round the turret walls.
1253
+ const wall: WallPoint[] = plan.map((p, i) => {
1254
+ const q = plan[(i + 1) % plan.length] as Vec2;
1255
+ const o = plan[(i + plan.length - 1) % plan.length] as Vec2;
1256
+ const tx = q[0] - o[0];
1257
+ const ty = q[1] - o[1];
1258
+ const l = Math.hypot(tx, ty) || 1;
1259
+ return { p, n: [ty / l, -tx / l] };
1260
+ });
1261
+ turret.push(zimmerit([...wall.slice(1), wall[0] as WallPoint], 0.05, 0.82));
1262
+ // Turret number "332", outlined digits (Bundesarchiv Bild 101I-277-0846-08), both sides,
1263
+ // reading front-to-back on the left and back-to-front on the right.
1264
+ for (const s of [1, -1])
1265
+ [3, 3, 2].forEach((d, i) => {
1266
+ const x = s > 0 ? 0.05 - i * 0.22 : -0.39 + i * 0.22;
1267
+ const y = s * (Math.sqrt(1.33 ** 2 - (x + 0.2) ** 2) + 0.022);
1268
+ for (const rect of digitRects(d, 0.3)) {
1269
+ const grow = rect.map(
1270
+ ([u, v], k): Vec2 => [
1271
+ u + (k === 0 || k === 3 ? -0.012 : 0.012),
1272
+ v + (k < 2 ? -0.012 : 0.012),
1273
+ ],
1274
+ );
1275
+ turret.push(sideDecal(grow, s, x, y - s * 0.004, 0.3, black));
1276
+ turret.push(sideDecal(rect, s, x, y, 0.3, white));
1277
+ }
1278
+ });
1279
+ }
1280
+ ctx.part('turret_body', muddy(paintJob(merge(turret), TURRET_AT), TURRET_AT, 0.3), {
1281
+ parent: 'turret',
1282
+ });
1283
+
1284
+ // ---- Gun: curved mantlet with coax and sight ports; KwK 36 L/56 with muzzle brake ----
1285
+ // The elevation sweep is checked as a full turn about the trunnion, so it necessarily passes
1286
+ // through the turret roof and front deck; the real arc (−4°…+15°) clears both.
1287
+ ctx.group('gun', {
1288
+ at: [1.0, 0, 0.42],
1289
+ parent: 'turret',
1290
+ motion: { kind: 'sway', axis: [0, 1, 0], degrees: 5, hz: 0.25 },
1291
+ clearOf: ['turret_body', 'hull', 'hull_detail', 'stowage', 'fenders'],
1292
+ });
1293
+ ctx.emitter('muzzle', {
1294
+ kind: 'muzzle',
1295
+ parent: 'gun',
1296
+ at: [4.15, 0, 0],
1297
+ dir: [1, 0, 0],
1298
+ rate: 60,
1299
+ life: 0.25,
1300
+ size: [0.4, 2.0],
1301
+ speed: 12,
1302
+ spreadDeg: 25,
1303
+ when: 'event',
1304
+ });
1305
+ const mantletArc = lod === 0 ? 8 : 4;
1306
+ const mantlet: Vec2[] = [];
1307
+ for (let i = 0; i <= mantletArc; i++) {
1308
+ const a = -Math.PI / 2 + (Math.PI * i) / mantletArc;
1309
+ mantlet.push([0.24 * Math.cos(a), 0.4 * Math.sin(a)]);
1310
+ }
1311
+ const mParts = [prism({ profile: mantlet, depth: 1.95, plane: 'xz', color: dunkelgelb })];
1312
+ if (lod === 0)
1313
+ mParts.push(
1314
+ box({ size: [0.04, 0.09, 0.09], at: [0.22, 0.42, 0.0], color: black }),
1315
+ box({ size: [0.04, 0.09, 0.07], at: [0.23, -0.2, 0.12], color: black }),
1316
+ box({ size: [0.04, 0.07, 0.07], at: [0.23, -0.34, 0.12], color: black }),
1317
+ );
1318
+ const gunAt: Vec3 = [TURRET_AT[0] + 1.0, 0, TURRET_AT[2] + 0.42];
1319
+ ctx.part('gun_mantlet', paintJob(merge(mParts), gunAt), { parent: 'gun' });
1320
+ const breech = 4.14 - 4.928;
1321
+ ctx.part(
1322
+ 'gun_barrel',
1323
+ paintJob(
1324
+ lathe({
1325
+ profile:
1326
+ lod === 0
1327
+ ? [
1328
+ [0.12, breech],
1329
+ [0.17, 0.2],
1330
+ [0.26, 0.2],
1331
+ [0.26, 0.24],
1332
+ [0.17, 0.25],
1333
+ [0.17, 0.5],
1334
+ [0.12, 0.56],
1335
+ [0.105, 0.62],
1336
+ [0.088, 3.62],
1337
+ [0.14, 3.64],
1338
+ [0.15, 3.66],
1339
+ [0.15, 3.8],
1340
+ [0.11, 3.82],
1341
+ [0.11, 3.9],
1342
+ [0.15, 3.92],
1343
+ [0.15, 4.08],
1344
+ [0.12, 4.13],
1345
+ [0.06, 4.14],
1346
+ [0.06, 4.0],
1347
+ ]
1348
+ : [
1349
+ [0.12, breech],
1350
+ [0.17, 0.2],
1351
+ [0.26, 0.2],
1352
+ [0.26, 0.24],
1353
+ [0.17, 0.25],
1354
+ [0.17, 0.5],
1355
+ [0.1, 0.6],
1356
+ [0.088, 3.62],
1357
+ [0.15, 3.66],
1358
+ [0.15, 4.14],
1359
+ ],
1360
+ segments: [16, 10, 6][lod] ?? 6,
1361
+ axis: 'x',
1362
+ color: dunkelgelb,
1363
+ caps: true,
1364
+ }),
1365
+ gunAt,
1366
+ ),
1367
+ { parent: 'gun' },
1368
+ );
1369
+ },
1370
+ });