@developerz.ai/ts23d 0.1.0 → 0.2.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 (162) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +1 -1
  3. package/README.md +10 -5
  4. package/dist/{chunk-a8fxakcj.js → chunk-6b05mnmx.js} +2 -2
  5. package/dist/{chunk-kgvbpqqw.js → chunk-7rtfjh9f.js} +6 -5
  6. package/dist/chunk-7rtfjh9f.js.map +10 -0
  7. package/dist/{chunk-1ypfs7ga.js → chunk-8j4c28jt.js} +790 -276
  8. package/dist/chunk-8j4c28jt.js.map +46 -0
  9. package/dist/{chunk-wtqhq529.js → chunk-d1fv6hrm.js} +6 -6
  10. package/dist/{chunk-wtqhq529.js.map → chunk-d1fv6hrm.js.map} +1 -1
  11. package/dist/{chunk-gcyp9b4d.js → chunk-dbq4qf52.js} +362 -215
  12. package/dist/chunk-dbq4qf52.js.map +25 -0
  13. package/dist/{chunk-xnt2sdhf.js → chunk-drna2rny.js} +2 -2
  14. package/dist/{chunk-ravbb06q.js → chunk-dxvypr7y.js} +2 -2
  15. package/dist/{chunk-11v28977.js → chunk-f6yme003.js} +85 -71
  16. package/dist/{chunk-11v28977.js.map → chunk-f6yme003.js.map} +6 -6
  17. package/dist/{chunk-4ddzsqtc.js → chunk-hg7pf34j.js} +4 -3
  18. package/dist/{chunk-4ddzsqtc.js.map → chunk-hg7pf34j.js.map} +5 -5
  19. package/dist/{chunk-w1zyhj40.js → chunk-jq4e17tj.js} +2 -2
  20. package/dist/{chunk-kvcep7ft.js → chunk-y63c6zcv.js} +967 -218
  21. package/dist/chunk-y63c6zcv.js.map +53 -0
  22. package/dist/cli/args.d.ts +7 -1
  23. package/dist/cli/bin.js +66 -25
  24. package/dist/cli/bin.js.map +6 -5
  25. package/dist/cli/manifest.d.ts +5 -1
  26. package/dist/cli/texture-cache.d.ts +4 -0
  27. package/dist/core/definition.d.ts +7 -0
  28. package/dist/core/errors.d.ts +1 -1
  29. package/dist/core/index.js +1 -1
  30. package/dist/export/credits.d.ts +4 -0
  31. package/dist/export/glb-options.d.ts +48 -0
  32. package/dist/export/glb.d.ts +5 -35
  33. package/dist/export/hierarchy.d.ts +23 -0
  34. package/dist/export/index.d.ts +2 -1
  35. package/dist/export/index.js +21 -7
  36. package/dist/export/index.js.map +1 -1
  37. package/dist/export/lod-coverage.d.ts +12 -0
  38. package/dist/export/metadata.d.ts +2 -0
  39. package/dist/export/version.d.ts +2 -2
  40. package/dist/grammar/index.js +2 -2
  41. package/dist/index.js +63 -17
  42. package/dist/index.js.map +1 -1
  43. package/dist/kinds/head-armour.d.ts +3 -7
  44. package/dist/kinds/head-bust.d.ts +3 -2
  45. package/dist/kinds/head-face.d.ts +3 -1
  46. package/dist/kinds/head-garb.d.ts +4 -0
  47. package/dist/kinds/head-mesh.d.ts +4 -0
  48. package/dist/kinds/head-mouth.d.ts +4 -0
  49. package/dist/kinds/head-params.d.ts +9 -1
  50. package/dist/kinds/head-tone.d.ts +10 -0
  51. package/dist/kinds/head-torso.d.ts +35 -0
  52. package/dist/kinds/index.js +6 -6
  53. package/dist/material/cc0.d.ts +4 -0
  54. package/dist/material/define.d.ts +34 -0
  55. package/dist/material/generator.d.ts +1 -1
  56. package/dist/material/index.d.ts +8 -3
  57. package/dist/material/index.js +39 -7
  58. package/dist/material/index.js.map +1 -1
  59. package/dist/material/library.d.ts +7 -0
  60. package/dist/material/material-def.d.ts +30 -0
  61. package/dist/material/paint/canvas.d.ts +7 -0
  62. package/dist/material/paint/default-sheet.d.ts +3 -1
  63. package/dist/material/paint/fabric.d.ts +4 -0
  64. package/dist/material/paint/image-strip.d.ts +4 -0
  65. package/dist/material/paint/kit.d.ts +5 -0
  66. package/dist/material/paint/metal.d.ts +3 -0
  67. package/dist/material/paint/orm.d.ts +1 -1
  68. package/dist/material/paint/strip.d.ts +1 -1
  69. package/dist/material/paint/types.d.ts +32 -2
  70. package/dist/material/palette.d.ts +2 -0
  71. package/dist/material/slots.d.ts +5 -0
  72. package/dist/material/texture-source.d.ts +34 -0
  73. package/dist/material/trim-sheet.d.ts +20 -4
  74. package/dist/neural/index.js +2 -2
  75. package/dist/passes/index.js +2 -2
  76. package/dist/primitives/index.js +3 -3
  77. package/dist/qa/index.js +2 -2
  78. package/dist/render/beauty.d.ts +1 -1
  79. package/dist/render/index.js +3 -3
  80. package/dist/render/lighting.d.ts +7 -0
  81. package/dist/render/shade.d.ts +1 -1
  82. package/dist/testing/index.js +5 -5
  83. package/docs/README.md +4 -0
  84. package/docs/architecture/gltf-capabilities.md +38 -1
  85. package/docs/architecture/materials.md +151 -0
  86. package/docs/idea/10-sculpting.md +166 -0
  87. package/docs/idea/11-materials-authoring.md +132 -0
  88. package/docs/idea/12-blender-bridge.md +136 -0
  89. package/docs/idea/13-asset-pipeline-research.md +89 -0
  90. package/docs/plans/roadmap.md +12 -0
  91. package/package.json +2 -1
  92. package/recipes/boxcar/README.md +7 -0
  93. package/recipes/boxcar/boxcar.ts +118 -34
  94. package/recipes/goblin_head/README.md +27 -14
  95. package/recipes/goblin_head/goblin_head.ts +11 -9
  96. package/recipes/human_head/README.md +8 -4
  97. package/recipes/human_head/human_head.ts +4 -3
  98. package/recipes/material_swatches/material_swatches.ts +11 -5
  99. package/recipes/orc_head/README.md +32 -20
  100. package/recipes/orc_head/orc_head.ts +9 -7
  101. package/src/cli/args.ts +16 -3
  102. package/src/cli/manifest.ts +6 -0
  103. package/src/cli/run.ts +33 -9
  104. package/src/cli/texture-cache.ts +24 -0
  105. package/src/core/asset.ts +1 -0
  106. package/src/core/definition.ts +7 -0
  107. package/src/core/errors.ts +6 -1
  108. package/src/export/credits.ts +25 -0
  109. package/src/export/glb-options.ts +53 -0
  110. package/src/export/glb.ts +100 -113
  111. package/src/export/hierarchy.ts +122 -0
  112. package/src/export/index.ts +17 -1
  113. package/src/export/lod-coverage.ts +71 -0
  114. package/src/export/metadata.ts +3 -0
  115. package/src/export/version.ts +1 -1
  116. package/src/kinds/head-armour.ts +103 -73
  117. package/src/kinds/head-bust.ts +49 -86
  118. package/src/kinds/head-face.ts +94 -74
  119. package/src/kinds/head-garb.ts +181 -0
  120. package/src/kinds/head-hair.ts +74 -33
  121. package/src/kinds/head-mesh.ts +43 -5
  122. package/src/kinds/head-mouth.ts +111 -0
  123. package/src/kinds/head-params.ts +11 -2
  124. package/src/kinds/head-presets.ts +30 -27
  125. package/src/kinds/head-tone.ts +52 -0
  126. package/src/kinds/head-torso.ts +230 -0
  127. package/src/kinds/head.ts +8 -3
  128. package/src/material/cc0.ts +41 -0
  129. package/src/material/define.ts +130 -0
  130. package/src/material/generator.ts +15 -8
  131. package/src/material/index.ts +34 -3
  132. package/src/material/library.ts +196 -0
  133. package/src/material/material-def.ts +84 -0
  134. package/src/material/paint/canvas.ts +11 -1
  135. package/src/material/paint/compose.ts +1 -1
  136. package/src/material/paint/decode-png.ts +19 -13
  137. package/src/material/paint/default-sheet.ts +40 -28
  138. package/src/material/paint/fabric.ts +63 -0
  139. package/src/material/paint/image-strip.ts +88 -0
  140. package/src/material/paint/kit.ts +37 -0
  141. package/src/material/paint/masks.ts +3 -1
  142. package/src/material/paint/metal.ts +109 -0
  143. package/src/material/paint/orm.ts +14 -1
  144. package/src/material/paint/planks.ts +49 -4
  145. package/src/material/paint/strip.ts +35 -29
  146. package/src/material/paint/types.ts +45 -2
  147. package/src/material/paint/validate.ts +19 -3
  148. package/src/material/palette.ts +3 -0
  149. package/src/material/slots.ts +18 -0
  150. package/src/material/texture-source.ts +86 -0
  151. package/src/material/trim-sheet.ts +79 -13
  152. package/src/render/beauty.ts +2 -2
  153. package/src/render/lighting.ts +26 -0
  154. package/src/render/shade.ts +10 -5
  155. package/dist/chunk-1ypfs7ga.js.map +0 -42
  156. package/dist/chunk-gcyp9b4d.js.map +0 -22
  157. package/dist/chunk-kgvbpqqw.js.map +0 -10
  158. package/dist/chunk-kvcep7ft.js.map +0 -43
  159. /package/dist/{chunk-a8fxakcj.js.map → chunk-6b05mnmx.js.map} +0 -0
  160. /package/dist/{chunk-xnt2sdhf.js.map → chunk-drna2rny.js.map} +0 -0
  161. /package/dist/{chunk-ravbb06q.js.map → chunk-dxvypr7y.js.map} +0 -0
  162. /package/dist/{chunk-w1zyhj40.js.map → chunk-jq4e17tj.js.map} +0 -0
