@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
@@ -0,0 +1,166 @@
1
+ # 10 — Sculpting: what artists do, and what it means for SDF edit lists
2
+
3
+ **Artists fight topology so they can push clay; ts23d has no topology until it meshes. Most of the sculpting toolbox maps onto the existing `sculpt` edit list as new edit kinds, not as a mesh editor.** This page surveys how sculptors work (Blender, ZBrush, the SpeedSculpt add-on), maps each technique onto `src/primitives/sculpt*.ts` and `sdf*.ts`, and proposes the next DSL steps. Research as of 2026-11; everything under "Proposed" is **planned, not shipped**.
4
+
5
+ ## How sculptors work
6
+
7
+ | Technique | What it does | Source |
8
+ |---|---|---|
9
+ | **Voxel remesh** (Blender Remesh, ZBrush DynaMesh) | Rebuild the whole mesh as an even, manifold grid at one resolution. Blender's Remesh modifier has Blocks / Smooth / Sharp / Voxel modes; Voxel "generate[s] a new manifold mesh from the current geometry" via OpenVDB. Used for **base forms**: merge blobs, keep brush strokes even | [Blender: Remesh](https://docs.blender.org/manual/en/latest/modeling/modifiers/generate/remesh.html), [ZBrush base mesh creation](https://help.maxon.net/zbr/en-us/Content/html/features/main-features/base-mesh-creation/base-mesh-creation.html) |
10
+ | **Dyntopo** (dynamic topology) | Subdivides/collapses under the brush during a stroke: "most brushes will subdivide the mesh during the stroke". Detail size is Relative (screen pixels), Constant (fraction of a unit), Brush or Manual. Used for **local detail** without densifying everything | [Blender: Dyntopo](https://docs.blender.org/manual/en/latest/sculpt_paint/sculpting/tool_settings/dyntopo.html) |
11
+ | **Retopology** (ZRemesher, QuadriFlow, by hand) | After the form is final, a clean low-poly cage with controlled edge flow; community order is "DynaMesh first, ZRemesher last" | [ZBrushCentral thread](https://zbrushcentral.com/t/what-is-the-difference-between-dynamesh-and-zremesher-advantages/324598) (practitioner opinion) |
12
+ | **Skin / ZSpheres** | A vertex-and-edge skeleton with a radius per vertex becomes a mostly-quad tube surface. Blender: one **root** per connected set; **branch smoothing** relaxes joints "with the side effect of shrinking it". ZSpheres: "Adaptive Skin" around a sphere chain | [Blender: Skin](https://docs.blender.org/manual/en/latest/modeling/modifiers/generate/skin.html), [ZBrush ZSpheres](https://help.maxon.net/zbp/en-us/Content/html/4_palettes_tool-3-zsphere-settings.html) |
13
+ | **Lattice deform** | A coarse control cage bends everything inside it; non-destructive, keeps UVs, one lattice drives several objects. Theory: free-form deformation with trivariate Bernstein polynomials (Sederberg & Parry, SIGGRAPH 1986) | [Blender: Lattice modifier](https://docs.blender.org/manual/en/latest/modeling/modifiers/deform/lattice.html), [Sederberg & Parry 1986](https://history.siggraph.org/?p=116034) |
14
+ | **SpeedSculpt** (paid add-on, Pitiwazou) | A workflow wrapper over Blender: build from primitives with auto mirror + parenting, curves, **Draw Skin** (tails, horns), Dyntopo + Remesh, **Geomesh** ("create a final version of your meshes without having to combine them": non-destructive union preview), Booleans, Lattice from a mask, **Quick Pose** (bones for a part), Decimate, masks, cutters. Lathe appears in forum discussion but not on the current product page | [Superhive: SpeedSculpt](https://superhivemarket.com/products/speedsculpt), [Blender Artists thread](https://blenderartists.org/t/speedsculpt-addon/673722) |
15
+ | **Primary → secondary → tertiary** | Block the big planes (skull, cheekbones, sockets, jaw), then features and folds, then surface detail (pores, wrinkles). Stylised work exaggerates primaries and keeps planes crisp | [Polycount discussion](https://polycount.com/discussion/comment/2555887), Gnomon "Sculpting the Planes of the Head" (course; description only) |
16
+ | **Reference planes** | Front and side orthographic images behind the model; today often AI-generated turnarounds. Sculptors check silhouette against them in ortho views | Common practice; no single authoritative source |
17
+
18
+ **The pattern:** remesh, Dyntopo and retopology all exist because a triangle mesh stores the form *and* its sampling in the same data. Push a region and it runs out of triangles; merge two blobs and the topology tangles. An SDF edit list stores only the form. Sampling happens once, at the end, at whatever `resolution` the LOD asks for.
19
+
20
+ ## What ts23d already has
21
+
22
+ | Artist concept | ts23d today | Where |
23
+ |---|---|---|
24
+ | Ordered clay edits: add, subtract, intersect, paint | `sculpt({ edits })`, Dreams-style smooth-min fold with `blend` radius; colour rides the same weights | `src/primitives/sculpt.ts`, `sculpt-tree.ts` |
25
+ | Non-destructive booleans (Geomesh) | Edits never collapse; `op: 'subtract' \| 'intersect'`; **groups** fold their own edits first, so a subtract inside a group trims only that group | `sculpt-edit.ts` (`SculptGroup`) |
26
+ | Voxel remesh (base forms) | `sdf()` samples a cubic grid; `resolution` (cell edge, metres) *is* the voxel size and the LOD knob; surface nets (default) or marching cubes; always closed | `sdf.ts`, `sdf-grid.ts`, `surface-nets.ts` |
27
+ | Symmetry (mirror X in Blender) | `mirror: 'y'`, per-edit opt-out | `sculpt.ts` |
28
+ | Primitive library | sphere, ellipsoid, capsule, roundBox, cone, torus + rotation | `sculpt-edit.ts`, `sdf-ops.ts` (`shapes`, `sd`) |
29
+ | Noise displacement (tertiary skin, rock pitting) | `sd.displace(f, valueNoise(rng), amount)`; `sculptField` exposes the field for it | `sdf-ops.ts`, `value-noise.ts` |
30
+ | Primary → secondary → tertiary order | The `head` kind writes skull edits primary → secondary, explicitly "the ZBrush order" | `src/kinds/head-face.ts`, `head-extras.ts` |
31
+ | Hand-painted cavity + top-light tone | `toned()` samples the field: cavity, warm key, cool bounce, into vertex colour | `src/kinds/head-tone.ts` |
32
+ | Smooth normals at low res | Normals from the field gradient, so a coarse LOD2 still shades like the exact surface (vertex-frequency "high→low") | `sdf.ts` |
33
+ | Sparse evaluation | Bounding-sphere skips, 8³ block culling by Lipschitz bound | `sculpt-tree.ts`, `sdf-grid.ts` |
34
+ | Lathe | Polygon `lathe` for hard surface (not an SDF shape) | `lathe.ts` |
35
+ | Lattice | **Name clash:** `lattice` is the truss primitive, not a deformer | `lattice.ts` |
36
+
37
+ Missing: brush strokes along a path, tapered tubes (draw-skin), revolve as an SDF shape, cage/lattice deformation, bend/twist/taper warps, posing a group about a pivot, adaptive resolution, reference-image overlays.
38
+
39
+ ## Proposed: brushes as SDF edits (planned)
40
+
41
+ Each brush is a new **edit kind** in the same list, so it composes with groups, mirror, colour and blend for free. Every proposal keeps determinism (pure functions of the edit data) and the zero-dependency rule.
42
+
43
+ ### 1. Strokes: `crease`, `inflate`, `flatten`, `pinch` along a path
44
+
45
+ ```ts
46
+ interface SculptStroke {
47
+ readonly brush: 'clay' | 'crease' | 'inflate' | 'flatten' | 'pinch';
48
+ readonly path: readonly Vec3[]; // polyline, metres (a curve stroke)
49
+ readonly radius: number | readonly number[]; // per path point = pressure taper
50
+ readonly strength?: number; // metres of push at the centre, signed
51
+ readonly falloff?: 'smooth' | 'sharp' | 'linear';
52
+ readonly color?: Vec3; readonly mirror?: boolean;
53
+ }
54
+ type SculptEdit = SculptShapeEdit | SculptGroup | SculptStroke; // sculpt-edit.ts
55
+ ```
56
+
57
+ | Brush | Field operation | Lipschitz cost |
58
+ |---|---|---|
59
+ | `clay` | smooth-union a flattened tapered capsule chain along the path | 1 (exact-ish) |
60
+ | `crease` | smooth-subtract a thin tapered capsule chain with a small `blend`: a V-groove (eyelid fold, armour seam, knuckle) | 1 |
61
+ | `inflate` | `d − strength · w(p)`, w = falloff of distance to the path | 1 + strength/radius |
62
+ | `flatten` | blend `d` toward the distance to the plane fitted through the path, weight w(p) | ≤ 2 |
63
+ | `pinch` | domain warp: pull `p` toward the path by `strength · w(p)` before evaluating everything earlier | 1/(1 − max warp gradient) |
64
+
65
+ The Lipschitz column matters: `sdf-grid.ts` skips whole 8³ blocks using a Lipschitz bound. Inflate and warps stretch distances, so each stroke must raise the bound it hands to `sampleGrid`, or the grid can cull real surface (a hole, not an error). The compiler sums the bounds; a test fails if a stroke's declared bound is exceeded at sample points. Size: **M** (crease/clay/inflate), **M** more for pinch/flatten.
66
+
67
+ ### 2. Draw-skin: `skin()` as a capsule chain with two radii
68
+
69
+ ```ts
70
+ interface SkinNode { readonly at: Vec3; readonly r: number }
71
+ function skin(o: {
72
+ nodes: readonly SkinNode[]; edges: readonly (readonly [number, number])[];
73
+ blend?: number; // branch smoothing: smooth-min radius at joints (metres)
74
+ color?: Vec3; op?: 'add' | 'subtract';
75
+ }): SculptGroup; // pure helper: returns edits, no new evaluator
76
+ ```
77
+
78
+ Needs one new shape: `roundCone` (a capsule with radius `r1` at `at`, `r2` at `to`; Quílez's exact round-cone distance). `blend` *is* Blender's branch smoothing, without the shrink: smooth-min adds material at joints instead of relaxing it away. Tails, horns, tentacles, roots, limbs. The same node graph becomes the bone list for glTF skins later ([gltf-capabilities](../architecture/gltf-capabilities.md#skins-how-ts23d-will-rig-without-auto-rigging)). Size: **S**.
79
+
80
+ ### 3. Revolve: lathe as an SDF shape
81
+
82
+ `{ shape: 'revolve', profile: Vec2[], at, rotate? }`: the 2D polygon distance evaluated at `(√(x²+y²), z)`. Vases, helmets, pommels, mushroom caps that must blend into organic edits (the polygon `lathe` cannot smooth-union). Size: **S–M**.
83
+
84
+ ### 4. Cage deform (FFD): `ffd()`
85
+
86
+ Named `ffd` because `lattice` is taken by the truss.
87
+
88
+ ```ts
89
+ interface Ffd {
90
+ readonly box: readonly [Vec3, Vec3]; // the undeformed cage
91
+ readonly divisions: readonly [number, number, number]; // control points per axis, 2..6
92
+ readonly move: ReadonlyArray<{ readonly index: readonly [number, number, number]; readonly by: Vec3 }>;
93
+ }
94
+ function ffd(mesh: Mesh, cage: Ffd): Mesh; // passes tier: Bernstein FFD on vertices
95
+ ```
96
+
97
+ Apply it **after** meshing, to vertices (Sederberg–Parry forward map), then recompute normals. Works on any mesh (sculpt, loft, lathe, imported), keeps UVs and colours, is exact and cheap. Inverting FFD to warp an SDF query point needs Newton iteration per sample and breaks the Lipschitz cull, so field-space FFD is not proposed. Size: **S–M**. Use: squash a whole bust into a chibi, lean a tower, bulge a barrel.
98
+
99
+ ### 5. Bend, twist, taper on groups
100
+
101
+ ```ts
102
+ interface SculptGroup { /* … */ readonly warp?: { twist?: number /* °/m about local Z */;
103
+ bend?: number /* °/m */; taper?: number /* scale/m */; pivot?: Vec3; axis?: Vec3 } }
104
+ ```
105
+
106
+ Domain warps on the query point before the group's edits, like Quílez's twist/bend operators; each reports its Lipschitz factor (same rule as strokes). Horns that curl, banners, bent pipes in an organic piece. Size: **S**.
107
+
108
+ ### 6. Quick pose: a group about a pivot
109
+
110
+ ```ts
111
+ interface SculptGroup { /* … */ readonly pose?: { readonly pivot: Vec3; readonly rotate: Vec3 } }
112
+ ```
113
+
114
+ Rotate a whole group (an arm, the jaw, a tail) about a joint before folding. Sculptors pose to check silhouette; here it also prepares rigging: the posed group *is* the joint. Size: **S**.
115
+
116
+ ### 7. Forms as an LOD and review axis
117
+
118
+ ```ts
119
+ interface SculptShapeEdit { /* … */ readonly form?: 'primary' | 'secondary' | 'tertiary' }
120
+ sculpt({ edits, resolution, forms: ctx.pick([['primary','secondary','tertiary'], ['primary','secondary'], ['primary']]) })
121
+ ```
122
+
123
+ Tertiary forms below the cell size cost triangles and alias anyway; dropping them by LOD makes LOD2 cleaner *and* faster to sample. `t3d review` can render a "primary only" view: a stylised character that reads from that view reads at a distance (a classic sculpting check). Size: **S**.
124
+
125
+ ### 8. Reference overlays: front/side images in the contact sheet
126
+
127
+ ```
128
+ t3d shot recipes/goblin_head/goblin_head.ts --ref front=refs/front.png --ref side=refs/side.png
129
+ ```
130
+
131
+ The contact sheet is already orthographic with a metre grid (`src/render/sheet.ts`), so an aligned overlay of a front/side turnaround (AI-generated or drawn) is a blend into the matching tile, plus a silhouette IoU number per view. This is the orthographic cousin of the planned `photoMatch` (`src/render/photo-match.ts`, throws `T3D_NOT_IMPLEMENTED`). Images are inputs only; nothing ships them. Size: **M**.
132
+
133
+ ### 9. Adaptive resolution (the Dyntopo analogue)
134
+
135
+ Uniform grids spend cells on flat cranium as on lips. Options: an octree of cells refined where `|d| < h` and the edit tree is dense, meshed by dual contouring (Ju et al., SIGGRAPH 2002) to keep sharp features; or a per-edit `detail` hint that refines only inside that edit's bounds. Deterministic if refinement order is fixed. Size: **L**; only worth it once a recipe hits `MAX_POINTS` (`sdf-grid.ts`) or the 1 s budget.
136
+
137
+ ## What not to build
138
+
139
+ | Idea | Why not |
140
+ |---|---|
141
+ | Interactive mesh brushes on triangle meshes | Stroke-by-stroke mesh mutation is what remesh/Dyntopo exist to patch; it is not text an agent can diff. The edit list is the sculpt |
142
+ | Retopology to quads | glTF engines draw triangles; edge flow matters for subdivision and hand-skinning, neither of which ts23d does. Skin weights come from blend weights instead |
143
+ | Realistic anatomy as a goal | Stylised readable forms are the fit ([07](07-what-fits.md)); realism goes through `importGlb` / `/neural` |
144
+
145
+ ## Next steps for ts23d (prioritised)
146
+
147
+ 1. **`roundCone` shape + `skin()` helper** (draw-skin). S. Unlocks tails, horns, limbs, roots; feeds the `creature` kind (M4).
148
+ 2. **`form` tags + `forms` LOD filter** on sculpt edits, and a primary-only review view. S.
149
+ 3. **Group `pose` and `warp` (twist/bend/taper)** with declared Lipschitz factors. S.
150
+ 4. **`ffd()` cage deform pass** on any mesh (Bernstein, vertices). S–M.
151
+ 5. **Strokes: `crease`, `clay`, `inflate`** along a path with a radius taper, Lipschitz-aware culling and a test that catches an under-declared bound. M.
152
+ 6. **`--ref front=… side=…` overlays + per-view silhouette IoU** in `t3d shot`. M.
153
+ 7. **`revolve` SDF shape.** S–M.
154
+ 8. **`pinch` / `flatten` strokes.** M.
155
+ 9. **Adaptive (octree / dual contouring) meshing**, only when a recipe needs it. L.
156
+
157
+ ## Sources
158
+
159
+ - Blender Manual 5.2 LTS: [Remesh](https://docs.blender.org/manual/en/latest/modeling/modifiers/generate/remesh.html) · [Dyntopo](https://docs.blender.org/manual/en/latest/sculpt_paint/sculpting/tool_settings/dyntopo.html) · [Skin](https://docs.blender.org/manual/en/latest/modeling/modifiers/generate/skin.html) · [Lattice](https://docs.blender.org/manual/en/latest/modeling/modifiers/deform/lattice.html)
160
+ - Maxon ZBrush docs: [Base mesh creation (DynaMesh, ZRemesher, ZSpheres)](https://help.maxon.net/zbr/en-us/Content/html/features/main-features/base-mesh-creation/base-mesh-creation.html)
161
+ - SpeedSculpt: [Superhive product page](https://superhivemarket.com/products/speedsculpt), [Blender Artists thread](https://blenderartists.org/t/speedsculpt-addon/673722)
162
+ - T. Sederberg, S. Parry, "Free-form deformation of solid geometric models", SIGGRAPH 1986, [archive](https://history.siggraph.org/?p=116034)
163
+ - A. Evans, "Learning from failure" (Dreams SDF sculpting), SIGGRAPH 2015, [slides](https://advances.realtimerendering.com/s2015/AlexEvans_SIGGRAPH-2015-sml.pdf)
164
+ - I. Quílez, distance functions and domain operators, iquilezles.org/articles/distfunctions (page unreachable when checked 2026-11-07; formulas as widely reproduced)
165
+ - T. Ju et al., "Dual Contouring of Hermite Data", SIGGRAPH 2002
166
+ - Forms order: [Polycount discussion](https://polycount.com/discussion/comment/2555887) (practitioner convention, not a standard)
@@ -0,0 +1,132 @@
1
+ # 11 — Authoring materials: node graphs, edge wear, and the one trim sheet
2
+
3
+ **Substance Designer, Material Maker and ArmorPaint all run the same chain: noise → height → normal / AO / curvature → masks → albedo, roughness, metalness. ts23d's painter already runs that chain in TypeScript; what it lacks is curvature, so edges can't wear yet.** This page maps the material-authoring toolbox onto `src/material/` and proposes where edge wear and roughness variation should live, given that every asset shares one trim sheet ([04](04-materials.md), [ADR 008](../decisions/008-surface-materials.md)). Research as of 2026-11; everything under "Proposed" is **planned, not shipped**.
4
+
5
+ ## How material artists work
6
+
7
+ | Tool | Model | Licence / cost | Source |
8
+ |---|---|---|---|
9
+ | Substance 3D Designer (Adobe) | Node graph of generators and filters, output = tileable PBR maps; **mesh-based mask generators** take baked curvature + AO | Commercial subscription | [Edge Damages node](https://helpx.adobe.com/substance-3d-designer/substance-compositing-graphs/nodes-reference-for-substance-compositing-graphs/node-library/mesh-based-generators/mask-generators/edge-damages.html) |
10
+ | Substance 3D Painter | Paint on the mesh; "smart materials" are generator stacks (Metal Edge Wear, …) driven by baked mesh maps | Commercial | [Metal Edge Wear](https://experienceleague.adobe.com/en/docs/substance-3d-painter/using/effects/generators/metal-edge-wear) |
11
+ | Material Maker | Node graph like Designer, also paints models; built on Godot | MIT ("files in this repository are licensed under the MIT license") | [GitHub](https://github.com/RodZill4/material-maker) |
12
+ | ArmorPaint (ArmorTools) | Painter-style 3D texture painting, node materials | Source zlib/libpng; "Distributed binaries are paid" | [GitHub](https://github.com/armory3d/armortools), [licence](https://raw.githubusercontent.com/armory3d/armortools/main/license.md) |
13
+ | Blender shader nodes + bake | Procedural nodes, baked to images with Cycles | GPL software; output is yours | [Blender licence](https://www.blender.org/about/license/) |
14
+
15
+ ### The chain, step by step
16
+
17
+ | Step | What artists do | Why |
18
+ |---|---|---|
19
+ | 1. Shapes + noise | Tile generators (bricks, planks), Perlin/cells/fBm, warps | All structure starts as a greyscale field |
20
+ | 2. **Height** | Combine shapes into one height map | The single source of truth: everything else derives from it |
21
+ | 3. Normal | Gradient of height (Sobel / Scharr) at known texel size | Relief that lights correctly |
22
+ | 4. AO / cavity | Blur(height) − height, or ray-marched | Dirt collects, light doesn't reach |
23
+ | 5. **Curvature** | Convex edges (+) vs concave creases (−) from the height Laplacian, or baked from the mesh | Drives edge wear, dust, highlights |
24
+ | 6. Masks | Thresholded noise × curvature × AO; **slope blur in Min mode** to chew a mask edge into chips | Wear goes where it would happen physically |
25
+ | 7. Albedo / roughness / metal | Gradient-map masks into colour, roughness and metalness | Realism lives here: roughness *variation* reads as material more than colour does |
26
+
27
+ Edge damage specifically: Designer's **Edge Damages** "represents damage done to raised, convex edges based on curvature and baked AO" (both inputs required); Adobe's own tutorial chips a height map with slope blur / multi-directional warp in Min mode, then subtracts the damaged height from the original to extract the damaged areas ([tutorial](https://www.adobe.com/learn/substance-3d-designer/web/create-edge-damage)). Practitioners note low-poly hard edges give weak curvature bakes and UV seams break edge masks ([Unreal forum](https://forums.unrealengine.com/t/help-with-turning-a-curvature-map-into-a-mask-for-edge-wear/48349), [Polycount](https://polycount.com/discussion/comment/2571946)).
28
+
29
+ ## What ts23d already has
30
+
31
+ | Chain step | ts23d today | Where |
32
+ |---|---|---|
33
+ | Canvas in real metres | Layers `height` (m), `tone`, `rough`, `metal`, `mix` (two-colour blend), `occl` | `src/material/paint/canvas.ts` |
34
+ | Shapes | Running-bond layout shared by bricks, stone, planks, slates, plates; SDF helpers for decal motifs | `paint/courses.ts`, `paint/shapes.ts` |
35
+ | Noise | Seeded, periodic-in-U value noise + fBm (strips tile) | `paint/noise.ts` |
36
+ | Normal | Sobel of height over texel size in metres, glTF tangent space | `paint/normal.ts` |
37
+ | AO / cavity | Blurred height − height, packed into ORM R; mild cavity darkening baked into albedo | `paint/orm.ts` |
38
+ | Masks | `rustStreaks`, `grime` (bottom pooling + rain streaks) | `paint/masks.ts` |
39
+ | Edge chips, texture-space | Paint films with `chips` revealing albedo2 (planks: mostly along board edges; metal; plates: chipped paint on edges) | `paint/planks.ts`, `paint/metal.ts`, `paint/plates.ts` |
40
+ | Measured PBR | 16 library strips with physicallybased.info reflectance, sRGB 30–240 albedo range | `src/material/library.ts` |
41
+ | Project materials | `defineMaterial({ kind, roughness, albedo, albedo2, params, paint })`; a `paint` layer gets a `PaintKit` (tileable noise, hash, smooth, `each`) and is NaN-checked | `define.ts`, `material-def.ts`, `paint/kit.ts` |
42
+ | Image inputs | PNG stamped into cells | `paint/stamp.ts`, `paint/decode-png.ts` |
43
+ | Mesh-space wear | `grime` (vertex colour near ground / zones), `bakeAo` (ray-cast AO into vertex colour), `livery` (paint by rules) | `src/passes/` |
44
+ | Shader-side wear | `ShaderHints.wear`: darkens + roughens **cavities** (1 − AO) and ground grime | `src/material/shader.ts` |
45
+ | Hard-surface realism | `box({ bevel })` chamfers, `weightedNormals` (big faces flat, bevels catch light) | `primitives/bevel-box.ts`, `passes/weighted-normals.ts` |
46
+
47
+ **The structural constraint.** A strip is shared by every asset and tiles along U, so the texture cannot know where *this* mesh's edges are. Substance bakes curvature per asset; ts23d can't, without giving up one material. So wear splits in two: **texture-space** wear (edges *inside* the pattern: brick arrises, plank ends, plate laps), which belongs in the painter, and **mesh-space** wear (the silhouette edges of this hull), which belongs in vertex data and the shader. Today only cavities are handled mesh-side; convex edges are not.
48
+
49
+ ## Proposed (planned)
50
+
51
+ ### 1. Texture-space curvature and `edgeWear` in the painter
52
+
53
+ ```ts
54
+ // src/material/paint/curvature.ts — Laplacian of height (metres⁻¹), wraps along U for strips
55
+ export function curvature(c: Canvas, radiusMetres: number): Float32Array; // + convex, − concave
56
+ // src/material/paint/masks.ts
57
+ export function edgeWear(c: Canvas, o: {
58
+ amount: number; // 0..1, share of convex edge exposed
59
+ width: number; // metres of edge the wear can reach
60
+ chip: number; // 0..1 noise erosion of the mask edge (slope-blur-min analogue)
61
+ to: 'albedo2' | 'lighter'; // reveal the second colour (bare metal, raw wood) or just lighten
62
+ roughness?: number; metalness?: number; // what is exposed (bare steel: 0.35, 1)
63
+ }, seed: number): void;
64
+ ```
65
+
66
+ Exposed via `PaintKit.curvature()` and a shared `params.edgeWear` (like today's `params.grime`, `paint/strip.ts`), so every strip kind gets it. Chipping: threshold `curvature × (1 + chip·fbm)`, then a min-filter pass in a noise-warped direction: deterministic, a few lines, no GPU. Size: **S**.
67
+
68
+ ### 2. Mesh-space convexity → edge wear in vertex data
69
+
70
+ ```ts
71
+ // src/passes/edge-wear.ts
72
+ export interface EdgeWearOptions {
73
+ readonly angle?: number; // dihedral (°) that counts as an edge; default 30
74
+ readonly width?: number; // metres the wear spreads from the edge (geodesic, capped)
75
+ readonly amount: number; // 0..1
76
+ readonly lighten?: number; // vertex-colour lift on worn edges (scuffed paint)
77
+ readonly attribute?: boolean; // also write a `_WEAR` 0..1 vertex attribute for the shader
78
+ }
79
+ export function edgeWear(mesh: Mesh, o: EdgeWearOptions): Mesh;
80
+ ```
81
+
82
+ Per-vertex convexity from the signed dihedral angles of incident edges (polygon meshes), or from the field Laplacian for `sdf`/`sculpt` meshes (the SDF Laplacian is the level set's mean curvature up to a convention factor, because |∇d| = 1; [StEik, eq. 14](https://arxiv.org/pdf/2305.18414)). `head-tone.ts` already probes the field for cavity; convexity is the same probe with the other sign. Lightening vertex colour works in every engine with no shader; `_WEAR` follows the `_WIND` precedent and lets `threeMaterialSource` / `godotShaderSource` blend toward bare metal (`mix`, roughness down, metalness up) per pixel. Needs the GLB writer to emit `_WEAR` (an UNOWNED change in `src/export/`). Size: **M**.
83
+
84
+ ### 3. An `edge` trim row: the trim-sheet way to wear edges
85
+
86
+ Production trim sheets carry dedicated bevel/edge strips: a chamfer mapped to a strip that is painted worn ([docs/idea/13](13-asset-pipeline-research.md#trim-sheets)). `box({ bevel })` already emits chamfer quads; mapping them to a `worn_edge` strip (bright scuffed metal or paint over primer, `edgeWear` at 100%) gives pixel-sharp wear on every bevelled asset with no new attribute and no new material. Costs one strip row (28 rows today, 4 custom slots: `trim-sheet.ts`). Size: **S–M**.
87
+
88
+ ### 4. Roughness variation as a first-class knob
89
+
90
+ Every painter writes `rough`; add `roughnessVariation` (0..1, default from the library) to `MaterialDef`, applied as low-frequency fBm + cavity-driven increase (dirt is rougher) + edge-driven decrease (handled edges polish). Validate that a strip's roughness histogram is not flat: a `material` test fails a strip whose roughness standard deviation is below a floor (a flat roughness map is the first sign of a CG look). Size: **S**.
91
+
92
+ ### 5. A node-graph-shaped helper API, as functions
93
+
94
+ Node graphs are hard for agents to diff ([ADR 001](../decisions/001-recipe-compiler-over-ai-meshes.md)); plain composable functions are not. Wrap the painter internals as pure layer ops a `defineMaterial({ paint })` can chain:
95
+
96
+ ```ts
97
+ paint(s, kit) {
98
+ const h = kit.layer(); // Float32Array, metres
99
+ kit.cells(h, { size: 0.04, jitter: 0.8 }); // Voronoi stones
100
+ kit.add(h, kit.fbm({ size: 0.3, amp: 0.002 }));
101
+ const wear = kit.mask(kit.curvature(h), { above: 0.6, chip: 0.5 });
102
+ kit.write(s, { height: h, rough: kit.lerp(0.7, 0.4, wear), mix: wear });
103
+ }
104
+ ```
105
+
106
+ `PaintKit` (`paint/types.ts`) is the place. Ops: `layer`, `fbm`, `cells` (Voronoi/Worley), `tiles`, `warp`, `slopeBlur`, `blur`, `curvature`, `mask`, `levels`, `lerp`, `write`. Each is a tested pure function over typed arrays, periodic in U. Size: **M**.
107
+
108
+ ### 6. Texel-density gate
109
+
110
+ The sheet is 2048² at 64 px per strip row; a strip is 2 m along U (`trim-sheet.ts`), i.e. ~1024 px/m along U at 2048². Industry examples: 10.24 px/cm first person, 5.12 px/cm third person ([Maxon: Texel density](https://help.maxon.net/c4d/en-us/Content/html/Texel_Density.html)). Strips are anisotropic (V spans `scaleMetres`), so a `texel-density` QA gate should report px/m along U and V per part and flag parts below a target, and faces where V is stretched (the `box` known limit in [04](04-materials.md#known-limits)). The roadmap already lists "Gates: … texel density" (M2). Size: **S–M**.
111
+
112
+ ### 7. CC0 photo textures as *inputs* to strips (not as maps)
113
+
114
+ ambientCG / Poly Haven / cgbookcase sets are CC0 ([13](13-asset-pipeline-research.md#free-cc0-libraries)). Copying their colour maps into the sheet breaks the greyscale-tint model and the size budget. Better: use them as **calibration** (sample albedo and roughness histograms to set `MaterialDef` numbers, cite the URL in `sources`) and, for a few strips, as a **height source**: a vendored CC0 displacement PNG, resampled to the strip, then run through the same normal/AO/curvature chain so it stays consistent. Vendored with a hash; never fetched at build time. Size: **M**.
115
+
116
+ ## Next steps for ts23d (prioritised)
117
+
118
+ 1. **`curvature()` + `edgeWear` mask in the painter**, exposed as `params.edgeWear` on every strip and on `PaintKit`. S.
119
+ 2. **`edgeWear(mesh)` pass**: dihedral convexity (polygon) / Laplacian (SDF) → vertex-colour lift. S–M. Then `_WEAR` attribute + shader-source support. M.
120
+ 3. **`roughnessVariation`** on `MaterialDef` + the flat-roughness test. S.
121
+ 4. **`worn_edge` strip** mapped by `box({ bevel })` chamfers. S–M.
122
+ 5. **`PaintKit` layer ops** (fbm, cells, slopeBlur, mask, levels, lerp, write). M.
123
+ 6. **Texel-density gate** (px/m per part, V-stretch warning). S–M.
124
+ 7. **CC0 height sources + calibration workflow** documented in the author-recipe skill. M.
125
+
126
+ ## Sources
127
+
128
+ - Adobe: [Edge Damages](https://helpx.adobe.com/substance-3d-designer/substance-compositing-graphs/nodes-reference-for-substance-compositing-graphs/node-library/mesh-based-generators/mask-generators/edge-damages.html), [Leather Wear](https://helpx.adobe.com/substance-3d-designer/substance-compositing-graphs/nodes-reference-for-substance-compositing-graphs/node-library/mesh-based-generators/mask-generators/leather-wear.html), [Create edge damage tutorial](https://www.adobe.com/learn/substance-3d-designer/web/create-edge-damage), [Painter Metal Edge Wear](https://experienceleague.adobe.com/en/docs/substance-3d-painter/using/effects/generators/metal-edge-wear)
129
+ - [Material Maker](https://github.com/RodZill4/material-maker) (MIT, Godot) · [ArmorTools](https://github.com/armory3d/armortools) (zlib source, paid binaries)
130
+ - Curvature of an SDF: Yang et al., "StEik", 2023, [arXiv:2305.18414](https://arxiv.org/pdf/2305.18414)
131
+ - Texel density examples: [Maxon Cinema 4D docs](https://help.maxon.net/c4d/en-us/Content/html/Texel_Density.html)
132
+ - Edge-mask pitfalls: [Unreal forum](https://forums.unrealengine.com/t/help-with-turning-a-curvature-map-into-a-mask-for-edge-wear/48349), [Polycount](https://polycount.com/discussion/comment/2571946) (practitioner reports)
@@ -0,0 +1,136 @@
1
+ # 12 — Blender bridge (design + feasibility)
2
+
3
+ **Status: all planned. Nothing here is shipped, and the core library never needs Blender.** An optional adapter could call a user-installed Blender headless (`blender -b --python`) for things ts23d's CPU paths do not do: Cycles path-traced beauty renders under a CC0 HDRI, high→low bakes, voxel remesh / decimate of imported meshes, and GLB round-trips through other formats. Blender output is not byte-deterministic across versions or hardware, so every Blender result is a **cached input**, keyed by content hash, exactly like the `/neural` cache ([08](08-neural-adapters.md)). Research as of 2026-11-07; Blender was not run on the authoring machine, so every command below is unverified on hardware.
4
+
5
+ ## Why at all
6
+
7
+ | Need | ts23d today | What Blender adds |
8
+ |---|---|---|
9
+ | Hero image for a README / store page | `beauty()`: zero-GPU rasteriser, key sun + PCF shadows, sky fill, AO, ORM-driven specular (`src/render/beauty.ts`, `shade.ts`) | Path-traced GI, HDRI lighting, depth of field, real glass |
10
+ | Bring in a messy mesh (CC0 base mesh, a sculpt, a neural output) | `importGlb` planned (M5) | Voxel remesh to a manifold, decimate to a budget, clean normals, before ts23d fits it |
11
+ | Per-asset baked maps | Not in the one-sheet model (vertex AO via `bakeAo`) | Selected-to-active normal/AO/curvature bakes, for an opt-in per-asset texture (needs an ADR, see below) |
12
+ | Format reach | GLB only | FBX/USD/OBJ in or out through Blender's importers (out of scope for core; [gltf-capabilities](../architecture/gltf-capabilities.md)) |
13
+
14
+ Not a reason: modelling. Recipes stay TypeScript; [ADR 001](../decisions/001-recipe-compiler-over-ai-meshes.md) rejected DCC node graphs because agents diff them poorly.
15
+
16
+ ## Verified facts the design rests on
17
+
18
+ | Fact | Source |
19
+ |---|---|
20
+ | `-b/--background` "Run in background (often used for UI-less rendering)"; `-P/--python <file>`; `--python-expr`; `--python-exit-code <code>` sets the exit code on a Python exception; `--factory-startup` skips the user's startup.blend; `-noaudio`; `--` ends option processing, rest goes to `sys.argv`; `-t/--threads` | [Blender CLI arguments](https://docs.blender.org/manual/en/latest/advanced/command_line/arguments.html) |
21
+ | `--python-use-system-env` is *opt-in*: by default Blender's Python ignores `PYTHONPATH` and user site-packages | same |
22
+ | LTS: 5.2 LTS and 4.5 LTS maintained (both updated 2026-10-22), "critical fixes throughout a 2-year time span", no new features or API changes | [Blender LTS](https://www.blender.org/download/lts/) |
23
+ | Bake settings: `use_selected_to_active`, `cage_extrusion` ("Inflate the active object…"), `cage_object`, `max_ray_distance` (0 = no limit) | [bpy BakeSettings](https://docs.blender.org/api/latest/bpy.types.BakeSettings.html) |
24
+ | glTF importer/exporter (Khronos) bundled since 2.80; `bpy.ops.export_scene.gltf(filepath=…, export_format='GLB')` headless | [glTF-Blender-IO](https://github.com/KhronosGroup/glTF-Blender-IO), [example](https://til.jakelazaroff.com/blender/export-a-blender-file-to-glb-from-the-command-line/) |
25
+ | Voxel remesh uses OpenVDB and makes a manifold mesh | [Remesh modifier](https://docs.blender.org/manual/en/latest/modeling/modifiers/generate/remesh.html) |
26
+ | Cycles is not bit-reproducible run to run in general (a user report: identical only with `-t 1`); CPU and GPU tiles drew different random values | [blender#101726](https://projects.blender.org/blender/blender/issues/101726) (could not open, 403; summarised from search), [T72141](https://developer.blender.org/T72141) — treat as unverified |
27
+ | Licence: "What you create with Blender is your sole property"; scripts using the Python API, if published, must be under a GPL-compliant licence | [Blender licence](https://www.blender.org/about/license/) |
28
+
29
+ ## Architecture
30
+
31
+ ```
32
+ ts23d (TypeScript, zero deps) user's machine, optional
33
+ ───────────────────────────── ────────────────────────
34
+ t3d blender render recipe.ts ─┐
35
+ compile → toGlb (bytes) │ job.json + in.glb + hdri path
36
+ hash(job) → cache lookup ───┼─ miss ─► Bun.spawn(blender, ['-b', '--factory-startup',
37
+ │ '-noaudio', '--python-exit-code', '3',
38
+ cache hit: read out.* ◄────┘ '--python', 'ts23d_job.py', '--', 'job.json'])
39
+ ◄─ out.png / out.glb + report.json (stdout last line)
40
+ .ts23d/blender/<sha256>/{out.*, report.json} committed or published like the neural cache
41
+ ```
42
+
43
+ | Piece | Where | Notes |
44
+ |---|---|---|
45
+ | Discovery | `blender` on PATH, or `T3D_BLENDER` / `--blender <path>` | Env is read by the **CLI only**, never by `src/` library code (same rule as API keys in [08](08-neural-adapters.md)) |
46
+ | Version pin | `blender --version` parsed; supported list in code (e.g. `['4.5', '5.2']`); others refused | Version (major.minor + build hash) goes into the cache key and `report.json` |
47
+ | Job | A JSON file: `{ op, input, params, seed, ts23dVersion }` | One Python entry script dispatches on `op`; no Python ever imported by TS |
48
+ | Python scripts | `blender/ts23d_job.py` + one file per op, shipped in the npm package as data | GPL-compliant licence required if published (Blender licence page). MIT is generally considered GPL-compatible; **confirm with counsel before publishing** |
49
+ | Module | New `src/blender/` at tier 3 (needs `export` for GLB bytes and `render` types), or `cli`-only | Must stay out of `src/index.ts` main barrel; subpath `@developerz.ai/ts23d/blender`. Needs `scripts/lib/tiers.ts` entry (UNOWNED) |
50
+ | Isolation | `--factory-startup` (no user add-ons, prefs), `--python-use-system-env` **off**, a temp working dir, timeout | User add-ons are the biggest source of "works on my machine" |
51
+
52
+ ### Operations
53
+
54
+ ```ts
55
+ // @developerz.ai/ts23d/blender — planned API, all async, all cache-first
56
+ interface BlenderOptions { readonly blender?: string; readonly cacheDir?: string; readonly offline?: boolean }
57
+
58
+ render(asset: CompiledAsset, o: BlenderOptions & {
59
+ hdri?: string; // path to a vendored CC0 .hdr/.exr (Poly Haven), hashed into the key
60
+ camera?: 'hero' | PerspectiveCamera; // default = beauty()'s cameraFor(), so shots line up
61
+ samples?: number; // default 128; denoise: 'oidn' | 'none'
62
+ size?: readonly [number, number];
63
+ }): Promise<Uint8Array>; // PNG
64
+
65
+ remesh(glb: Uint8Array, o: BlenderOptions & { voxel: number; decimate?: number /* target tris */ }): Promise<Uint8Array>; // GLB
66
+ bake(high: Uint8Array, low: Uint8Array, o: BlenderOptions & {
67
+ maps: readonly ('normal' | 'ao' | 'curvature')[]; size: 512 | 1024 | 2048;
68
+ cageExtrusion: number; maxRayDistance?: number;
69
+ }): Promise<Record<string, Uint8Array>>; // PNGs, low's UVs
70
+ convert(bytes: Uint8Array, o: BlenderOptions & { from: 'fbx' | 'obj' | 'usd'; }): Promise<Uint8Array>; // → GLB
71
+ ```
72
+
73
+ ```
74
+ t3d blender render recipes/t34/t34.ts --hdri refs/studio_small_09_2k.hdr --out renders # → cycles.png
75
+ t3d blender remesh in/horse.glb --voxel 0.01 --decimate 8000 --out in/horse_clean.glb
76
+ t3d blender bake --high in/rock_high.glb --low out/rock/lod2.glb --maps normal,ao --size 1024
77
+ t3d blender doctor # finds Blender, prints version, runs a 1-triangle job, reports timings
78
+ ```
79
+
80
+ Every command prints the cache key and, with `--json`, a machine-readable report, like the rest of the CLI.
81
+
82
+ ## Determinism: how to live with a non-deterministic tool
83
+
84
+ 1. **Blender never runs in the gate.** `bun run verify` and CI never call Blender. Committed `hero.png` stays the CPU `beauty()` render.
85
+ 2. **Results are inputs, cached by content.** Key = `sha256(op, params, input bytes, hdri bytes, script bytes, blender version+hash)`. A recipe that consumes a Blender result (say, a remeshed imported mesh) reads it from `.ts23d/blender/<key>/`; the compile stays byte-deterministic because the bytes are fixed.
86
+ 3. **Cache miss in CI = error, never a silent rebuild:** `T3D_BLENDER_CACHE_MISS` with `fix: run "t3d blender remesh … " locally with Blender 5.2 and commit .ts23d/blender/<key>`. Share the miss logic with the neural cache (one `src/core` or `src/cache` helper; design choice for the implementer).
87
+ 4. **Regeneration is explicit.** `--refresh` deletes the entry and reruns. A version bump of Blender changes the key, so old results keep working until someone regenerates.
88
+ 5. **Renders are compared perceptually, not by bytes.** If a Cycles image is ever checked in CI-like flows, compare by a downscaled mean-absolute-error threshold, not a hash. Fixed `scene.cycles.seed`, fixed samples, `-t 1` when bit-stability on one machine matters (slow; per the bug report above).
89
+ 6. **Geometry from Blender is normalised on the way back**: ts23d's own `optimize` (weld + Forsyth) runs on the imported mesh, so vertex order no longer depends on Blender internals once cached.
90
+
91
+ ## Errors (proposed codes)
92
+
93
+ | Code | When | `fix:` |
94
+ |---|---|---|
95
+ | `T3D_BLENDER_NOT_FOUND` | No binary on PATH / flag / `T3D_BLENDER` | Install Blender 5.2 LTS from blender.org, or pass `--blender /path/to/blender` |
96
+ | `T3D_BLENDER_VERSION` | Version not in the supported list | Use 4.5 LTS or 5.2 LTS, or `--allow-blender-version` (key still records it) |
97
+ | `T3D_BLENDER_FAILED` | Non-zero exit (`--python-exit-code`), timeout, missing output | The last 40 lines of Blender's stderr + the job file path to rerun by hand |
98
+ | `T3D_BLENDER_CACHE_MISS` | Offline/CI and no cached result | The exact command that regenerates it |
99
+
100
+ ## High→low baking vs the one-material rule
101
+
102
+ A Blender normal bake makes a **per-asset** texture; [ADR 008](../decisions/008-surface-materials.md) says every asset shares the trim sheet. Options, in order of preference:
103
+
104
+ | Option | Keeps one material? | Notes |
105
+ |---|---|---|
106
+ | A. Don't bake normals; SDF meshes already take normals from the exact field at every LOD (`sdf.ts`) | Yes | Covers sculpts today; polygon recipes get `weightedNormals` + bevels |
107
+ | B. Bake AO / curvature into **vertex colour / `_WEAR`** on the low mesh (sample the bake at vertices) | Yes | Cheap; ts23d can do this itself (`bakeAo` exists) without Blender |
108
+ | C. Opt-in `surface: 'baked'` with its own texture set for hero assets | No: a second material per asset | Needs an ADR; only worth it for imported organic hero assets |
109
+
110
+ Recommendation: ship A/B in core first; Blender baking only serves C, so it waits for that ADR.
111
+
112
+ ## Feasibility
113
+
114
+ | Item | Feasible? | Effort | Risk |
115
+ |---|---|---|---|
116
+ | `render` (Cycles + HDRI, same camera as `beauty`) | Yes: standard headless pattern | M | Camera/axis conversion (ts23d Z-up +X front → glTF Y-up → Blender Z-up); test against `beauty()` silhouette |
117
+ | `remesh` / `decimate` on imported GLB | Yes | S–M | Vertex colours / UVs lost by voxel remesh; re-colour on the ts23d side (`importGlb` already plans recolouring) |
118
+ | `convert` FBX/OBJ/USD → GLB | Yes | S | Importer quirks per format; GLB stays the only output |
119
+ | `bake` selected-to-active | Yes technically | M–L | Only useful with option C above |
120
+ | CI coverage | Partial | S | Tests mock the spawn (fake `blender` script printing a canned report); a manual `t3d blender doctor` is the real smoke test |
121
+
122
+ ## Next steps for ts23d (prioritised, all planned)
123
+
124
+ 1. **New ADR "Optional external tools"**: Blender runs only via the CLI/subpath, results are cached inputs, never in the gate; licence note on shipped Python. S.
125
+ 2. **Shared content-hash cache helper** for `/neural` and `/blender` (key, lookup, miss error). S–M.
126
+ 3. **`t3d blender doctor` + discovery + version pin** with the four error codes. S.
127
+ 4. **`t3d blender render`** (Cycles, CC0 HDRI path, `beauty()`'s camera), output `cycles.png` beside `hero.png`, never gated. M.
128
+ 5. **`remesh` / `decimate` / `convert`** feeding the planned `importGlb`. M.
129
+ 6. **Baking** only after an ADR accepts a per-asset `baked` surface. L.
130
+
131
+ ## Sources
132
+
133
+ - [Blender command-line arguments](https://docs.blender.org/manual/en/latest/advanced/command_line/arguments.html) · [LTS](https://www.blender.org/download/lts/) · [Licence](https://www.blender.org/about/license/)
134
+ - [bpy.types.BakeSettings](https://docs.blender.org/api/latest/bpy.types.BakeSettings.html) · [Remesh modifier](https://docs.blender.org/manual/en/latest/modeling/modifiers/generate/remesh.html)
135
+ - [glTF-Blender-IO](https://github.com/KhronosGroup/glTF-Blender-IO) · [headless GLB export example](https://til.jakelazaroff.com/blender/export-a-blender-file-to-glb-from-the-command-line/)
136
+ - Cycles reproducibility reports: [blender#101726](https://projects.blender.org/blender/blender/issues/101726), [T72141](https://developer.blender.org/T72141) (not opened directly; status unverified)
@@ -0,0 +1,89 @@
1
+ # 13 — The professional game-asset pipeline, free CC0 libraries, and where ts23d sits
2
+
3
+ **The studio pipeline is blockout → high poly → low poly → UV → bake → texture → present. ts23d collapses most of it into one compile: the recipe is the blockout and the low poly, the trim sheet replaces per-asset UV + bake + texture, and `shot` / `hero` / `review` are the presentation. What it borrows from the pipeline is the taste: bevels, roughness variation, consistent texel density, readable silhouettes.** This page records the pipeline as practitioners describe it, the free CC0 libraries (with exact licence wording) that can feed ts23d as inputs, and the gaps. Research as of 2026-11-07; "Proposed" items are **planned, not shipped**. Neutral positioning: artists, DCC tools and AI generators all produce good assets; ts23d is the free, code-based option.
4
+
5
+ ## The pipeline as practitioners describe it
6
+
7
+ | Stage | What happens | Typical tools | ts23d equivalent | Where |
8
+ |---|---|---|---|---|
9
+ | Reference | Photos, drawings, specs, front/side sheets | PureRef, browser | `recipes/<name>/README.md` written first; `real` + `sources` + `measures` | CLAUDE.md recipe loop |
10
+ | **Blockout** | Primitive masses at real scale, read the silhouette in-engine | Any DCC | Step 2 "Masses", `proportions` ±5% / `measures` ±2% gates | [05](05-agent-loop.md) |
11
+ | High poly | Sub-D or sculpt with every bevel and detail, for baking only | ZBrush, Blender, Maya | Not a separate artefact: LOD0 *is* the dense mesh; SDF normals come from the exact field | `src/primitives/sdf.ts` |
12
+ | Low poly | Game mesh around the high poly; hard edges at UV splits | DCC, retopo tools | Same recipe at LOD1/LOD2, gated by `ctx.lod`, never decimated | [02](02-dsl.md), CLAUDE.md |
13
+ | UV | Unwrap, pack, consistent texel density | RizomUV, Blender | Primitives author UVs onto the trim sheet at true metres | `src/material/trim-sheet.ts` |
14
+ | **Bake** | High → low: normal, AO, curvature, ID; cage controls the ray search so hard edges shade soft | Marmoset Toolbag, Substance, xNormal, Blender | Vertex AO (`bakeAo`), weighted normals, field-gradient normals; no per-asset maps by design | `src/passes/bake-ao.ts`, `weighted-normals.ts` |
15
+ | Texture | Smart materials on baked maps: edge wear from curvature, dirt from AO, roughness breakup | Substance Painter, ArmorPaint | Procedural trim sheet + vertex colour + `grime` / `livery`; edge wear planned ([11](11-materials-authoring.md)) | `src/material/paint/`, `src/passes/` |
16
+ | Present | Marmoset turntable, ArtStation renders, in-engine shots | Marmoset, engine | `t3d shot` (six views), `t3d hero` (beauty), `t3d review` (AI review pack) | `src/render/` |
17
+
18
+ Order varies: some artists build the high poly first and the low poly around it, others go blockout → mid → high → low; hard-surface flows often add an in-between mesh ([Polycount thread](https://polycount.com/discussion/comment/1771950), practitioner opinions). The bake cage "allows the baker to search for high-poly detail as if edges were soft, while the actual low-poly retains hard edges" (Gnomon course description, [Creating Props for Games](https://bit.thegnomonworkshop.com/workshops/creating-props-for-games-vol-1)).
19
+
20
+ ## The two realism keys
21
+
22
+ **Bevels.** Real objects have no razor edges; a 1–5 mm chamfer catches a highlight and reads as "manufactured" even at a distance. Studios fake bevels in textures to save triangles: Insomniac's "Ultimate Trim" (GDC 2015, Sunset Overdrive) is a sheet of horizontal trim strips with 45° normal-map bevels, standardised so materials swap without re-UVing ([GDC Vault](https://gdcvault.com/play/1022323/The-Ultimate-Trim-Texturing-Techniques), [CG Channel summary](https://www.cgchannel.com/2019/07/download-justen-lazarros-free-ultimate-trim-texturing-tools)). The Last of Us Part I used baked-bevel trims to save texture memory ([80.lv](https://80.lv/articles/the-last-of-us-remake-s-trim-pipeline-in-substance-3d-explained)).
23
+ ts23d today: geometric bevels via `box({ bevel })` + `weightedNormals` (the mid-poly look; `src/primitives/bevel-box.ts`).
24
+
25
+ **Roughness variation.** A flat roughness value reads as plastic CG; smudges, wear and dirt varying the specular response read as a material. ts23d today: every painter writes a `rough` layer with mottling; there is no gate on it. Proposed: `roughnessVariation` + a flat-roughness test ([11](11-materials-authoring.md)).
26
+
27
+ ### Trim sheets
28
+
29
+ ts23d's sheet *is* a trim sheet in the studio sense ([ADR 003](../decisions/003-shared-trim-sheet.md) → [ADR 008](../decisions/008-surface-materials.md)): 28 strip rows tiling along U, then decal and foliage cells, one 2048² set (`src/material/trim-sheet.ts`). What studio trims have that ts23d's lacks:
30
+
31
+ | Studio trim feature | ts23d | Proposed |
32
+ |---|---|---|
33
+ | Bevel/edge strips with baked 45° normal + worn edge | No; bevels are geometry | A `worn_edge` strip; map `box({ bevel })` chamfers onto it (S–M) |
34
+ | Strip heights doubling (small to large trims) | Equal 64 px rows | Only if a recipe needs fine trims; layout change = ADR |
35
+ | Decals over trims (bolts, vents, grates) as floating quads | Cells, mapped by `cell()` | `decal()` primitive: a quad offset 1–2 mm along the normal, cutout surface (S) |
36
+
37
+ ### Texel density
38
+
39
+ Examples from vendor docs: 10.24 px/cm first person, 5.12 px/cm third person, 2.56 px/cm or less top-down; closer cameras need more ([Maxon](https://help.maxon.net/c4d/en-us/Content/html/Texel_Density.html)). ts23d's strips give 1024 px/m along U at 2048² (a strip spans 2 m), constant across assets by construction; along V it depends on `scaleMetres`. The planned texel-density gate (roadmap M2) should measure both axes.
40
+
41
+ ## Free CC0 libraries
42
+
43
+ All five below state CC0 or public-domain terms. CC0 means no attribution required and commercial use and redistribution allowed; it also means **no warranty** (no indemnity for trademarks, likeness or third-party IP depicted). Verified on each site 2026-11-07 unless noted.
44
+
45
+ | Library | What | Licence wording | Exceptions | Fits ts23d as |
46
+ |---|---|---|---|---|
47
+ | [Poly Haven](https://polyhaven.com/license) | HDRIs, PBR textures, models | "Our assets are all licensed as CC0"; "You can redistribute them"; "You do not need to give credit" | Site content other than the assets (logos, renders) is protected | **HDRIs** for the Blender bridge's Cycles renders ([12](12-blender-bridge.md)) and a future image-based-light option in `beauty()`; texture calibration |
48
+ | [ambientCG](https://docs.ambientcg.com/license/) | PBR materials, HDRIs, models | "provided under the Creative Commons CC0 1.0 Universal License"; "You don't need to give credit" | None stated | Calibrating `MaterialDef` roughness/albedo; CC0 **height** maps as strip sources ([11](11-materials-authoring.md)) |
49
+ | [cgbookcase](https://www.cgbookcase.com) | PBR textures (hundreds of sets) | CC0 per the author's announcements ([BlenderNation 2018](https://www.blendernation.com/2018/09/14/250-free-cc0-high-quality-pbr-textures-from-cgbookcase-com/), [CG Channel 2019](https://www.cgchannel.com/2019/03/download-380-free-pbr-texture-sets-from-cgbookcase-com/)) | **Licence page not reachable when checked (404)**; verify on the site before vendoring | Same as ambientCG |
50
+ | [Kenney](https://kenney.nl/support) | Low-poly game kits, UI, audio | "all game assets on the asset pages are public domain licensed (CC0)"; "Attribution is not required" | "Do not use our logo" | `importGlb` (planned, M5) inputs; scale/style references for stylised recipes |
51
+ | [Quaternius](https://quaternius.com/packs/fantasypropsmegakit.html) | Low-poly models, some rigged/animated, glTF included | CC0, "free to use in personal, educational and commercial projects" (pack pages; [OpenGameArt mirrors](https://opengameart.org/content/low-poly-rpg-pack)) | None stated | `importGlb` inputs: rigged characters for what recipes do weakly ([07](07-what-fits.md)) |
52
+
53
+ Other licences to know, **not** CC0: Sketchfab downloads carry per-model CC-BY / CC-BY-NC etc. (check each); Fab/Quixel Megascans have their own EULA; Substance Source is subscription content. None of these should be vendored into the repo.
54
+
55
+ ### How CC0 assets enter ts23d without breaking determinism
56
+
57
+ | Rule | Why |
58
+ |---|---|
59
+ | **Vendor, never fetch at build time.** Commit the file (or publish it with the package) with its sha256 | A URL can change or vanish; bytes in git cannot |
60
+ | **Record provenance in `sources`** (URL, licence, date) and in GLB XMP (`KHR_xmp_json_ld`, shipped) | CC0 needs no credit, but provenance is how a reviewer checks it |
61
+ | **Images are inputs, not the only path** ([04](04-materials.md#image-sources)) | The sheet must still paint with no image present |
62
+ | **Imported meshes go through `importGlb` → fit → recolour onto the sheet** | Keeps one material and real units |
63
+ | A `licence` field per vendored input, checked by a script in `bun run verify` (proposed) | "A rule without a script is a suggestion" (CLAUDE.md) |
64
+
65
+ ## Where ts23d differs on purpose
66
+
67
+ | Studio practice | ts23d choice | Reason |
68
+ |---|---|---|
69
+ | Per-asset baked normal/AO/curvature maps | One shared sheet; wear in vertex data | One draw call, swap palette by recipe edit ([ADR 008](../decisions/008-surface-materials.md)) |
70
+ | Decimate the high poly for LODs | LOD-gated emission from one source | Cleaner silhouettes, named parts survive every LOD |
71
+ | Hand UVs | UVs by construction at true metres | Agents don't unwrap; consistent density for free |
72
+ | Quad retopology | Triangles, vertex-cache ordered (`optimize`) | Engines draw triangles; ts23d doesn't subdivide |
73
+
74
+ ## Next steps for ts23d (prioritised)
75
+
76
+ 1. **Texel-density gate** (px/m along U and V per part; V-stretch warning). S–M. Already on roadmap M2.
77
+ 2. **`worn_edge` strip + bevel-to-strip mapping** in `box({ bevel })`: the Ultimate-Trim idea with real geometry. S–M.
78
+ 3. **`decal()` primitive**: offset quad on a cell, cutout surface, for bolts/vents/grates. S.
79
+ 4. **Vendored-input convention + `licence` check** in `bun run verify` (sha256, URL, licence, date per file under `assets/vendor/` or similar). S.
80
+ 5. **Roughness-variation knob + flat-roughness test** ([11](11-materials-authoring.md)). S.
81
+ 6. **CC0 HDRI support**: Poly Haven `.hdr` loader for an optional IBL term in `beauty()` (zero-dep RGBE decode is ~100 lines) and for the Blender bridge. M.
82
+ 7. **Document the calibration workflow** in `.claude/skills/author-recipe/SKILL.md`: sample a CC0 texture's albedo/roughness, put the numbers in `MaterialDef`, cite the URL. S.
83
+
84
+ ## Sources
85
+
86
+ - Pipeline: [Polycount threads](https://polycount.com/discussion/comment/1771950), [Gnomon: Creating Props for Games](https://bit.thegnomonworkshop.com/workshops/creating-props-for-games-vol-1) (course description) — practitioner accounts, not a standard
87
+ - Trims: [GDC Vault, "The Ultimate Trim: Texturing Techniques of Sunset Overdrive"](https://gdcvault.com/play/1022323/The-Ultimate-Trim-Texturing-Techniques) (Insomniac, 2015), [CG Channel](https://www.cgchannel.com/2019/07/download-justen-lazarros-free-ultimate-trim-texturing-tools), [80.lv on The Last of Us Part I trims](https://80.lv/articles/the-last-of-us-remake-s-trim-pipeline-in-substance-3d-explained)
88
+ - Texel density: [Maxon Cinema 4D docs](https://help.maxon.net/c4d/en-us/Content/html/Texel_Density.html)
89
+ - Licences: [Poly Haven](https://polyhaven.com/license), [ambientCG](https://docs.ambientcg.com/license/), [Kenney](https://kenney.nl/support), [Quaternius](https://quaternius.com/packs/fantasypropsmegakit.html), cgbookcase via [BlenderNation](https://www.blendernation.com/2018/09/14/250-free-cc0-high-quality-pbr-textures-from-cgbookcase-com/) and [CG Channel](https://www.cgchannel.com/2019/03/download-380-free-pbr-texture-sets-from-cgbookcase-com/)
@@ -66,6 +66,10 @@ Materials:
66
66
  - [x] Sheet in the GLB: embedded or shared external PNGs (`--sheet`, `--sheet-size`)
67
67
  - [ ] KTX2 profile; fix mip bleed between strips; per-face V mapping on `box`
68
68
  - [ ] Image-stamped cells (decals from any `ImageSource`)
69
+ - [ ] Painter curvature + `edgeWear` mask (`params.edgeWear`, `PaintKit.curvature`) ([11](../idea/11-materials-authoring.md))
70
+ - [ ] `edgeWear(mesh)` pass (dihedral / SDF-Laplacian convexity → vertex colour; `_WEAR` attribute for shader sources)
71
+ - [ ] `roughnessVariation` on `MaterialDef` + flat-roughness test
72
+ - [ ] `worn_edge` strip mapped by `box({ bevel })` chamfers; `decal()` offset-quad primitive
69
73
 
70
74
  Optimisation and GLB production (gap ids from [glb-production.md](../architecture/glb-production.md)):
71
75
  - [x] `optimize(mesh)`: vertex weld + vertex-cache reorder, reports ACMR (default in export)
@@ -110,6 +114,12 @@ Exit bar: a 4-4-0 with tender matching [`docs/reference/loco-photo-match.png`](h
110
114
  - [x] `sdf`: surface nets or marching cubes over a signed-distance field; `sculpt` edit lists (Dreams-style)
111
115
  - [ ] `creature`: SDF flesh over biped / quadruped / bird skeletons, bones as named parts
112
116
  - [ ] Recipes: stylised critters, rocks
117
+ - [ ] `roundCone` sculpt shape + `skin()` (draw-skin capsule chains) ([10](../idea/10-sculpting.md))
118
+ - [ ] Sculpt `form` tags (primary/secondary/tertiary) as an LOD filter + primary-only review view
119
+ - [ ] Group `pose` (pivot + rotate) and `warp` (twist/bend/taper) with declared Lipschitz factors
120
+ - [ ] `ffd()` cage deform pass (Sederberg–Parry)
121
+ - [ ] Sculpt strokes along a path: `crease`, `clay`, `inflate` (then `pinch`, `flatten`); `revolve` SDF shape
122
+ - [ ] `t3d shot --ref front=… side=…` overlays + per-view silhouette IoU
113
123
 
114
124
  ## M5: Neural adapters and import
115
125
 
@@ -118,6 +128,8 @@ Exit bar: a 4-4-0 with tender matching [`docs/reference/loco-photo-match.png`](h
118
128
  - [ ] `importGlb`: any GLB as a part, fitted and recoloured onto the trim sheet
119
129
  - [ ] `kubeezImages`: kubeez.com image source for references, decals, texture sources
120
130
  - [ ] Recipe: a code-built cart drawn by a generated horse
131
+ - [ ] Optional Blender bridge, never in the gate ([12](../idea/12-blender-bridge.md)): ADR, shared content-hash cache with /neural, `t3d blender doctor | render | remesh | convert`
132
+ - [ ] Vendored-input convention (sha256 + URL + licence) and a `licence` check in `bun run verify`; CC0 HDRI loader for `beauty()` IBL
121
133
 
122
134
  ## M6: Reverse direction and reach
123
135
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@developerz.ai/ts23d",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Free, deterministic 3D asset generation in TypeScript — a DSL your coding agent writes, compiled to game-ready glTF.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -75,6 +75,7 @@
75
75
  "filesize": "bun run scripts/filesize.ts",
76
76
  "recipes": "bun run scripts/recipes.ts",
77
77
  "shots": "bun run scripts/shots.ts",
78
+ "textures": "bun run scripts/textures.ts",
78
79
  "release": "bun run scripts/release.ts",
79
80
  "t3d": "bun run src/cli/bin.ts",
80
81
  "site:dev": "bun run --filter @ts23d/site dev",
@@ -44,3 +44,10 @@ children `*_wheel_l/_r`) · `door_l`, `door_r` (slide one way toward −X, `oneW
44
44
  - Truck centres are not published in the sources found: 9.37 m is scaled from the photo.
45
45
  - Lettering is painted on the boards and post faces; the diagonal braces cross over it (a stroke
46
46
  painted on a brace face jumps sideways in parallax and garbles the word at 3/4 views).
47
+
48
+ ## Materials
49
+ Painted plank sheathing and door (`wood_painted`: car colour over grey wood, worn at board edges and
50
+ ends), silver-grey running board and saddles (`wood_weathered`), painted steel frame, sills and roof
51
+ (`steel_painted`), sand-cast trucks, wheels and bolsters (`iron_cast`), and rusty hardware: grab
52
+ irons, couplers, brake gear (`iron_rust`). The numbers and sources are in
53
+ `docs/architecture/materials.md`.