@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
package/src/export/glb.ts CHANGED
@@ -2,71 +2,59 @@
2
2
  // with its pivot as translation, and each part's motion baked as a looping animation clip.
3
3
  // Extras a stock viewer understands: light sockets → KHR_lights_punctual, glowing parts →
4
4
  // emissive (+ KHR_materials_emissive_strength), provenance → asset.copyright + KHR_xmp_json_ld.
5
- // Zero dependencies and byte-deterministic: same CompiledAsset in, same bytes out.
5
+ // `exportLodGlb` packs several LODs into ONE file (MSFT_lod alternates sharing materials,
6
+ // textures and motion samplers). Zero dependencies and byte-deterministic.
6
7
 
7
8
  import type { CompiledAsset, Part } from '../core/index.ts';
8
- import { quatFromAxisAngle, Ts23dError } from '../core/index.ts';
9
- import type { OptimizeOptions } from '../passes/index.ts';
9
+ import { Ts23dError } from '../core/index.ts';
10
10
  import { BinWriter, type Json } from './bin-writer.ts';
11
11
  import { concat, packGlb } from './container.ts';
12
- import { LIGHTS, lightOf } from './lights.ts';
12
+ import { withCredits } from './credits.ts';
13
+ import type { GlbOptions, GlbResult, LodGlbResult } from './glb-options.ts';
14
+ import { newDoc, writeHierarchy } from './hierarchy.ts';
15
+ import { LIGHTS } from './lights.ts';
16
+ import {
17
+ checkCoverage,
18
+ coverageFor,
19
+ defaultCoverage,
20
+ MSFT_LOD,
21
+ SCREEN_COVERAGE,
22
+ } from './lod-coverage.ts';
13
23
  import { EMISSIVE_STRENGTH, type GlowRule, glowMaterial, materialsFor } from './materials.ts';
14
24
  import { globRe, glowLayer, mergeToPivots } from './merge-pivots.ts';
15
- import { type GlbMeta, metadata } from './metadata.ts';
16
- import { motionChannel } from './motion.ts';
17
- import { type ExportReport, optimizeParts } from './optimize-parts.ts';
18
- import { writePrimitive } from './primitive.ts';
19
- import { type SheetBinding, sheetGltf } from './textures.ts';
25
+ import { metadata } from './metadata.ts';
26
+ import { optimizeParts, summarize } from './optimize-parts.ts';
27
+ import { sheetGltf } from './textures.ts';
20
28
 
21
29
  // Viewers auto-play the FIRST clip and glTF has no "paused" flag, so the first clip is `rest`: two
22
30
  // keys holding the root's rest rotation. The file opens still; pick `run` to play it.
23
31
  // Authoring frame: Z up, +X front. glTF: Y up, +Z front, +X = the asset's left. This root
24
32
  // rotation maps +X→+Z, +Z→+Y, +Y→+X (verified numerically), so assets face forward in engines.
25
33
  const Z_UP_TO_Y_UP = [-0.5, -0.5, -0.5, 0.5];
