@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.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +1 -1
- package/README.md +10 -5
- package/dist/{chunk-a8fxakcj.js → chunk-6b05mnmx.js} +2 -2
- package/dist/{chunk-kgvbpqqw.js → chunk-7rtfjh9f.js} +6 -5
- package/dist/chunk-7rtfjh9f.js.map +10 -0
- package/dist/{chunk-1ypfs7ga.js → chunk-8j4c28jt.js} +790 -276
- package/dist/chunk-8j4c28jt.js.map +46 -0
- package/dist/{chunk-wtqhq529.js → chunk-d1fv6hrm.js} +6 -6
- package/dist/{chunk-wtqhq529.js.map → chunk-d1fv6hrm.js.map} +1 -1
- package/dist/{chunk-gcyp9b4d.js → chunk-dbq4qf52.js} +362 -215
- package/dist/chunk-dbq4qf52.js.map +25 -0
- package/dist/{chunk-xnt2sdhf.js → chunk-drna2rny.js} +2 -2
- package/dist/{chunk-ravbb06q.js → chunk-dxvypr7y.js} +2 -2
- package/dist/{chunk-11v28977.js → chunk-f6yme003.js} +85 -71
- package/dist/{chunk-11v28977.js.map → chunk-f6yme003.js.map} +6 -6
- package/dist/{chunk-4ddzsqtc.js → chunk-hg7pf34j.js} +4 -3
- package/dist/{chunk-4ddzsqtc.js.map → chunk-hg7pf34j.js.map} +5 -5
- package/dist/{chunk-w1zyhj40.js → chunk-jq4e17tj.js} +2 -2
- package/dist/{chunk-kvcep7ft.js → chunk-y63c6zcv.js} +967 -218
- package/dist/chunk-y63c6zcv.js.map +53 -0
- package/dist/cli/args.d.ts +7 -1
- package/dist/cli/bin.js +66 -25
- package/dist/cli/bin.js.map +6 -5
- package/dist/cli/manifest.d.ts +5 -1
- package/dist/cli/texture-cache.d.ts +4 -0
- package/dist/core/definition.d.ts +7 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/export/credits.d.ts +4 -0
- package/dist/export/glb-options.d.ts +48 -0
- package/dist/export/glb.d.ts +5 -35
- package/dist/export/hierarchy.d.ts +23 -0
- package/dist/export/index.d.ts +2 -1
- package/dist/export/index.js +21 -7
- package/dist/export/index.js.map +1 -1
- package/dist/export/lod-coverage.d.ts +12 -0
- package/dist/export/metadata.d.ts +2 -0
- package/dist/export/version.d.ts +2 -2
- package/dist/grammar/index.js +2 -2
- package/dist/index.js +63 -17
- package/dist/index.js.map +1 -1
- package/dist/kinds/head-armour.d.ts +3 -7
- package/dist/kinds/head-bust.d.ts +3 -2
- package/dist/kinds/head-face.d.ts +3 -1
- package/dist/kinds/head-garb.d.ts +4 -0
- package/dist/kinds/head-mesh.d.ts +4 -0
- package/dist/kinds/head-mouth.d.ts +4 -0
- package/dist/kinds/head-params.d.ts +9 -1
- package/dist/kinds/head-tone.d.ts +10 -0
- package/dist/kinds/head-torso.d.ts +35 -0
- package/dist/kinds/index.js +6 -6
- package/dist/material/cc0.d.ts +4 -0
- package/dist/material/define.d.ts +34 -0
- package/dist/material/generator.d.ts +1 -1
- package/dist/material/index.d.ts +8 -3
- package/dist/material/index.js +39 -7
- package/dist/material/index.js.map +1 -1
- package/dist/material/library.d.ts +7 -0
- package/dist/material/material-def.d.ts +30 -0
- package/dist/material/paint/canvas.d.ts +7 -0
- package/dist/material/paint/default-sheet.d.ts +3 -1
- package/dist/material/paint/fabric.d.ts +4 -0
- package/dist/material/paint/image-strip.d.ts +4 -0
- package/dist/material/paint/kit.d.ts +5 -0
- package/dist/material/paint/metal.d.ts +3 -0
- package/dist/material/paint/orm.d.ts +1 -1
- package/dist/material/paint/strip.d.ts +1 -1
- package/dist/material/paint/types.d.ts +32 -2
- package/dist/material/palette.d.ts +2 -0
- package/dist/material/slots.d.ts +5 -0
- package/dist/material/texture-source.d.ts +34 -0
- package/dist/material/trim-sheet.d.ts +20 -4
- package/dist/neural/index.js +2 -2
- package/dist/passes/index.js +2 -2
- package/dist/primitives/index.js +3 -3
- package/dist/qa/index.js +2 -2
- package/dist/render/beauty.d.ts +1 -1
- package/dist/render/index.js +3 -3
- package/dist/render/lighting.d.ts +7 -0
- package/dist/render/shade.d.ts +1 -1
- package/dist/testing/index.js +5 -5
- package/docs/README.md +4 -0
- package/docs/architecture/gltf-capabilities.md +38 -1
- package/docs/architecture/materials.md +151 -0
- package/docs/idea/10-sculpting.md +166 -0
- package/docs/idea/11-materials-authoring.md +132 -0
- package/docs/idea/12-blender-bridge.md +136 -0
- package/docs/idea/13-asset-pipeline-research.md +89 -0
- package/docs/plans/roadmap.md +12 -0
- package/package.json +2 -1
- package/recipes/boxcar/README.md +7 -0
- package/recipes/boxcar/boxcar.ts +118 -34
- package/recipes/goblin_head/README.md +27 -14
- package/recipes/goblin_head/goblin_head.ts +11 -9
- package/recipes/human_head/README.md +8 -4
- package/recipes/human_head/human_head.ts +4 -3
- package/recipes/material_swatches/material_swatches.ts +11 -5
- package/recipes/orc_head/README.md +32 -20
- package/recipes/orc_head/orc_head.ts +9 -7
- package/src/cli/args.ts +16 -3
- package/src/cli/manifest.ts +6 -0
- package/src/cli/run.ts +33 -9
- package/src/cli/texture-cache.ts +24 -0
- package/src/core/asset.ts +1 -0
- package/src/core/definition.ts +7 -0
- package/src/core/errors.ts +6 -1
- package/src/export/credits.ts +25 -0
- package/src/export/glb-options.ts +53 -0
- package/src/export/glb.ts +100 -113
- package/src/export/hierarchy.ts +122 -0
- package/src/export/index.ts +17 -1
- package/src/export/lod-coverage.ts +71 -0
- package/src/export/metadata.ts +3 -0
- package/src/export/version.ts +1 -1
- package/src/kinds/head-armour.ts +103 -73
- package/src/kinds/head-bust.ts +49 -86
- package/src/kinds/head-face.ts +94 -74
- package/src/kinds/head-garb.ts +181 -0
- package/src/kinds/head-hair.ts +74 -33
- package/src/kinds/head-mesh.ts +43 -5
- package/src/kinds/head-mouth.ts +111 -0
- package/src/kinds/head-params.ts +11 -2
- package/src/kinds/head-presets.ts +30 -27
- package/src/kinds/head-tone.ts +52 -0
- package/src/kinds/head-torso.ts +230 -0
- package/src/kinds/head.ts +8 -3
- package/src/material/cc0.ts +41 -0
- package/src/material/define.ts +130 -0
- package/src/material/generator.ts +15 -8
- package/src/material/index.ts +34 -3
- package/src/material/library.ts +196 -0
- package/src/material/material-def.ts +84 -0
- package/src/material/paint/canvas.ts +11 -1
- package/src/material/paint/compose.ts +1 -1
- package/src/material/paint/decode-png.ts +19 -13
- package/src/material/paint/default-sheet.ts +40 -28
- package/src/material/paint/fabric.ts +63 -0
- package/src/material/paint/image-strip.ts +88 -0
- package/src/material/paint/kit.ts +37 -0
- package/src/material/paint/masks.ts +3 -1
- package/src/material/paint/metal.ts +109 -0
- package/src/material/paint/orm.ts +14 -1
- package/src/material/paint/planks.ts +49 -4
- package/src/material/paint/strip.ts +35 -29
- package/src/material/paint/types.ts +45 -2
- package/src/material/paint/validate.ts +19 -3
- package/src/material/palette.ts +3 -0
- package/src/material/slots.ts +18 -0
- package/src/material/texture-source.ts +86 -0
- package/src/material/trim-sheet.ts +79 -13
- package/src/render/beauty.ts +2 -2
- package/src/render/lighting.ts +26 -0
- package/src/render/shade.ts +10 -5
- package/dist/chunk-1ypfs7ga.js.map +0 -42
- package/dist/chunk-gcyp9b4d.js.map +0 -22
- package/dist/chunk-kgvbpqqw.js.map +0 -10
- package/dist/chunk-kvcep7ft.js.map +0 -43
- /package/dist/{chunk-a8fxakcj.js.map → chunk-6b05mnmx.js.map} +0 -0
- /package/dist/{chunk-xnt2sdhf.js.map → chunk-drna2rny.js.map} +0 -0
- /package/dist/{chunk-ravbb06q.js.map → chunk-dxvypr7y.js.map} +0 -0
- /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
|
-
//
|
|
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 {
|
|
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 {
|
|
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 {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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:
|
|
40
|
+
channels: roots.map((node) => ({ sampler: 0, target: { node, path: 'rotation' } })),
|
|
33
41
|
};
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
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((
|
|
99
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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(
|
|
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:
|
|
182
|
-
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:
|
|
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
|
+
}
|
package/src/export/index.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
// Public API of /export: back ends that turn a CompiledAsset into a file.
|
|
2
2
|
|
|
3
|
-
export {
|
|
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
|
+
}
|
package/src/export/metadata.ts
CHANGED
|
@@ -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 {
|
package/src/export/version.ts
CHANGED
|
@@ -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.
|
|
5
|
+
export const VERSION = '0.2.0';
|
|
6
6
|
export const GENERATOR = `@developerz.ai/ts23d ${VERSION}`;
|