@@ -1,34 +1,41 @@
1
1
  # Orc warrior bust — original stylised design
2
2
 
3
3
  ## Identity
4
- A brutish orc warlord, read from the 3/4 view by: **massive underbitten jaw with lower tusks**
5
- curving out past the upper lip, **heavy scowling brow** over small deep-set ember eyes, **flat
6
- broad nose**, **pointed ears** swept back, a **top-knot** on a low skull, and **spiked layered
7
- pauldrons + fur mantle** on huge trapezius.
4
+ A brutish orc warlord, read from the 3/4 view by: a **hunched brute silhouette** (trapezius sloping
5
+ from behind the ears to the shoulders, the neck nearly as wide as the head), a **massive
6
+ underbitten jaw** with **lower tusks rising outside the upper lip**, a **heavy brow shelf** (a
7
+ flattened bar with a glabella knot, scowling down at the centre) shadowing **small deep-set ember
8
+ eyes**, a **flat broad nose**, **cheekbone ridges** running back to swept **pointed ears**, a **bound
9
+ warrior's knot** falling to the nape, war paint, **pectorals and deltoids** on the bare chest, and
10
+ **spiked plate pauldrons** (a domed cap and a shingled lame, hollow shells with worn bright rims)
11
+ over a **fur mantle** — cut as a collectible bust on a turned plinth.
8
12
 