26
- function restClip(bin: BinWriter): Json {
34
+ function restClip(bin: BinWriter, roots: readonly number[]): Json {
27
35
  const input = bin.accessor(new Float32Array([0, 1]), 'SCALAR', undefined, true);
28
36
  const output = bin.accessor(new Float32Array([...Z_UP_TO_Y_UP, ...Z_UP_TO_Y_UP]), 'VEC4');
29
37
  return {
30
38
  name: 'rest',
31
39
  samplers: [{ input, output, interpolation: 'STEP' }],
32
- channels: [{ sampler: 0, target: { node: 0, path: 'rotation' } }],
40
+ channels: roots.map((node) => ({ sampler: 0, target: { node, path: 'rotation' } })),
33
41
  };
34
42
  }
35
43
 
36
- export interface GlbOptions {
37
- /** Keep every part as its own node (debugging, per-part picking). Default merges to pivots. */
38
- readonly keepParts?: boolean;
39
- /**
40
- * Bake each part's `motion` into a looping `run` clip. Off by default: glTF has no autoplay
41
- * flag and most viewers play the first clip, while engines usually drive the named pivots
42
- * from game code. Turn it on for previews, cutscenes and ambient props.
43
- */
44
- readonly animations?: boolean;
45
- /**
46
- * Bind the painted trim sheet to every material: a `PaintedSheet` embeds the three PNGs (one
47
- * self-contained file); `{ external: true, prefix? }` references shared `trim_sheet_*.png` by
48
- * URI. Omitted: no textures, byte-identical to the untextured output.
49
- */
50
- readonly sheet?: SheetBinding;
51
- /**
52
- * Weld + vertex-cache + vertex-fetch optimisation per primitive (passes/optimize). On by
53
- * default; `false` writes the meshes exactly as compiled (debugging the IR).
54
- */
55
- readonly optimize?: boolean | OptimizeOptions;
56
- /** Parts that emit light (lamp lenses, lit windows): one emissive material per rule. */
57
- readonly glow?: readonly GlowRule[];
58
- /** Provenance: asset.copyright + an XMP packet (title, creators, licence, sources). */
59
- readonly meta?: GlbMeta;
44
+ export type { GlbOptions, GlbResult, LodGlbResult } from './glb-options.ts';
45
+
46
+ export function toGlb(input: CompiledAsset, o: GlbOptions = {}): Uint8Array {
47
+ return exportGlb(input, o).glb;
60
48
  }
61
49
 
62
- /** The GLB bytes plus what the optimiser did (vertex counts, ACMR before/after, per part). */
63
- export interface GlbResult {
64
- readonly glb: Uint8Array;
65
- readonly report: ExportReport;
50
+ export function exportGlb(input: CompiledAsset, o: GlbOptions = {}): GlbResult {
51
+ const { glb, reports } = exportLodGlb([input], o);
52
+ return { glb, report: reports[0] ?? summarize([], false) };
66
53
  }
67
54
 
68
- export function toGlb(input: CompiledAsset, o: GlbOptions = {}): Uint8Array {
69
- return exportGlb(input, o).glb;
55
+ /** All LODs in one GLB: LOD0 in the scene, the rest as MSFT_lod alternates outside it. */
56
+ export function toLodGlb(lods: readonly CompiledAsset[], o: GlbOptions = {}): Uint8Array {
57
+ return exportLodGlb(lods, o).glb;
70
58
  }
71
59
 
72
60
  function checkGlow(rules: readonly GlowRule[]): void {
@@ -78,9 +66,22 @@ function checkGlow(rules: readonly GlowRule[]): void {
78
66
  });
79
67
  }
80
68
 
81
- export function exportGlb(input: CompiledAsset, o: GlbOptions = {}): GlbResult {
82
- const glow = o.glow ?? input.glow ?? [];
83
- checkGlow(glow);
69
+ function checkLods(lods: readonly CompiledAsset[]): void {
70
+ const names = new Set(lods.map((l) => l.name));
71
+ const levels = lods.map((l) => l.lod);
72
+ if (
73
+ lods.length === 0 ||
74
+ names.size !== 1 ||
75
+ levels.some((l, i) => i > 0 && l <= (levels[i - 1] ?? 0))
76
+ )
77
+ throw new Ts23dError('T3D_RECIPE_INVALID', {
78
+ cause: `exportLodGlb got LODs [${levels.join(', ')}] of [${[...names].join(', ')}]`,
79
+ fix: 'pass compile(asset, { lod }) results of ONE asset, highest detail first: LOD0, LOD1, LOD2',
80
+ });
81
+ }
82
+
83
+ /** One LOD prepared for writing: merged + optimised parts and its glow-layer lookup. */
84
+ function prepare(input: CompiledAsset, o: GlbOptions, glow: readonly GlowRule[]) {
84
85
  const opt = o.optimize === false ? false : o.optimize === true ? {} : (o.optimize ?? {});
85
86
  const merged = o.keepParts
86
87
  ? input
@@ -94,101 +95,87 @@ export function exportGlb(input: CompiledAsset, o: GlbOptions = {}): GlbResult {
94
95
  const glowOf = (p: Part, i: number): number =>
95
96
  glowLayer.get(merged.parts[i] ?? p) ??
96
97
  (o.keepParts ? glowRes.findIndex((rs) => rs.some((r) => r.test(p.name))) : -1);
98
+ return { asset, report, glowOf };
99
+ }
100
+
101
+ export function exportLodGlb(lods: readonly CompiledAsset[], o: GlbOptions = {}): LodGlbResult {
102
+ checkLods(lods);
103
+ const top = lods[0] as CompiledAsset;
104
+ const glow = o.glow ?? top.glow ?? [];
105
+ checkGlow(glow);
106
+ const preps = lods.map((l) => prepare(l, o, glow));
107
+ const withSheet = o.sheet !== undefined;
97
108
  const mats = materialsFor(
98
- new Set(asset.parts.flatMap((p) => (p.surface ? [p.surface] : []))),
99
- o.sheet !== undefined,
109
+ new Set(preps.flatMap((p) => p.asset.parts.flatMap((q) => (q.surface ? [q.surface] : [])))),
110
+ withSheet,
100
111
  );
101
112
  const glowAt = mats.materials.length;
102
- const materials = [
103
- ...mats.materials,
104
- ...glow.map((g, i) => glowMaterial(g, i, o.sheet !== undefined)),
105
- ];
106
- const bin = new BinWriter();
107
- const meshes: Json[] = [];
108
- const nodes: Json[] = [{ name: asset.name, children: [] as number[] }];
109
- const index = new Map<string, number>();
110
- const samplers: Json[] = [];
111
- const channels: Json[] = [];
112
- const lights: Json[] = [];
113
- const attach = (parentId: number, id: number): void => {
114
- const parent = nodes[parentId];
115
- if (!parent) return;
116
- parent['children'] = [...((parent['children'] as number[] | undefined) ?? []), id];
117
- };
118
-
119
- asset.parts.forEach((part: Part, i) => {
120
- const node: Json = { name: part.name, translation: [...part.at] };
121
- if (part.yaw) node['rotation'] = [...quatFromAxisAngle([0, 0, 1], part.yaw)];
122
- if (part.emitter || part.socket) {
123
- node['extras'] = {
124
- ts23d: {
125
- ...(part.emitter ? { emitter: part.emitter } : {}),
126
- ...(part.socket ? { socket: part.socket } : {}),
127
- },
128
- };
129
- }
130
- if (part.mesh && part.mesh.indices.length > 0) {
131
- const g = glowOf(part, i);
132
- const material = g >= 0 ? glowAt + g : (mats.index.get(part.surface ?? 'opaque') ?? 0);
133
- meshes.push({
134
- name: part.name,
135
- primitives: [writePrimitive(bin, part.mesh, material, o.sheet !== undefined)],
136
- });
137
- node['mesh'] = meshes.length - 1;
138
- }
139
- nodes.push(node);
140
- const id = nodes.length - 1;
141
- index.set(part.name, id);
142
- attach(part.parent === null ? 0 : (index.get(part.parent) ?? 0), id);
143
- const lit = lightOf(part);
144
- if (lit) {
145
- lights.push(lit.light);
146
- nodes.push({
147
- name: `${part.name}_light`,
148
- rotation: [...lit.rotation],
149
- extensions: { [LIGHTS]: { light: lights.length - 1 } },
150
- });
151
- attach(id, nodes.length - 1);
152
- }
153
- if (part.motion && o.animations) {
154
- const { sampler, channel } = motionChannel(bin, part.motion, id, part.at);
155
- samplers.push(sampler);
156
- channels.push({ ...channel, sampler: samplers.length - 1 });
157
- }
158
- });
113
+ const materials = [...mats.materials, ...glow.map((g, i) => glowMaterial(g, i, withSheet))];
114
+ const doc = newDoc(new BinWriter());
115
+ const roots = preps.map(({ asset, glowOf }, k) =>
116
+ writeHierarchy(doc, asset, k === 0 ? asset.name : `${asset.name}_LOD${asset.lod}`, {
117
+ withSheet,
118
+ animations: o.animations === true,
119
+ materialOf: (part, i) => {
120
+ const g = glowOf(part, i);
121
+ return g >= 0 ? glowAt + g : (mats.index.get(part.surface ?? 'opaque') ?? 0);
122
+ },
123
+ }),
124
+ );
125
+ for (const r of roots) {
126
+ const node = doc.nodes[r];
127
+ if (node) node['rotation'] = Z_UP_TO_Y_UP;
128
+ }
129
+ const coverage: number[] = [];
130
+ const rootNode = doc.nodes[0];
131
+ if (roots.length > 1 && rootNode) {
132
+ const c = o.lodCoverage ?? top.lodCoverage ?? defaultCoverage(lods);
133
+ checkCoverage(c, top.name);
134
+ coverage.push(
135
+ ...coverageFor(
136
+ lods.map((l) => l.lod),
137
+ c,
138
+ ),
139
+ );
140
+ // Alternates stay OUT of the scene's root list: a viewer without MSFT_lod shows LOD0 only.
141
+ rootNode['extensions'] = { [MSFT_LOD]: { ids: roots.slice(1) } };
142
+ rootNode['extras'] = { [SCREEN_COVERAGE]: coverage };
143
+ }
159
144
 
160
145
  // Every accessor — the rest clip's included — must be written BEFORE the buffer is sealed.
161
- const animations = channels.length ? [restClip(bin), { name: 'run', samplers, channels }] : [];
146
+ const { bin, lights, samplers, channels } = doc;
147
+ const animations = channels.length
148
+ ? [restClip(bin, roots), { name: 'run', samplers, channels }]
149
+ : [];
162
150
  const geometry = bin.bytes();
163
151
  const tex = o.sheet ? sheetGltf(o.sheet, bin.views, geometry.byteLength) : null;
164
152
  const body = tex?.tail.byteLength ? concat(geometry, tex.tail) : geometry;
165
- const meta = metadata(asset.name, o.meta ?? input.meta);
153
+ const meta = metadata(top.name, withCredits(top, o.meta ?? top.meta));
166
154
  const used = [
167
155
  ...mats.extensionsUsed,
168
156
  ...(glow.some((g) => (g.strength ?? 4) !== 1) ? [EMISSIVE_STRENGTH] : []),
169
157
  ...(lights.length ? [LIGHTS] : []),
158
+ ...(coverage.length ? [MSFT_LOD] : []),
170
159
  ...meta.used,
171
160
  ].sort();
172
161
  const extensions: Json = {
173
162
  ...(lights.length ? { [LIGHTS]: { lights } } : {}),
174
163
  ...(meta.extensions ?? {}),
175
164
  };
176
- const root = nodes[0];
177
- if (root) root['rotation'] = Z_UP_TO_Y_UP;
178
165
  const gltf: Json = {
179
166
  asset: meta.asset,
180
167
  extras: {
181
- kind: asset.kind,
182
- lod: asset.lod,
168
+ kind: top.kind,
169
+ lod: coverage.length ? lods.map((l) => l.lod) : top.lod,
183
170
  units: 'metres',
184
171
  up: 'Z (rotated to glTF Y-up at the root)',
185
172
  },
186
173
  ...(used.length ? { extensionsUsed: used } : {}),
187
174
  ...(Object.keys(extensions).length ? { extensions } : {}),
188
175
  scene: 0,
189
- scenes: [{ name: asset.name, nodes: [0] }],
190
- nodes,
191
- meshes,
176
+ scenes: [{ name: top.name, nodes: [0] }],
177
+ nodes: doc.nodes,
178
+ meshes: doc.meshes,
192
179
  materials,
193
180
  ...(tex ? tex.json : {}),
194
181
  buffers: [{ byteLength: body.byteLength }],
@@ -196,5 +183,5 @@ export function exportGlb(input: CompiledAsset, o: GlbOptions = {}): GlbResult {
196
183
  accessors: bin.accessors,
197
184
  ...(animations.length ? { animations } : {}),
198
185
  };
199
- return { glb: packGlb(gltf, body), report };
186
+ return { glb: packGlb(gltf, body), reports: preps.map((p) => p.report), coverage };
200
187
  }
@@ -0,0 +1,122 @@
1
+ // Writes one LOD's node tree into a shared glTF document: a root node, one node per (merged) part
2
+ // with its pivot as translation, light nodes under light sockets, meshes, and the part motions as
3
+ // `run` channels. Called once per LOD; the bin, materials, lights and motion samplers are shared,
4
+ // so a single-file GLB stores each identical motion and light only once.
5
+
6
+ import type { CompiledAsset, Part } from '../core/index.ts';
7
+ import { quatFromAxisAngle } from '../core/index.ts';
8
+ import type { BinWriter, Json } from './bin-writer.ts';
9
+ import { LIGHTS, lightOf } from './lights.ts';
10
+ import { motionChannel } from './motion.ts';
11
+ import { writePrimitive } from './primitive.ts';
12
+
13
+ /** The growing glTF arrays every LOD appends to. */
14
+ export interface GltfDoc {
15
+ readonly bin: BinWriter;
16
+ readonly nodes: Json[];
17
+ readonly meshes: Json[];
18
+ readonly lights: Json[];
19
+ readonly samplers: Json[];
20
+ readonly channels: Json[];
21
+ /** motion + rest pose → sampler index: the same wheel spin at LOD0 and LOD1 is one sampler. */
22
+ readonly motionSamplers: Map<string, number>;
23
+ readonly lightIndex: Map<string, number>;
24
+ }
25
+
26
+ export const newDoc = (bin: BinWriter): GltfDoc => ({
27
+ bin,
28
+ nodes: [],
29
+ meshes: [],
30
+ lights: [],
31
+ samplers: [],
32
+ channels: [],
33
+ motionSamplers: new Map(),
34
+ lightIndex: new Map(),
35
+ });
36
+
37
+ export interface HierarchyOptions {
38
+ /** Material index for a part (trim sheet by surface, or its glow layer). */
39
+ readonly materialOf: (part: Part, i: number) => number;
40
+ readonly withSheet: boolean;
41
+ readonly animations: boolean;
42
+ }
43
+
44
+ function attach(doc: GltfDoc, parentId: number, id: number): void {
45
+ const parent = doc.nodes[parentId];
46
+ if (!parent) return;
47
+ parent['children'] = [...((parent['children'] as number[] | undefined) ?? []), id];
48
+ }
49
+
50
+ function lightIndexOf(doc: GltfDoc, light: Json): number {
51
+ const key = JSON.stringify(light);
52
+ const known = doc.lightIndex.get(key);
53
+ if (known !== undefined) return known;
54
+ doc.lights.push(light);
55
+ doc.lightIndex.set(key, doc.lights.length - 1);
56
+ return doc.lights.length - 1;
57
+ }
58
+
59
+ function addMotion(doc: GltfDoc, part: Part, id: number): void {
60
+ if (!part.motion) return;
61
+ const key = JSON.stringify([part.motion, part.at]);
62
+ let sampler = doc.motionSamplers.get(key);
63
+ let channel: Json;
64
+ if (sampler === undefined) {
65
+ const written = motionChannel(doc.bin, part.motion, id, part.at);
66
+ doc.samplers.push(written.sampler);
67
+ sampler = doc.samplers.length - 1;
68
+ doc.motionSamplers.set(key, sampler);
69
+ channel = written.channel;
70
+ } else {
71
+ channel = {
72
+ target: { node: id, path: part.motion.kind === 'slide' ? 'translation' : 'rotation' },
73
+ };
74
+ }
75
+ doc.channels.push({ ...channel, sampler });
76
+ }
77
+
78
+ /** Appends `asset`'s tree under a new root named `rootName`; returns the root node index. */
79
+ export function writeHierarchy(
80
+ doc: GltfDoc,
81
+ asset: CompiledAsset,
82
+ rootName: string,
83
+ o: HierarchyOptions,
84
+ ): number {
85
+ doc.nodes.push({ name: rootName, children: [] as number[] });
86
+ const root = doc.nodes.length - 1;
87
+ const index = new Map<string, number>();
88
+ asset.parts.forEach((part: Part, i) => {
89
+ const node: Json = { name: part.name, translation: [...part.at] };
90
+ if (part.yaw) node['rotation'] = [...quatFromAxisAngle([0, 0, 1], part.yaw)];
91
+ if (part.emitter || part.socket) {
92
+ node['extras'] = {
93
+ ts23d: {
94
+ ...(part.emitter ? { emitter: part.emitter } : {}),
95
+ ...(part.socket ? { socket: part.socket } : {}),
96
+ },
97
+ };
98
+ }
99
+ if (part.mesh && part.mesh.indices.length > 0) {
100
+ doc.meshes.push({
101
+ name: part.name,
102
+ primitives: [writePrimitive(doc.bin, part.mesh, o.materialOf(part, i), o.withSheet)],
103
+ });
104
+ node['mesh'] = doc.meshes.length - 1;
105
+ }
106
+ doc.nodes.push(node);
107
+ const id = doc.nodes.length - 1;
108
+ index.set(part.name, id);
109
+ attach(doc, part.parent === null ? root : (index.get(part.parent) ?? root), id);
110
+ const lit = lightOf(part);
111
+ if (lit) {
112
+ doc.nodes.push({
113
+ name: `${part.name}_light`,
114
+ rotation: [...lit.rotation],
115
+ extensions: { [LIGHTS]: { light: lightIndexOf(doc, lit.light) } },
116
+ });
117
+ attach(doc, id, doc.nodes.length - 1);
118
+ }
119
+ if (o.animations) addMotion(doc, part, id);
120
+ });
121
+ return root;
122
+ }
@@ -1,6 +1,22 @@
1
1
  // Public API of /export: back ends that turn a CompiledAsset into a file.
2
2
 
3
- export { exportGlb, type GlbOptions, type GlbResult, toGlb } from './glb.ts';
3
+ export {
4
+ exportGlb,
5
+ exportLodGlb,
6
+ type GlbOptions,
7
+ type GlbResult,
8
+ type LodGlbResult,
9
+ toGlb,
10
+ toLodGlb,
11
+ } from './glb.ts';
12
+ export {
13
+ checkCoverage,
14
+ coverageFor,
15
+ defaultCoverage,
16
+ type LodCoverage,
17
+ MSFT_LOD,
18
+ SCREEN_COVERAGE,
19
+ } from './lod-coverage.ts';
4
20
  export { mergeToPivots } from './merge-pivots.ts';
5
21
  export {
6
22
  type ExportReport,
@@ -0,0 +1,71 @@
1
+ // View-distance hints for the single-file GLB: MSFT_lod (vendor extension, node level) on the
2
+ // LOD0 root lists the LOD1/LOD2 roots, and `extras.MSFT_screencoverage` holds, per LOD, the
3
+ // screen-height fraction below which the next LOD (or nothing) takes over — Unity LODGroup's
4
+ // "screen relative transition height". Spec: KhronosGroup/glTF extensions/2.0/Vendor/MSFT_lod.
5
+ //
6
+ // Default thresholds match screen-space triangle density: a LOD with r × the triangles looks as
7
+ // dense at √r × the size on screen (triangles per pixel ∝ tris / coverage²). Landmarks (≥ 10 m)
8
+ // cull later than props, so a building stays on the skyline.
9
+
10
+ import type { CompiledAsset } from '../core/index.ts';
11
+ import { Ts23dError, worldMeshes } from '../core/index.ts';
12
+
13
+ export const MSFT_LOD = 'MSFT_lod';
14
+ export const SCREEN_COVERAGE = 'MSFT_screencoverage';
15
+
16
+ export type LodCoverage = readonly [number, number, number];
17
+
18
+ const tris = (a: CompiledAsset): number =>
19
+ a.parts.reduce((s, p) => s + (p.mesh ? p.mesh.indices.length / 3 : 0), 0);
20
+
21
+ /** Largest extent in metres: `real` when declared, else the LOD0 world bounds. */
22
+ function extent(a: CompiledAsset): number {
23
+ if (a.real) return Math.max(a.real.length, a.real.width, a.real.height);
24
+ const lo = [Infinity, Infinity, Infinity];
25
+ const hi = [-Infinity, -Infinity, -Infinity];
26
+ for (const mesh of worldMeshes(a.parts).values()) {
27
+ const p = mesh.positions;
28
+ for (let i = 0; i < p.length; i++) {
29
+ const k = i % 3;
30
+ const v = p[i] ?? 0;
31
+ lo[k] = Math.min(lo[k] ?? 0, v);
32
+ hi[k] = Math.max(hi[k] ?? 0, v);
33
+ }
34
+ }
35
+ return Math.max(0, ...hi.map((h, k) => h - (lo[k] ?? 0)));
36
+ }
37
+
38
+ const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
39
+ const round = (v: number): number => Math.round(v * 1000) / 1000;
40
+
41
+ /** Default [LOD0→1, LOD1→2, cull] for an asset from its compiled LODs (any subset, LOD0 first). */
42
+ export function defaultCoverage(lods: readonly CompiledAsset[]): LodCoverage {
43
+ const byLod = new Map<number, number>(lods.map((a) => [a.lod, tris(a)]));
44
+ const top = lods[0];
45
+ const t0 = Math.max(1, top ? tris(top) : 1);
46
+ const ratio = (lod: number): number => (byLod.get(lod) ?? t0) / t0;
47
+ const c1 = round(clamp(Math.sqrt(ratio(1)), 0.1, 0.6));
48
+ const c2 = round(clamp(Math.sqrt(ratio(2)), 0.02, c1 / 2));
49
+ const cull = top && extent(top) >= 10 ? 0.002 : 0.01;
50
+ return [c1, c2, Math.min(cull, round(c2 / 2))];
51
+ }
52
+
53
+ export function checkCoverage(c: readonly number[], asset: string): void {
54
+ const [a = -1, b = -1, cull = -1] = c;
55
+ if (c.length !== 3 || !(a < 1 && a > b && b > cull && cull >= 0))
56
+ throw new Ts23dError('T3D_RECIPE_INVALID', {
57
+ cause: `lodCoverage ${JSON.stringify(c)} on '${asset}' is not strictly descending in [0, 1)`,
58
+ fix: 'give [LOD0→LOD1, LOD1→LOD2, cull] screen-height fractions, e.g. [0.5, 0.2, 0.01]; 0 = never cull',
59
+ });
60
+ }
61
+
62
+ /**
63
+ * MSFT_screencoverage for the LODs actually written (e.g. [0, 2]): entry j is the coverage where
64
+ * LOD j hands over — to the next included LOD's switch point, or to nothing (cull) for the last.
65
+ */
66
+ export function coverageFor(levels: readonly number[], c: LodCoverage): number[] {
67
+ return levels.map((_, j) => {
68
+ const next = levels[j + 1];
69
+ return next === undefined ? c[2] : (c[next - 1] ?? c[2]);
70
+ });
71
+ }
@@ -17,6 +17,8 @@ export interface GlbMeta {
17
17
  readonly license?: string;
18
18
  /** Where the numbers came from (the recipe's `sources`) → `dc:relation`. */
19
19
  readonly sources?: readonly string[];
20
+ /** Third-party inputs (e.g. CC0 texture sets) → `dc:contributor`. Filled by export. */
21
+ readonly credits?: readonly string[];
20
22
  }
21
23
 
22
24
  const XMP = 'KHR_xmp_json_ld';
@@ -49,6 +51,7 @@ export function metadata(
49
51
  ...(meta.copyright ? { 'dc:rights': alt(meta.copyright) } : {}),
50
52
  ...(meta.license ? { 'xmpRights:UsageTerms': alt(meta.license) } : {}),
51
53
  ...(meta.sources?.length ? { 'dc:relation': { '@set': [...meta.sources] } } : {}),
54
+ ...(meta.credits?.length ? { 'dc:contributor': { '@set': [...meta.credits] } } : {}),
52
55
  'xmp:CreatorTool': GENERATOR,
53
56
  };
54
57
  return {
@@ -2,5 +2,5 @@
2
2
  // constant because the library may not import package.json (rootDir is src); version.test.ts
3
3
  // fails the gate when the two disagree, and `bun run release` stamps both.
4
4
 
5
- export const VERSION = '0.1.0';
5
+ export const VERSION = '0.2.0';
6
6
  export const GENERATOR = `@developerz.ai/ts23d ${VERSION}`;