@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,7 +1,7 @@
1
1
  // Recipe validation: every bad input throws T3D_RECIPE_INVALID with the fix, before any painting.
2
2
 
3
3
  import { Ts23dError } from '../../core/index.ts';
4
- import { CELLS, STRIPS } from '../trim-sheet.ts';
4
+ import { CELLS, STRIPS, stripRow } from '../trim-sheet.ts';
5
5
  import type { CellImage, SheetRecipe, StripKind, StripRecipe } from './types.ts';
6
6
 
7
7
  const KINDS: readonly StripKind[] = [
@@ -13,6 +13,9 @@ const KINDS: readonly StripKind[] = [
13
13
  'slate',
14
14
  'concrete',
15
15
  'glass',
16
+ 'metal',
17
+ 'weave',
18
+ 'leather',
16
19
  ];
17
20
  const SIZES = [512, 1024, 2048];
18
21
 
@@ -33,6 +36,16 @@ export function validateStrip(where: string, r: StripRecipe): void {
33
36
  if (!unit(r.roughness)) fail(`${where}.roughness is ${r.roughness}`, 'roughness is 0..1');
34
37
  if (r.metalness !== undefined && !unit(r.metalness))
35
38
  fail(`${where}.metalness is ${r.metalness}`, 'metalness is 0..1');
39
+ for (const key of ['albedo', 'albedo2'] as const) {
40
+ const a = r[key];
41
+ if (a !== undefined && !(a.length === 3 && a.every(unit)))
42
+ fail(
43
+ `${where}.${key} is [${a}]`,
44
+ `${key} is linear RGB, three numbers 0..1 (use hex('#rrggbb'))`,
45
+ );
46
+ }
47
+ if (r.paint !== undefined && typeof r.paint !== 'function')
48
+ fail(`${where}.paint is not a function`, 'paint is (surface, kit) => void');
36
49
  for (const [k, v] of Object.entries(r.params ?? {}))
37
50
  if (!Number.isFinite(v)) fail(`${where}.params.${k} is ${v}`, 'params are finite numbers');
38
51
  }
@@ -42,8 +55,11 @@ export function validateSheet(recipe: SheetRecipe): void {
42
55
  if (recipe.size !== undefined && !SIZES.includes(recipe.size))
43
56
  fail(`size ${recipe.size}`, 'size is 512, 1024 or 2048');
44
57
  for (const [id, r] of Object.entries(recipe.strips)) {
45
- if (!(STRIPS as readonly string[]).includes(id))
46
- fail(`strip '${id}' is not in the layout`, `strips are ${STRIPS.join(', ')}`);
58
+ if (stripRow(id) < 0)
59
+ fail(
60
+ `strip '${id}' is not in the layout`,
61
+ `strips are ${STRIPS.join(', ')} or a defineMaterial() id`,
62
+ );
47
63
  if (r) validateStrip(`strips.${id}`, r);
48
64
  }
49
65
  for (const [id, e] of Object.entries(recipe.cells ?? {})) {
@@ -15,6 +15,9 @@ export function hex(value: string): Vec3 {
15
15
  return [lin((n >> 16) & 255), lin((n >> 8) & 255), lin(n & 255)];
16
16
  }
17
17
 
18
+ /** Vertex colour for material strips that carry their own colour (oak, brass, rubber…). */
19
+ export const NATURAL: Vec3 = [1, 1, 1];
20
+
18
21
  export function definePalette(entries: Readonly<Record<string, string>>): Palette {
19
22
  return Object.fromEntries(Object.entries(entries).map(([k, v]) => [k, hex(v)]));
20
23
  }
@@ -0,0 +1,18 @@
1
+ // The custom-strip slot table: ids registered by `defineMaterial`, in registration order. A leaf
2
+ // module (no imports) so the layout (trim-sheet.ts) and the registry (registry.ts) can both read it.
3
+
4
+ const custom: string[] = [];
5
+
6
+ /** Slot index among the custom strips, or −1. */
7
+ export const customIndex = (id: string): number => custom.indexOf(id);
8
+
9
+ export const customIds = (): readonly string[] => custom;
10
+
11
+ export function pushCustom(id: string): number {
12
+ custom.push(id);
13
+ return custom.length - 1;
14
+ }
15
+
16
+ export function clearCustom(): void {
17
+ custom.length = 0;
18
+ }
@@ -0,0 +1,86 @@
1
+ // Image-backed strips: a CC0 PBR texture set (Poly Haven, ambientCG or a local file) declared as
2
+ // data — URL + sha256 per map, licence, author page, real-world size. Compile and paint stay
3
+ // offline: bytes come only from a content-addressed cache the caller provides (`provideTextures`,
4
+ // filled by `bun run textures`); a missing map throws T3D_TEXTURE_MISSING with that command.
5
+
6
+ import { Ts23dError } from '../core/index.ts';
7
+
8
+ export interface TextureFile {
9
+ /** Download URL (https) or, for `source: 'file'`, a path relative to the working directory. */
10
+ readonly url: string;
11
+ /** Lower-case hex sha256 of the file's bytes: the cache key and the integrity check. */
12
+ readonly sha256: string;
13
+ }
14
+
15
+ export interface TextureSource {
16
+ readonly source: 'polyhaven' | 'ambientcg' | 'file';
17
+ /** The set's id at the source, e.g. 'wood_planks_grey'. */
18
+ readonly id: string;
19
+ /** Only public-domain sets are accepted, so a generated asset never inherits a licence duty. */
20
+ readonly license: 'CC0-1.0';
21
+ /** Human page crediting the set (goes into the GLB's XMP credits). */
22
+ readonly page: string;
23
+ readonly author?: string;
24
+ /** Real-world size the image covers, metres (U × V). Poly Haven lists it as `dimensions`. */
25
+ readonly metres: readonly [number, number];
26
+ readonly maps: {
27
+ /** sRGB base colour. */
28
+ readonly albedo: TextureFile;
29
+ /** Tangent-space normal, OpenGL convention (+Y up in the image; Poly Haven `nor_gl`). */
30
+ readonly normal?: TextureFile;
31
+ /** Packed AO / roughness / metalness (Poly Haven `arm`, glTF ORM order). */
32
+ readonly arm?: TextureFile;
33
+ };
34
+ }
35
+
36
+ /** PNG bytes by sha256. */
37
+ export type TextureCache = ReadonlyMap<string, Uint8Array>;
38
+
39
+ const SHA = /^[0-9a-f]{64}$/;
40
+ const provided = new Map<string, Uint8Array>();
41
+
42
+ /** Hand verified texture bytes to the painter (e.g. from `loadTextureCache()` in scripts). */
43
+ export function provideTextures(cache: TextureCache): void {
44
+ for (const [k, v] of cache) provided.set(k, v);
45
+ }
46
+
47
+ export function textureBytes(file: TextureFile, where: string): Uint8Array {
48
+ const hit = provided.get(file.sha256);
49
+ if (hit) return hit;
50
+ throw new Ts23dError('T3D_TEXTURE_MISSING', {
51
+ cause: `${where}: texture ${file.url} (sha256 ${file.sha256.slice(0, 12)}…) is not in the cache`,
52
+ fix: 'run `bun run textures <module defining the material>` once (downloads + verifies into node_modules/.cache/ts23d/textures), then provideTextures(await loadTextureCache()) before painting',
53
+ });
54
+ }
55
+
56
+ export function validateTexture(id: string, t: TextureSource): void {
57
+ const fail = (cause: string, fix: string): never => {
58
+ throw new Ts23dError('T3D_MATERIAL_INVALID', { cause: `material '${id}': ${cause}`, fix });
59
+ };
60
+ if (!['polyhaven', 'ambientcg', 'file'].includes(t.source))
61
+ fail(`texture.source '${t.source}'`, "use 'polyhaven', 'ambientcg' or 'file'");
62
+ if (t.license !== 'CC0-1.0')
63
+ fail(
64
+ `texture.license '${t.license}'`,
65
+ 'only CC0-1.0 sets are accepted (Poly Haven and ambientCG are CC0)',
66
+ );
67
+ if (!(t.metres.length === 2 && t.metres.every((m) => Number.isFinite(m) && m > 0)))
68
+ fail(`texture.metres [${t.metres}]`, 'give the real size the image covers, e.g. [1.5, 1.5]');
69
+ for (const [map, f] of Object.entries(t.maps)) {
70
+ if (!f) continue;
71
+ if (!SHA.test(f.sha256))
72
+ fail(
73
+ `texture.maps.${map}.sha256 '${f.sha256}'`,
74
+ 'lower-case hex sha256 of the file (sha256sum)',
75
+ );
76
+ if (!/\.png$/i.test(f.url))
77
+ fail(
78
+ `texture.maps.${map}.url '${f.url}'`,
79
+ 'use the PNG download (8- or 16-bit); JPEG is not decoded',
80
+ );
81
+ }
82
+ }
83
+
84
+ /** One credit line for the GLB's XMP and the docs. */
85
+ export const creditOf = (t: TextureSource): string =>
86
+ `${t.id}${t.author ? ` by ${t.author}` : ''} (${t.source}, ${t.license}) ${t.page}`;
@@ -1,9 +1,15 @@
1
- // The shared trim sheet: ONE 1024² atlas (albedo + normal + ORM) every asset maps into, so every
1
+ // The shared trim sheet: ONE 2048² atlas (albedo + normal + ORM) every asset maps into, so every
2
2
  // asset is one material and one draw call. Strips tile along U; cells are fixed decals.
3
3
  // Layout is data here; `paintTrimSheet` (generator.ts) paints it. V grows with world up on
4
- // box/planar faces. Foliage cells (cutout leaf cards, alpha = leaf mask) sit in their own rows.
4
+ // box/planar faces. V 0–0.875 holds 28 strip rows (64 px each at 2048²): the 8 original strips,
5
+ // 16 physically distinct materials (woods, metals, rubber, canvas, leather, plastic) and 4 slots
6
+ // for project materials (`defineMaterial`). V 0.875–1 holds 128 px cells: decals, then foliage.
5
7
 
6
- export type StripId =
8
+ import { Ts23dError } from '../core/index.ts';
9
+ import { customIndex } from './slots.ts';
10
+
11
+ /** The original eight strips: greyscale, tinted by vertex colour. */
12
+ export type ClassicStripId =
7
13
  | 'plain'
8
14
  | 'brick'
9
15
  | 'stone'
@@ -12,6 +18,28 @@ export type StripId =
12
18
  | 'wood'
13
19
  | 'glass'
14
20
  | 'concrete';
21
+ /** Material strips with measured colour: use a near-white vertex colour (`NATURAL`) for truth. */
22
+ export type MaterialStripId =
23
+ | 'oak'
24
+ | 'pine'
25
+ | 'wood_weathered'
26
+ | 'wood_painted'
27
+ | 'iron_rust'
28
+ | 'iron_cast'
29
+ | 'steel_painted'
30
+ | 'steel_galvanised'
31
+ | 'brass'
32
+ | 'copper'
33
+ | 'aluminium'
34
+ | 'chrome'
35
+ | 'rubber'
36
+ | 'canvas'
37
+ | 'leather'
38
+ | 'plastic';
39
+ export type BuiltinStripId = ClassicStripId | MaterialStripId;
40
+ /** A project material's id, as returned by `defineMaterial` (a branded string). */
41
+ export type CustomStripId = string & { readonly __ts23dMaterial: true };
42
+ export type StripId = BuiltinStripId | CustomStripId;
15
43
  export type DecalCellId = 'window_sash' | 'door_panel' | 'clock' | 'smokebox' | 'sign' | 'porthole';
16
44
  /** Cutout leaf cards: the anchor (twig base) is the cell's bottom-centre, growth toward +V. */
17
45
  export type FoliageCellId =
@@ -37,8 +65,10 @@ export interface UvRect {
37
65
  readonly metres: number;
38
66
  }
39
67
 
40
- const STRIP_H = 1 / 16;
41
- const strips: readonly StripId[] = [
68
+ /** Strip rows in the atlas: built-ins first, then `CUSTOM_STRIP_SLOTS` project materials. */
69
+ export const STRIP_ROWS = 28;
70
+ const STRIP_H = 1 / 32;
71
+ const classic: readonly ClassicStripId[] = [
42
72
  'plain',
43
73
  'brick',
44
74
  'stone',
@@ -48,6 +78,26 @@ const strips: readonly StripId[] = [
48
78
  'glass',
49
79
  'concrete',
50
80
  ];
81
+ const material: readonly MaterialStripId[] = [
82
+ 'oak',
83
+ 'pine',
84
+ 'wood_weathered',
85
+ 'wood_painted',
86
+ 'iron_rust',
87
+ 'iron_cast',
88
+ 'steel_painted',
89
+ 'steel_galvanised',
90
+ 'brass',
91
+ 'copper',
92
+ 'aluminium',
93
+ 'chrome',
94
+ 'rubber',
95
+ 'canvas',
96
+ 'leather',
97
+ 'plastic',
98
+ ];
99
+ const strips: readonly BuiltinStripId[] = [...classic, ...material];
100
+ export const CUSTOM_STRIP_SLOTS = STRIP_ROWS - strips.length;
51
101
  const decals: readonly DecalCellId[] = [
52
102
  'window_sash',
53
103
  'door_panel',
@@ -70,30 +120,46 @@ const foliage: readonly FoliageCellId[] = [
70
120
  'foliage_needles',
71
121
  ];
72
122
  const cells: readonly CellId[] = [...decals, ...foliage];
73
- /** Decals fill row 0 of the cell region (V 0.5); foliage starts on row 1 (V 0.625). */
123
+ /** Cells are 1/16 of the sheet, 16 per row from V 0.875: decals at 0–5, foliage from slot 8. */
124
+ const CELL = 1 / 16;
125
+ const CELL_V0 = STRIP_ROWS * STRIP_H;
74
126
  const FOLIAGE_SLOT = 8;
75
127
  const slotOf = (id: CellId): number => {
76
128
  const d = decals.indexOf(id as DecalCellId);
77
129
  return d >= 0 ? d : FOLIAGE_SLOT + foliage.indexOf(id as FoliageCellId);
78
130
  };
79
131
 
80
- export const TRIM_SHEET_SIZE = 1024;
132
+ export const TRIM_SHEET_SIZE = 2048;
133
+
134
+ /** Atlas row of a strip id, or −1 when it is neither built in nor defined. */
135
+ export function stripRow(id: string): number {
136
+ const b = strips.indexOf(id as BuiltinStripId);
137
+ if (b >= 0) return b;
138
+ const c = customIndex(id);
139
+ return c >= 0 ? strips.length + c : -1;
140
+ }
81
141
 
82
142
  export function stripRect(id: StripId): UvRect {
83
- const i = strips.indexOf(id);
143
+ const i = stripRow(id);
144
+ if (i < 0)
145
+ throw new Ts23dError('T3D_MATERIAL_UNKNOWN', {
146
+ cause: `strip '${id}' is neither built in nor defined`,
147
+ fix: `use a built-in strip (${strips.join(', ')}) or register it first with defineMaterial({ id: '${id}', ... })`,
148
+ });
84
149
  return { u0: 0, v0: i * STRIP_H, u1: 1, v1: (i + 1) * STRIP_H, metres: 2 };
85
150
  }
86
151
 
87
- /** Cells sit in the lower half (V ≥ 0.5), 8 per row: decals on row 0, foliage from row 1. */
88
152
  export function cellRect(id: CellId): UvRect {
89
153
  const i = slotOf(id);
90
- const size = 1 / 8;
91
- const u0 = (i % 8) * size;
92
- const v0 = 0.5 + Math.floor(i / 8) * size;
93
- return { u0, v0, u1: u0 + size, v1: v0 + size, metres: 1 };
154
+ const u0 = (i % 16) * CELL;
155
+ const v0 = CELL_V0 + Math.floor(i / 16) * CELL;
156
+ return { u0, v0, u1: u0 + CELL, v1: v0 + CELL, metres: 1 };
94
157
  }
95
158
 
159
+ /** Every built-in strip, classic first. */
96
160
  export const STRIPS = strips;
161
+ export const CLASSIC_STRIPS = classic;
162
+ export const MATERIAL_STRIPS = material;
97
163
  export const CELLS = cells;
98
164
  export const DECAL_CELLS = decals;
99
165
  export const FOLIAGE_CELLS = foliage;
@@ -29,7 +29,7 @@ export interface BeautyOptions {
29
29
  readonly fovDeg?: number;
30
30
  /** Samples per pixel edge (1–4). Default 2 (4 samples per pixel). */
31
31
  readonly supersample?: number;
32
- /** The painted trim sheet; default paints `DEFAULT_SHEET` at 512² once per process. */
32
+ /** The painted trim sheet; default paints `DEFAULT_SHEET` at 1024² once per process. */
33
33
  readonly sheet?: PaintedSheet;
34
34
  /** Zoom into the default framing: window centre (0–1, 0–1) of the full shot and a zoom ≥ 1. */
35
35
  readonly window?: { readonly x: number; readonly y: number; readonly zoom: number };
@@ -51,7 +51,7 @@ export interface BeautyRender {
51
51
  let defaultSheet: PaintedSheet | undefined;
52
52
  const sheetOf = (o: BeautyOptions): PaintedSheet => {
53
53
  if (o.sheet) return o.sheet;
54
- defaultSheet ??= paintTrimSheet({ ...DEFAULT_SHEET, size: 512 });
54
+ defaultSheet ??= paintTrimSheet({ ...DEFAULT_SHEET, size: 1024 });
55
55
  return defaultSheet;
56
56
  };
57
57
 
@@ -48,6 +48,32 @@ export function studioRig(cameraAzDeg: number): Rig {
48
48
  };
49
49
  }
50
50
 
51
+ const HORIZON: V3 = [0.82, 0.8, 0.76];
52
+ const ZENITH: V3 = [0.3, 0.34, 0.44];
53
+ const FLOOR: V3 = [0.3, 0.28, 0.26];
54
+ const BAND = 0.12;
55
+
56
+ /**
57
+ * The studio environment a reflection sees, channel `k`, along a reflection vector with up
58
+ * component `rz`: a bright cyclorama horizon fading to a cooler zenith, a dark band where the
59
+ * floor meets the wall, the lit floor below. Roughness pre-blurs it toward its mean, so polished
60
+ * metal shows the horizon line and rough metal a soft gradient — what makes metal read as metal.
61
+ */
62
+ export function envRadiance(rz: number, rough: number, k: number): number {
63
+ const h = HORIZON[k] ?? 0;
64
+ const sharp =
65
+ rz >= 0
66
+ ? h + ((ZENITH[k] ?? 0) - h) * Math.sqrt(rz)
67
+ : rz > -0.3
68
+ ? h * BAND + ((FLOOR[k] ?? 0) - h * BAND) * Math.min(1, -rz / 0.3) ** 0.5
69
+ : (FLOOR[k] ?? 0);
70
+ const blurred = 0.5 * h + 0.25 * (ZENITH[k] ?? 0) + 0.25 * (FLOOR[k] ?? 0);
71
+ // Above the horizon the blur keeps a little of the up/down split so rough metal still shades.
72
+ const tilt = 1 + 0.25 * Math.max(-1, Math.min(1, rz * 2));
73
+ const t = Math.min(1, rough * 1.6);
74
+ return sharp + (blurred * tilt - sharp) * t;
75
+ }
76
+
51
77
  /** Poisson-ish disc of 12 taps (texel offsets), radius ~1.6, rotated four ways for dithering. */
52
78
  const DISC: readonly (readonly [number, number])[] = [
53
79
  [-0.94, -0.4],
@@ -1,11 +1,12 @@
1
1
  // Per-pixel shading of the visibility buffer: reconstruct position, normal, UV and vertex colour
2
2
  // from (triangle, barycentrics), perturb by the trim sheet's normal map, then light it — key sun
3
3
  // with a PCF shadow, sky/bounce hemisphere × multi-direction AO × the sheet's baked occlusion,
4
- // and a roughness-driven specular (normalised Blinn–Phong + Schlick Fresnel).
4
+ // a roughness-driven sun specular (normalised Blinn–Phong + Schlick Fresnel) and a studio
5
+ // environment reflection (envRadiance) per texel from the sheet's ORM, so metal reads as metal.
5
6
 
6
7
  import type { PerspectiveCamera } from './camera.ts';
7
8
  import { type DepthMap, openness, visibility } from './depth-map.ts';
8
- import type { Rig } from './lighting.ts';
9
+ import { envRadiance, type Rig } from './lighting.ts';
9
10
  import type { Scene } from './scene.ts';
10
11
  import { type SheetTextures, sample } from './texture.ts';
11
12
 
@@ -184,7 +185,10 @@ export function shadeSample(
184
185
  const shin = Math.min(2048, 2 / (rough * rough * rough * rough) - 2);
185
186
  const lobe = ((shin + 8) / 25.13) * ndh ** shin;
186
187
  const fr = (1 - vdh) ** 5;
187
- const fe = (1 - ndv) ** 5 * (1 - rough) * (1 - rough);
188
+ const fv = (1 - ndv) ** 5;
189
+ // Image-based term: the studio seen along the reflection, occluded like the sky (+ cavity AO).
190
+ const rz = 2 * ndv * n2 - vz;
191
+ const specOcc = open * (0.4 + 0.6 * occ);
188
192
  const ch = [ar, ag, ab];
189
193
  for (let k = 0; k < 3; k++) {
190
194
  const al = ch[k] ?? 0;
@@ -192,8 +196,9 @@ export function shadeSample(
192
196
  const sky = (r.bounce[k] ?? 0) * (1 - hemi) + (r.sky[k] ?? 0) * hemi;
193
197
  const diffuse = al * dk * ((r.sunColor[k] ?? 0) * lit + sky * amb);
194
198
  const spec = (f0 + (1 - f0) * fr) * lobe * lit * (r.sunColor[k] ?? 0) * 0.8;
195
- const env =
196
- (f0 + (1 - f0) * fe) * (r.sky[k] ?? 0) * open * (glass ? 2.5 : 0.3 + 0.7 * (1 - rough));
199
+ const env = glass
200
+ ? (f0 + (1 - f0) * fv * (1 - rough) * (1 - rough)) * (r.sky[k] ?? 0) * open * 2.5
201
+ : (f0 + (Math.max(1 - rough, f0) - f0) * fv) * envRadiance(rz, rough, k) * specOcc;
197
202
  out[k] = (diffuse + spec + env) * r.exposure;
198
203
  }
199
204
  }