9
13
  ## Design spec (head units: 1 = chin to crown = 0.30 m, ≈ 1.3 × human)
10
14
  | Feature | Value (units) | Metres | Note |
11
15
  |---|---|---|---|
12
16
  | chin → crown | 1.00 | 0.300 | the `height` param |
13
- | overall bust L × W × H | — | 0.338 × 0.742 × 0.583 | `real`, ±5 % |
14
- | cranium width / depth / above eye line | 0.70 / 0.80 / 0.34 | 0.21 / 0.24 / 0.10 | low, sloped dome |
17
+ | overall bust L × W × H | — | 0.405 × 0.660 × 0.587 | `real`, ±5 %; length includes the knot's tail |
18
+ | cranium width / depth / above eye line | 0.70 / 0.74 / 0.31 | 0.21 / 0.22 / 0.09 | low, sloped dome: the face is the mass |
15
19
  | eye line | 0.56 | — | higher than human: the jaw is the mass |
16
- | eyeball radius / spacing | 0.050 / 0.155 | 0.015 / pupils 0.093 apart | `measures` gate, ±2 % |
17
- | jaw half-width at angle | 0.37 | 0.111 | human 0.22 |
18
- | muzzle (prognathism) / underbite / chin drop | 0.10 / 0.07 / 0.10 | | |
19
- | tusks length / base radius | 0.27 / 0.05 | 0.081 / 0.015 | 3 cone segments, flared |
20
- | neck radius | 0.32 | 0.096 | |
21
- | shoulders (half-span) | 1.05 | 0.315 | arms cut flat (classic bust) |
20
+ | eyeball radius / spacing | 0.054 / 0.155 | 0.016 / pupils 0.093 apart | `measures` gate, ±2 % |
21
+ | brow ridge / overhang / scowl | 0.075 / 0.06 / 0.04 | | a shelf, not two bumps |
22
+ | jaw half-width at angle / chin | 0.40 / 1.3 | 0.120 | human 0.22; square rounded-box chin |
23
+ | muzzle (prognathism) / underbite / chin drop | 0.10 / 0.10 / 0.10 | | lower lip thickens with the underbite |
24
+ | tusks length / base radius | 0.22 / 0.055 | 0.066 / 0.017 | 3 cone segments from the lower lip, flared |
25
+ | neck radius | 0.38 | 0.114 | |
26
+ | shoulders (half-span) / mass | 1.00 / 1 | 0.300 | `bust.mass` 1 = brute: torso lifted (hunch), thick traps |
27
+ | plinth | 0.17 + hunch | ≈ 0.08 | disc + socle; cut faces painted plinth stone |
22
28
 
23
29
  ## Palette (sRGB)
24
30
  | Role | Hex |
25
31
  |---|---|
26
- | skin / shade / lips | `#5f8b3a` / `#3c5c24` / `#4f6f30` |
32
+ | skin / shade / lips | `#5c8034` / `#33501e` / `#4f6f30` (painted tone: crevices darker, tops warmer) |
27
33
  | eyes: sclera / iris (+ emissive glow `#ff7a1a` × 2.5) | `#8a5a1a` / `#ffb030` |
28
34
  | tusks + bone spikes | `#e2d6b0` |
29
- | hair (top-knot) / fur mantle (strands alternate ×0.7) | `#1c1714` / `#5e4a34` |
35
+ | hair (warrior's knot) / fur mantle (strands alternate ×0.72) | `#231a14` / `#5e4a34` |
30
36
  | war paint (cheek claw marks, crest stripe, chin stripes) | `#8a1c12` |
31
- | pauldron iron (lames ×1/0.88/0.76, rims ×0.55, rivets ×1.4) / leather baldric | `#6e6a62` / `#5a3a22` |
37
+ | pauldron iron (cap ×1, lame ×0.85, worn rims ×1.3, rivets ×1.35, crest ×0.75) / leather baldric | `#55524d` / `#5a3a22` |
38
+ | plinth / cut faces | `#6f6c6c` (linear 0.16, 0.15, 0.15) / ×1.3 |
32
39
 
33
40
  ## Parts the engine needs
34
41
  | Part | Pivot | Motion |
@@ -43,8 +50,10 @@ along the lips, the marionette lines and under the chin. Its cut faces are paint
43
50
  when it opens; it laps 0.75 cells over the head so no gap shows.
44
51
 
45
52
  ## LODs
46
- Sculpt cell = 2 % / 4.5 % / 14 % of head height. LOD0/1 bake vertex AO across all parts (16/10 rays); LOD2 drops
47
- warts, wiry tufts and the AO bake, and floors ear thickness at 0.7 cells so ears stay attached.
53
+ Sculpt cell = 2 % / 4.5 % / 14 % of head height (bust 1.6 ×). LOD0/1 bake vertex AO across all
54
+ parts (16/10 rays); every LOD carries the painted tone (cavity, warm top, cool under). LOD1/2 drop
55
+ the teeth and tongue; LOD2 drops warts, wiry tufts and the AO bake, floors ear/tusk/nose radii at
56
+ about a cell, makes the pauldron plates solid and the plinth one drum so nothing opens or floats.
48
57
 
49
58
  ## References (stylised anatomy, not likeness)
50
59
  | Topic | Link | Used for |
@@ -52,7 +61,8 @@ warts, wiry tufts and the AO bake, and floors ear thickness at 0.7 cells so ears
52
61
  | Loomis head: eye line at half height, brow/nose/chin thirds | https://finearttutorials.com/guide/loomis-method/ | human eye line 0.5, nose base 0.32, mouth 0.22 |
53
62
  | Proportions of the head (ears between brow and nose base, head ≈ 5 eyes wide) | https://artyfactory.com/portraits/pencil-portraits/proportions-of-a-head.html | ear placement, eye size vs head width |
54
63
  | Primary → secondary → tertiary forms (block brow/cheek/jaw first, then cut lids and folds) | https://www.creativebloq.com/how-to/how-to-sculpt-and-pose-a-cartoon-head-in-zbrush | the edit order in `head-face.ts` |
55
- | Orc: widen head and jawline to break human proportion | https://www.creativebloq.com/3d/how-create-orc-zbrush-61411834 | orc jaw width 0.37 vs human 0.22 |
64
+ | Orc: widen head and jawline to break human proportion; sculpt bony forms (cheekbones) early | https://www.creativebloq.com/3d/how-create-orc-zbrush-61411834 | orc jaw width 0.40 vs human 0.22, zygomatic ridge |
65
+ | Stylised bust method: primary → secondary → tertiary shapes | https://cgzen.gumroad.com/l/stylized-bust | torso masses before gear |
56
66
  | Stylised orc head study (silhouette, form language) | https://blenderartists.org/t/orc-head-study-stylized-characters/1655542 | massing check |
57
67
  | Stylised hair: large clumps first, hairline → crown, one primary flow direction | https://gamesartist.co.uk/curly-hair-ls/ · https://www.cgchannel.com/2023/03/tutorial-creating-stylized-hairstyles-using-zbrush | `swept` locks in head-hair.ts |
58
68
  | Dreams (Media Molecule, Evans, SIGGRAPH 2015): sculptures as ordered SDF edits + smooth-min | https://www.mediamolecule.com/blog/article/siggraph_2015 · slides https://advances.realtimerendering.com/s2015/AlexEvans_SIGGRAPH-2015-sml.pdf | the whole approach |
@@ -62,5 +72,7 @@ hand-painted stylised fantasy" is the idiom (exaggerated primary forms, generous
62
72
  vertex colour with baked AO), not a specific source.
63
73
 
64
74
  ## Known gaps
65
- Pauldron lames still read soft (no hammered dents or edge wear); fur strands are cones, not
66
- noise-broken clumps; no skin pores/wrinkle noise; the top-knot is a plain bun.
75
+ Pauldron plates are smooth shells (no hammered dents or scratches); fur strands are cones, not
76
+ noise-broken clumps; no skin pores or wrinkle noise; the jaw part's seam still shows as a faint
77
+ line round the chin up close; the bust's U-cut leaves the belly reading slightly pear-shaped from
78
+ dead front.
@@ -1,7 +1,9 @@
1
1
  // Stylised orc warrior bust — an original design in the chunky hand-painted fantasy idiom: massive
2
- // underbitten jaw with lower tusks, heavy scowling brow over small ember eyes, flat broad nose,
3
- // pointed swept ears, top-knot, war-paint stripes, layered spiked pauldrons and a fur mantle. The
4
- // `head` kind sculpts it (race preset `orc`, chin to crown 0.30 m); this file is the spec.
2
+ // underbitten jaw with lower tusks rising outside the upper lip, a heavy brow shelf over small ember
3
+ // eyes, flat broad nose, swept ears, a bound warrior's knot, war paint; a hunched brute torso
4
+ // (trapezius from ear to shoulder, pectorals, deltoids) under spiked plate pauldrons and a fur
5
+ // mantle, cut as a collectible bust on a turned plinth. The `head` kind sculpts it (race preset
6
+ // `orc`, chin to crown 0.30 m); this file is the spec.
5
7
  // Design dossier: recipes/orc_head/README.md.
6
8
 
7
9
  import { defineAsset, head, hex } from '@developerz.ai/ts23d';
@@ -10,15 +12,15 @@ export const orc_head = defineAsset({
10
12
  name: 'orc_head',
11
13
  kind: 'character',
12
14
  description:
13
- 'Stylised orc warrior bust: underbite and lower tusks, heavy brow, ember eyes, top-knot, spiked pauldrons, fur mantle',
15
+ 'Stylised orc warrior bust on a plinth: underbite and lower tusks, heavy brow, ember eyes, warrior knot, hunched trapezius, spiked plate pauldrons, fur mantle',
14
16
  tags: ['character', 'fantasy', 'orc', 'bust', 'sculpt'],
15
17
  license: 'CC0-1.0',
16
18
  sources: ['recipes/orc_head/README.md (design dossier: proportions, palette, references)'],
17
19
  // Design spec, not a real-world one: overall bust size and eye spacing from the dossier.
18
20
  real: {
19
- length: 0.338,
20
- width: 0.742,
21
- height: 0.583,
21
+ length: 0.405,
22
+ width: 0.66,
23
+ height: 0.587,
22
24
  measures: [
23
25
  {
24
26
  label: 'eye spacing (pupil to pupil)',
package/src/cli/args.ts CHANGED
@@ -17,6 +17,12 @@ export interface Args {
17
17
  readonly sheetSize: 512 | 1024 | 2048;
18
18
  /** Which detail levels to build: all three by default, or e.g. only the hero mesh. */
19
19
  readonly lods: readonly (0 | 1 | 2)[];
20
+ /**
21
+ * How LODs land on disk: `split` lodN.glb per level · `single` one <asset>.glb with MSFT_lod
22
+ * alternates · `both` (default): split files import everywhere, the single file is for
23
+ * MSFT_lod-aware loaders and one-file pipelines.
24
+ */
25
+ readonly lodFiles: 'split' | 'single' | 'both';
20
26
  /** Beauty-render camera and size (hero, review, and build's hero.png); unset = defaults. */
21
27
  readonly render: {
22
28
  readonly width?: number;
@@ -42,8 +48,11 @@ flags:
42
48
  front, default -35 (front-left 3/4) · --el <deg> elevation, default 18 · --fov <deg> 30
43
49
  --sheet <mode> external (default): one shared trim_sheet_{albedo,normal,orm}.png in <out>/,
44
50
  referenced by every GLB · embed: self-contained GLBs · none: vertex colour only
45
- --sheet-size n 512 | 1024 (default) | 2048
51
+ --sheet-size n 512 | 1024 | 2048 (default)
46
52
  --lods <list> which detail levels to build: 0,1,2 (default) · 0 = hero only · 0,2 = near + far
53
+ --lods <mode> how they are written: both (default) = lod0/1/2.glb + <asset>.glb · split = lodN.glb
54
+ only · single = <asset>.glb only (LOD0 in the scene, LOD1/2 as MSFT_lod alternates
55
+ with screen-coverage switch hints; viewers without MSFT_lod show LOD0 only)
47
56
  --json machine-readable output`;
48
57
 
49
58
  export function parseArgs(argv: readonly string[]): Args {
@@ -53,8 +62,9 @@ export function parseArgs(argv: readonly string[]): Args {
53
62
  let animations = true;
54
63
  let shot = true;
55
64
  let sheet: Args['sheet'] = 'external';
56
- let sheetSize: Args['sheetSize'] = 1024;
65
+ let sheetSize: Args['sheetSize'] = 2048;
57
66
  let lods: Args['lods'] = [0, 1, 2];
67
+ let lodFiles: Args['lodFiles'] = 'both';
58
68
  const render: {
59
69
  width?: number;
60
70
  height?: number;
@@ -81,6 +91,8 @@ export function parseArgs(argv: readonly string[]): Args {
81
91
  else if (a === '--sheet') {
82
92
  const m = argv[++i];
83
93
  sheet = m === 'embed' || m === 'none' ? m : 'external';
94
+ } else if (a === '--lods' && /^(split|single|both)$/.test(argv[i + 1] ?? '')) {
95
+ lodFiles = argv[++i] as Args['lodFiles'];
84
96
  } else if (a === '--lods') {
85
97
  const picked = (argv[++i] ?? '')
86
98
  .split(',')
@@ -89,7 +101,7 @@ export function parseArgs(argv: readonly string[]): Args {
89
101
  lods = picked.length ? [...new Set(picked)].sort() : lods;
90
102
  } else if (a === '--sheet-size') {
91
103
  const n = Number(argv[++i]);
92
- sheetSize = n === 512 || n === 2048 ? n : 1024;
104
+ sheetSize = n === 512 || n === 1024 ? n : 2048;
93
105
  } else if (a === '--size') Object.assign(render, parseSize(argv[++i] ?? ''));
94
106
  else if (a === '--az') render.azDeg = num(a, argv[++i]);
95
107
  else if (a === '--el') render.elDeg = num(a, argv[++i]);
@@ -107,6 +119,7 @@ export function parseArgs(argv: readonly string[]): Args {
107
119
  sheet,
108
120
  sheetSize,
109
121
  lods,
122
+ lodFiles,
110
123
  render,
111
124
  };
112
125
  }
@@ -13,11 +13,15 @@ export interface AssetManifest {
13
13
  readonly tags: readonly string[];
14
14
  readonly files: {
15
15
  readonly lods: readonly string[];
16
+ /** All built LODs in one GLB (MSFT_lod), when `--lods single|both`. */
17
+ readonly single?: string;
16
18
  readonly hero?: string;
17
19
  readonly sheet?: string;
18
20
  readonly thumb?: string;
19
21
  };
20
22
  readonly triangles: readonly number[];
23
+ /** MSFT_screencoverage written into `files.single`: where each LOD hands over (last = cull). */
24
+ readonly lodCoverage?: readonly number[];
21
25
  readonly drawCalls: readonly number[];
22
26
  readonly pivots: readonly { name: string; parent: string | null; motion?: string }[];
23
27
  readonly emitters: readonly { name: string; kind: string; when: string }[];
@@ -42,6 +46,7 @@ export function manifestOf(
42
46
  lods: readonly CompiledAsset[],
43
47
  files: AssetManifest['files'],
44
48
  findings: readonly Finding[],
49
+ lodCoverage?: readonly number[],
45
50
  ): AssetManifest {
46
51
  const top = lods[0];
47
52
  const merged = top ? mergeToPivots(top) : null;
@@ -52,6 +57,7 @@ export function manifestOf(
52
57
  tags: asset.tags ?? [],
53
58
  files,
54
59
  triangles: lods.map(tris),
60
+ ...(lodCoverage?.length ? { lodCoverage } : {}),
55
61
  // One draw per merged mesh, glowing parts in their own emissive layer (as the GLB writes them).
56
62
  drawCalls: lods.map(
57
63
  (l) =>
package/src/cli/run.ts CHANGED
@@ -1,19 +1,33 @@
1
1
  // Command dispatch for `t3d`. Pure function of argv → exit code, so it is testable in-process.
2
2
  // Output layout, one folder per asset (stable paths agents and pipelines can rely on):
3
- // <out>/<asset>/lod0.glb lod1.glb lod2.glb hero.png thumb.png sheet.png manifest.json
3
+ // <out>/<asset>/lod0.glb lod1.glb lod2.glb <asset>.glb (all LODs, MSFT_lod) hero.png thumb.png
4
+ // sheet.png manifest.json
4
5
  // <out>/<asset>/review/… (t3d review) · <out>/index.json — every asset built, with its QA verdict
5
6
 
6
7
  import { mkdir } from 'node:fs/promises';
7
8
  import { join } from 'node:path';
8
9
  import { type Asset, compile, type Lod, Ts23dError } from '../core/index.ts';
9
- import { SHEET_MAPS, type SheetBinding, sheetFileName, sheetPngs, toGlb } from '../export/index.ts';
10
- import { DEFAULT_SHEET, type PaintedSheet, paintTrimSheet } from '../material/index.ts';
10
+ import {
11
+ exportLodGlb,
12
+ SHEET_MAPS,
13
+ type SheetBinding,
14
+ sheetFileName,
15
+ sheetPngs,
16
+ toGlb,
17
+ } from '../export/index.ts';
18
+ import {
19
+ DEFAULT_SHEET,
20
+ type PaintedSheet,
21
+ paintTrimSheet,
22
+ provideTextures,
23
+ } from '../material/index.ts';
11
24
  import { type Finding, runQa } from '../qa/index.ts';
12
25
  import { beauty, contactSheet, thumbnail } from '../render/index.ts';
13
26
  import { type Args, HELP, parseArgs } from './args.ts';
14
27
  import { loadAssets } from './load.ts';
15
28
  import { type AssetManifest, manifestOf } from './manifest.ts';
16
29
  import { heroOne, printRendered, type RenderResult, reviewOne } from './render-cmds.ts';
30
+ import { loadTextureCache } from './texture-cache.ts';
17
31
 
18
32
  async function buildOne(
19
33
  asset: Asset,
@@ -29,16 +43,22 @@ async function buildOne(
29
43
  runQa(a).map((f) => ({ ...f, message: `LOD${a.lod}: ${f.message}` })),
30
44
  );
31
45
  const glbs: string[] = [];
32
- if (args.cmd === 'build') {
46
+ const glbOptions = { animations: args.animations, ...(texture ? { sheet: texture } : {}) };
47
+ let single: string | undefined;
48
+ let coverage: readonly number[] = [];
49
+ if (args.cmd === 'build' && args.lodFiles !== 'single') {
33
50
  for (const a of lods) {
34
51
  const path = join(dir, `lod${a.lod}.glb`);
35
- await Bun.write(
36
- path,
37
- toGlb(a, { animations: args.animations, ...(texture ? { sheet: texture } : {}) }),
38
- );
52
+ await Bun.write(path, toGlb(a, glbOptions));
39
53
  glbs.push(path);
40
54
  }
41
55
  }
56
+ if (args.cmd === 'build' && args.lodFiles !== 'split') {
57
+ single = join(dir, `${asset.name}.glb`);
58
+ const packed = exportLodGlb(lods, glbOptions);
59
+ coverage = packed.coverage;
60
+ await Bun.write(single, packed.glb);
61
+ }
42
62
  let sheet: string | undefined;
43
63
  let thumb: string | undefined;
44
64
  let hero: string | undefined;
@@ -61,11 +81,13 @@ async function buildOne(
61
81
  lods,
62
82
  {
63
83
  lods: glbs,
84
+ ...(single ? { single } : {}),
64
85
  ...(hero ? { hero } : {}),
65
86
  ...(sheet ? { sheet } : {}),
66
87
  ...(thumb ? { thumb } : {}),
67
88
  },
68
89
  findings,
90
+ coverage,
69
91
  );
70
92
  await Bun.write(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
71
93
  return manifest;
@@ -77,6 +99,7 @@ function print(m: AssetManifest): void {
77
99
  );
78
100
  for (const f of [
79
101
  ...m.files.lods,
102
+ ...(m.files.single ? [m.files.single] : []),
80
103
  ...(m.files.hero ? [m.files.hero] : []),
81
104
  ...(m.files.sheet ? [m.files.sheet] : []),
82
105
  ])
@@ -108,11 +131,12 @@ export async function run(argv: readonly string[]): Promise<number> {
108
131
  }
109
132
  const built: AssetManifest[] = [];
110
133
  const rendered: RenderResult[] = [];
111
- // Paint the trim sheet once per run (~1 s at 1024²). External mode writes it once into <out>/
134
+ // Paint the trim sheet once per run (~4 s at 2048²). External mode writes it once into <out>/
112
135
  // and every GLB in <out>/<asset>/ references ../trim_sheet_*.png — one texture set for all.
113
136
  // The beauty renders sample the same painted sheet.
114
137
  const renders = args.cmd === 'hero' || args.cmd === 'review';
115
138
  const wantsSheet = (args.cmd === 'build' && args.sheet !== 'none') || renders;
139
+ if (wantsSheet) provideTextures(await loadTextureCache());
116
140
  const painted = wantsSheet
117
141
  ? paintTrimSheet({ ...DEFAULT_SHEET, size: args.sheetSize })
118
142
  : undefined;
@@ -0,0 +1,24 @@
1
+ // The local CC0 texture cache (`bun run textures` fills it): every file re-verified by its sha256
2
+ // name, handed to `provideTextures` before painting. Missing files are fine here; painting a
3
+ // material that needs one throws T3D_TEXTURE_MISSING with the fetch command.
4
+
5
+ import { readdir } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import type { TextureCache } from '../material/index.ts';
8
+
9
+ export const TEXTURE_CACHE = join('node_modules', '.cache', 'ts23d', 'textures');
10
+
11
+ const sha256 = (b: Uint8Array): string => new Bun.CryptoHasher('sha256').update(b).digest('hex');
12
+
13
+ /** Every cached map whose bytes still match its sha256 name. */
14
+ export async function loadTextureCache(dir = TEXTURE_CACHE): Promise<TextureCache> {
15
+ const out = new Map<string, Uint8Array>();
16
+ const names = await readdir(dir).catch(() => [] as string[]);
17
+ for (const name of names.sort()) {
18
+ const m = /^([0-9a-f]{64})\.png$/.exec(name);
19
+ if (!m?.[1]) continue;
20
+ const bytes = new Uint8Array(await Bun.file(join(dir, name)).arrayBuffer());
21
+ if (sha256(bytes) === m[1]) out.set(m[1], bytes);
22
+ }
23
+ return out;
24
+ }
package/src/core/asset.ts CHANGED
@@ -113,6 +113,7 @@ export function compile(asset: Asset, options: { lod?: Lod; seed?: number } = {}
113
113
  parts: [...parts.values()],
114
114
  ...metaOf(asset),
115
115
  ...(asset.glow?.length ? { glow: asset.glow } : {}),
116
+ ...(asset.lodCoverage ? { lodCoverage: asset.lodCoverage } : {}),
116
117
  };
117
118
  }
118
119
 
@@ -128,6 +128,12 @@ export interface AssetDefinition {
128
128
  readonly creators?: readonly string[];
129
129
  /** Emissive part sets; `t3d build` writes them as glowing materials. */
130
130
  readonly glow?: readonly Glow[];
131
+ /**
132
+ * View-distance hints for the single-file GLB (MSFT_lod `MSFT_screencoverage`): the screen-height
133
+ * fraction below which LOD0 → LOD1, LOD1 → LOD2, and LOD2 is culled (0 = never). Strictly
134
+ * descending, 1 > a > b > c >= 0. Omitted: derived from the triangle counts and size.
135
+ */
136
+ readonly lodCoverage?: readonly [number, number, number];
131
137
  build(ctx: BuildContext): void;
132
138
  }
133
139
 
@@ -146,4 +152,5 @@ export interface CompiledAsset {
146
152
  /** Provenance carried from the definition (absent when the recipe declares none). */
147
153
  readonly meta?: AssetMeta;
148
154
  readonly glow?: readonly Glow[];
155
+ readonly lodCoverage?: readonly [number, number, number];
149
156
  }
@@ -13,7 +13,12 @@ export type ErrorCode =
13
13
  | 'T3D_RENDER_OPTIONS'
14
14
  | 'T3D_AMBIENT_RANDOM'
15
15
  | 'T3D_RELEASE'
16
- | 'T3D_BENCH';
16
+ | 'T3D_BENCH'
17
+ | 'T3D_MATERIAL_INVALID'
18
+ | 'T3D_MATERIAL_UNKNOWN'
19
+ | 'T3D_MATERIAL_CONFLICT'
20
+ | 'T3D_SHEET_FULL'
21
+ | 'T3D_TEXTURE_MISSING';
17
22
 
18
23
  export interface ErrorDetails {
19
24
  readonly cause: string;
@@ -0,0 +1,25 @@
1
+ // Texture credits into the GLB: when an asset samples an image strip (a CC0 texture set registered
2
+ // with defineMaterial), its credit line joins the XMP packet's `dc:contributor`. Assets that use
3
+ // only procedural strips get no change, so their bytes stay identical.
4
+
5
+ import type { CompiledAsset } from '../core/index.ts';
6
+ import { stripCredits } from '../material/index.ts';
7
+ import type { GlbMeta } from './metadata.ts';
8
+
9
+ export function creditsOf(asset: CompiledAsset): string[] {
10
+ const out = new Set<string>();
11
+ for (const p of asset.parts) {
12
+ const uv = p.mesh?.uvs;
13
+ if (!uv) continue;
14
+ const v = new Float32Array(uv.length >> 1);
15
+ for (let i = 0; i < v.length; i++) v[i] = uv[i * 2 + 1] ?? 0;
16
+ for (const c of stripCredits(v)) out.add(c);
17
+ }
18
+ return [...out].sort();
19
+ }
20
+
21
+ export function withCredits(asset: CompiledAsset, meta: GlbMeta | undefined): GlbMeta | undefined {
22
+ const credits = creditsOf(asset);
23
+ if (!credits.length) return meta;
24
+ return { ...meta, credits: [...(meta?.credits ?? []), ...credits] };
25
+ }
@@ -0,0 +1,53 @@
1
+ // Options and results of the GLB writer (glb.ts): what goes into the file and what comes back.
2
+
3
+ import type { OptimizeOptions } from '../passes/index.ts';
4
+ import type { LodCoverage } from './lod-coverage.ts';
5
+ import type { GlowRule } from './materials.ts';
6
+ import type { GlbMeta } from './metadata.ts';
7
+ import type { ExportReport } from './optimize-parts.ts';
8
+ import type { SheetBinding } from './textures.ts';
9
+
10
+ export interface GlbOptions {
11
+ /** Keep every part as its own node (debugging, per-part picking). Default merges to pivots. */
12
+ readonly keepParts?: boolean;
13
+ /**
14
+ * Bake each part's `motion` into a looping `run` clip. Off by default: glTF has no autoplay
15
+ * flag and most viewers play the first clip, while engines usually drive the named pivots
16
+ * from game code. Turn it on for previews, cutscenes and ambient props.
17
+ */
18
+ readonly animations?: boolean;
19
+ /**
20
+ * Bind the painted trim sheet to every material: a `PaintedSheet` embeds the three PNGs (one
21
+ * self-contained file); `{ external: true, prefix? }` references shared `trim_sheet_*.png` by
22
+ * URI. Omitted: no textures, byte-identical to the untextured output.
23
+ */
24
+ readonly sheet?: SheetBinding;
25
+ /**
26
+ * Weld + vertex-cache + vertex-fetch optimisation per primitive (passes/optimize). On by
27
+ * default; `false` writes the meshes exactly as compiled (debugging the IR).
28
+ */
29
+ readonly optimize?: boolean | OptimizeOptions;
30
+ /** Parts that emit light (lamp lenses, lit windows): one emissive material per rule. */
31
+ readonly glow?: readonly GlowRule[];
32
+ /** Provenance: asset.copyright + an XMP packet (title, creators, licence, sources). */
33
+ readonly meta?: GlbMeta;
34
+ /**
35
+ * Single-file LODs only: [LOD0→LOD1, LOD1→LOD2, cull] screen-height fractions written as
36
+ * `MSFT_screencoverage`. Default: the asset's `lodCoverage`, else derived (lod-coverage.ts).
37
+ */
38
+ readonly lodCoverage?: LodCoverage;
39
+ }
40
+
41
+ /** The GLB bytes plus what the optimiser did (vertex counts, ACMR before/after, per part). */
42
+ export interface GlbResult {
43
+ readonly glb: Uint8Array;
44
+ readonly report: ExportReport;
45
+ }
46
+
47
+ /** One GLB holding several LODs, plus one optimiser report per LOD (same order as the input). */
48
+ export interface LodGlbResult {
49
+ readonly glb: Uint8Array;
50
+ readonly reports: readonly ExportReport[];
51
+ /** The `MSFT_screencoverage` written (empty for a single LOD). */
52
+ readonly coverage: readonly number[];
53
+ }