@ludic-labs/assembly 0.0.0-stage → 0.4.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 (78) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +2 -0
  3. package/README.md +50 -2
  4. package/dist/bake.d.ts +80 -0
  5. package/dist/bake.js +214 -0
  6. package/dist/browser.d.ts +89 -0
  7. package/dist/browser.js +534 -0
  8. package/dist/compat.d.ts +17 -0
  9. package/dist/compat.js +153 -0
  10. package/dist/compiler.d.ts +18 -0
  11. package/dist/compiler.js +1264 -0
  12. package/dist/diagnostics.d.ts +5 -0
  13. package/dist/diagnostics.js +8 -0
  14. package/dist/export.d.ts +54 -0
  15. package/dist/export.js +272 -0
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.js +3 -0
  18. package/dist/legacy.d.ts +36 -0
  19. package/dist/legacy.js +180 -0
  20. package/dist/math.d.ts +16 -0
  21. package/dist/math.js +62 -0
  22. package/dist/mesh.d.ts +11 -0
  23. package/dist/mesh.js +153 -0
  24. package/dist/motion.d.ts +63 -0
  25. package/dist/motion.js +1476 -0
  26. package/dist/pixel.d.ts +268 -0
  27. package/dist/pixel.js +942 -0
  28. package/dist/required.d.ts +2 -0
  29. package/dist/required.js +6 -0
  30. package/dist/shapes.d.ts +71 -0
  31. package/dist/shapes.js +396 -0
  32. package/dist/sprite-brief.d.ts +5 -0
  33. package/dist/sprite-brief.js +47 -0
  34. package/dist/sprite-compiler.d.ts +62 -0
  35. package/dist/sprite-compiler.js +1745 -0
  36. package/dist/sprite-decompile.d.ts +68 -0
  37. package/dist/sprite-decompile.js +786 -0
  38. package/dist/sprite-format.d.ts +4 -0
  39. package/dist/sprite-format.js +155 -0
  40. package/dist/sprite-guide.d.ts +40 -0
  41. package/dist/sprite-guide.js +330 -0
  42. package/dist/sprite-scene.d.ts +277 -0
  43. package/dist/sprite-scene.js +664 -0
  44. package/dist/sprite-types.d.ts +110 -0
  45. package/dist/sprite-types.js +1 -0
  46. package/dist/sprite.d.ts +8 -0
  47. package/dist/sprite.js +8 -0
  48. package/dist/svg/schema.d.ts +71 -0
  49. package/dist/svg/schema.js +217 -0
  50. package/dist/svg/types.d.ts +148 -0
  51. package/dist/svg/types.js +1 -0
  52. package/dist/svg.d.ts +26 -0
  53. package/dist/svg.js +539 -0
  54. package/dist/text.d.ts +12 -0
  55. package/dist/text.js +75 -0
  56. package/dist/types.d.ts +478 -0
  57. package/dist/types.js +1 -0
  58. package/docs/svg/README.md +150 -0
  59. package/docs/svg/specification.md +108 -0
  60. package/examples/svg/arcane-shield.json +261 -0
  61. package/examples/svg/aurora-ribbon.json +158 -0
  62. package/examples/svg/bouncing-ember.json +76 -0
  63. package/examples/svg/ember-fountain.json +224 -0
  64. package/examples/svg/lantern-keeper.json +1159 -0
  65. package/examples/svg/night-workshop.json +1120 -0
  66. package/examples/svg/waygate-platform.json +64 -0
  67. package/examples/svg/waygate-spark.json +92 -0
  68. package/examples/svg/waygate-wisp.json +105 -0
  69. package/examples/svg/waygate.json +485 -0
  70. package/package.json +132 -4
  71. package/schemas/asset-assembly-0.1.schema.json +354 -0
  72. package/schemas/asset-assembly-0.2.schema.json +429 -0
  73. package/schemas/asset-assembly-0.3.schema.json +572 -0
  74. package/schemas/motion-set-0.1.schema.json +178 -0
  75. package/schemas/pixel-sprite-0.1.schema.json +892 -0
  76. package/schemas/pixel-sprite-scene-0.1.schema.json +149 -0
  77. package/schemas/svg-asset-0.1.schema.json +1157 -0
  78. package/tools/svg-asset.mjs +106 -0
@@ -0,0 +1,4 @@
1
+ import type { SpriteDocument } from "./sprite-types.js";
2
+ export declare function formatSprite(value: unknown, indent?: string, key?: string | null): string;
3
+ /** Rename authored owners without merging parts; notes are keyed by the original name. */
4
+ export declare function renameParts(input: SpriteDocument, requested: Record<string, string>, notes?: Record<string, string>): SpriteDocument;
@@ -0,0 +1,155 @@
1
+ const WIDTH = 110;
2
+ export function formatSprite(value, indent = "", key = null) {
3
+ const pad = `${indent} `;
4
+ if (Array.isArray(value)) {
5
+ if (key === "pixels" && value.length > 1)
6
+ return `[\n${value.map((r) => `${pad}${JSON.stringify(r)}`).join(",\n")}\n${indent}]`;
7
+ const inline = `[${value.map((v) => formatSprite(v)).join(", ")}]`;
8
+ if (inline.length + indent.length <= WIDTH &&
9
+ !value.some((v) => v && typeof v === "object" && !Array.isArray(v)))
10
+ return inline;
11
+ return `[\n${value.map((v) => `${pad}${formatSprite(v, pad)}`).join(",\n")}\n${indent}]`;
12
+ }
13
+ if (value && typeof value === "object") {
14
+ const entries = Object.entries(value);
15
+ if (!entries.length)
16
+ return "{}";
17
+ const inline = `{ ${entries.map(([k, v]) => `${JSON.stringify(k)}: ${formatSprite(v, "", k)}`).join(", ")} }`;
18
+ if (!inline.includes("\n") &&
19
+ inline.length + indent.length <= WIDTH &&
20
+ !("pixels" in value &&
21
+ Array.isArray(value.pixels) &&
22
+ value.pixels.length > 1))
23
+ return inline;
24
+ return `{\n${entries.map(([k, v]) => `${pad}${JSON.stringify(k)}: ${formatSprite(v, pad, k)}`).join(",\n")}\n${indent}}`;
25
+ }
26
+ return JSON.stringify(value) ?? "null";
27
+ }
28
+ /** Rename authored owners without merging parts; notes are keyed by the original name. */
29
+ export function renameParts(input, requested, notes = {}) {
30
+ const doc = structuredClone(input);
31
+ const used = new Set();
32
+ const record = (v) => {
33
+ if (typeof v === "string")
34
+ used.add(v);
35
+ };
36
+ const eachLayer = (visit) => {
37
+ const lists = [
38
+ ...Object.values(doc.cels ?? {}).map((c) => c?.layers),
39
+ ...Object.values(doc.recipes ?? {}),
40
+ ];
41
+ for (const layers of lists)
42
+ if (Array.isArray(layers))
43
+ for (const l of layers)
44
+ if (l && typeof l === "object" && !Array.isArray(l))
45
+ visit(l);
46
+ };
47
+ if (Array.isArray(doc.parts))
48
+ doc.parts.forEach(record);
49
+ else if (doc.parts && typeof doc.parts === "object")
50
+ Object.keys(doc.parts).forEach(record);
51
+ eachLayer((l) => {
52
+ if (l.shade && typeof l.shade === "object" && !Array.isArray(l.shade))
53
+ Object.keys(l.shade).forEach(record);
54
+ record(l.part);
55
+ record(l.onPart);
56
+ record(l.at?.part);
57
+ for (const k of ["parts", "through"])
58
+ if (Array.isArray(l[k]))
59
+ l[k].forEach(record);
60
+ if (l.part === undefined) {
61
+ if (Array.isArray(l.silhouette))
62
+ l.silhouette.forEach(record);
63
+ else
64
+ record(l.silhouette);
65
+ }
66
+ });
67
+ for (const c of Object.values(doc.cels ?? {}))
68
+ if (Array.isArray(c?.exempt))
69
+ c.exempt.forEach(record);
70
+ const mapping = new Map();
71
+ const taken = new Set([...used].filter((n) => !Object.hasOwn(requested, n)));
72
+ const targets = new Set(Object.values(requested));
73
+ for (const [from, to] of Object.entries(requested)) {
74
+ if (typeof to !== "string")
75
+ throw new TypeError("part names must be strings");
76
+ let name = to;
77
+ for (let i = 2; taken.has(name) || (name !== to && targets.has(name)); i++)
78
+ name = `${to}-${i}`;
79
+ mapping.set(from, name);
80
+ taken.add(name);
81
+ }
82
+ const map = (v) => (typeof v === "string" ? (mapping.get(v) ?? v) : v);
83
+ const list = (v) => (Array.isArray(v) ? [...new Set(v.map(map))] : v);
84
+ eachLayer((l) => {
85
+ if (l.shade !== undefined) {
86
+ // Shade map keys and parts name authored owners; guide masks keep their original names.
87
+ let names = Array.isArray(l.parts) ? l.parts : null;
88
+ if (!names && l.shade === true)
89
+ names = Object.keys(doc.parts ?? {}).filter((n) => doc.parts[n]?.ramps?.length);
90
+ if (!names && l.shade && typeof l.shade === "object")
91
+ names = Object.keys(l.shade);
92
+ if (names?.some((n) => mapping.has(n))) {
93
+ const guides = { ...l.guides };
94
+ for (const n of names)
95
+ guides[n] = l.guides?.[n] ?? n;
96
+ l.guides = guides;
97
+ }
98
+ if (l.shade && typeof l.shade === "object" && !Array.isArray(l.shade))
99
+ l.shade = Object.fromEntries(Object.entries(l.shade).map(([n, ramp]) => [map(n), ramp]));
100
+ if (l.guides)
101
+ l.guides = Object.fromEntries(Object.entries(l.guides).map(([n, guide]) => [map(n), guide]));
102
+ }
103
+ // A silhouette's guide reference stays fixed; its implicit owner is renamed explicitly.
104
+ if (l.part === undefined && l.silhouette !== undefined) {
105
+ const names = typeof l.silhouette === "string" ? [l.silhouette] : l.silhouette;
106
+ if (Array.isArray(names) && names.length === 1 && mapping.has(names[0]))
107
+ l.part = names[0];
108
+ else if (Array.isArray(names) && names.some((n) => mapping.has(n))) {
109
+ // Split below: each guide mask retains its original ownership after mapping.
110
+ l.__spriteRename = names.map((n) => ({ ...l, silhouette: n, part: map(n) }));
111
+ }
112
+ }
113
+ if (Object.hasOwn(l, "part"))
114
+ l.part = map(l.part);
115
+ for (const k of ["parts", "through"])
116
+ if (Object.hasOwn(l, k))
117
+ l[k] = list(l[k]);
118
+ if (typeof l.onPart === "string")
119
+ l.onPart = map(l.onPart);
120
+ if (l.at && typeof l.at === "object" && !Array.isArray(l.at) && Object.hasOwn(l.at, "part"))
121
+ l.at.part = map(l.at.part);
122
+ });
123
+ const expand = (layers) => layers.flatMap((l) => l?.__spriteRename ?? [l]);
124
+ for (const c of Object.values(doc.cels ?? {})) {
125
+ if (Array.isArray(c?.layers))
126
+ c.layers = expand(c.layers);
127
+ if (Array.isArray(c?.exempt))
128
+ c.exempt = list(c.exempt);
129
+ }
130
+ for (const [name, layers] of Object.entries(doc.recipes ?? {}))
131
+ if (Array.isArray(layers))
132
+ doc.recipes[name] = expand(layers);
133
+ const declarations = Object.create(null);
134
+ if (Array.isArray(doc.parts))
135
+ for (const n of doc.parts)
136
+ declarations[String(map(n))] = {};
137
+ else
138
+ for (const [n, spec] of Object.entries(doc.parts ?? {}))
139
+ declarations[String(map(n))] = spec;
140
+ const described = Object.entries(notes).filter(([from]) => mapping.has(from));
141
+ if (described.length) {
142
+ for (const n of used)
143
+ declarations[String(map(n))] ??= {};
144
+ for (const [from, text] of described) {
145
+ const name = String(map(from));
146
+ declarations[name] = { ...declarations[name], notes: text };
147
+ }
148
+ doc.parts = declarations;
149
+ }
150
+ else if (Array.isArray(doc.parts))
151
+ doc.parts = list(doc.parts);
152
+ else if (doc.parts && typeof doc.parts === "object")
153
+ doc.parts = declarations;
154
+ return doc;
155
+ }
@@ -0,0 +1,40 @@
1
+ import type { CompiledAsset } from "./types.js";
2
+ import type { CompileSpriteOptions, SpriteGuidePart, SpriteView, SpriteRenderSpec } from "./sprite-types.js";
3
+ /** Empty pixels around render bases and layout guides, so the two agree on canvas size. */
4
+ export declare const DEFAULT_MARGIN = 2;
5
+ /**
6
+ * The asset in a sampled pose of one of its motion clips, `{ clip, phase }`,
7
+ * or the rest pose when `pose` is null.
8
+ */
9
+ export declare function posedAsset(asset: CompiledAsset, motionJson: unknown, pose: unknown, path?: string): CompiledAsset;
10
+ /** Renders a compiled asset; `fitHeight` searches the density that gives that many rows. */
11
+ export declare function renderSpriteBase(asset: CompiledAsset, spec: SpriteRenderSpec): import("./pixel.js").PixelImage;
12
+ /** Builds the `renderBase` option of compileSprite for documents that name `asset`. */
13
+ export declare function spriteRenderResolver(asset: CompiledAsset): NonNullable<CompileSpriteOptions["renderBase"]>;
14
+ /**
15
+ * Layout guide for one view of a compiled asset: the canvas the /pixel
16
+ * renderer would use at this density, and per part (merged across sides and
17
+ * repeats) and per instance id: visible bounding box, visible area, depth rank
18
+ * (0 = nearest) and visible pixel mask. Parts with no visible pixels are
19
+ * `hidden` when their projection covers at least one pixel (occluded) and are
20
+ * listed with `subpixel: true` otherwise (too small to show at this density).
21
+ */
22
+ export declare function layoutGuide(asset: CompiledAsset, view: SpriteView): {
23
+ width: number;
24
+ height: number;
25
+ pixelsPerMetre: number;
26
+ camera: string;
27
+ parts: Record<string, SpriteGuidePart>;
28
+ hidden: string[];
29
+ cut: string[];
30
+ owners: Int32Array<ArrayBufferLike>;
31
+ depth: Float32Array<ArrayBufferLike>;
32
+ roles: Int8Array<ArrayBufferLike>;
33
+ instances: string[];
34
+ originX: number;
35
+ originY: number;
36
+ };
37
+ /** Builds the `guide` option of compileSprite for documents that name `asset`. */
38
+ export declare function spriteGuideResolver(asset: CompiledAsset, { motion }?: {
39
+ motion?: unknown;
40
+ }): NonNullable<CompileSpriteOptions["guide"]>;
@@ -0,0 +1,330 @@
1
+ import { PIXEL_CAMERAS, PIXEL_PROFILES, pixelCamera, pixelInstances, renderPixelArt } from "./pixel.js";
2
+ import { compileMotion, poseAsset, samplePose } from "./motion.js";
3
+ import { requireValue } from "./required.js";
4
+ const RENDER_FIELDS = ["camera", "pixelsPerMetre", "fitHeight", "margin", "profile", "features", "snap"];
5
+ /** Empty pixels around render bases and layout guides, so the two agree on canvas size. */
6
+ export const DEFAULT_MARGIN = 2;
7
+ const fail = (code, path, message) => Object.assign(new Error(message), { diagnostics: [{ severity: "error", code, path, message }] });
8
+ /**
9
+ * The asset in a sampled pose of one of its motion clips, `{ clip, phase }`,
10
+ * or the rest pose when `pose` is null.
11
+ */
12
+ export function posedAsset(asset, motionJson, pose, path = "/pose") {
13
+ if (!pose)
14
+ return asset;
15
+ const bad = (message) => Object.assign(new Error(message), {
16
+ diagnostics: [{ severity: "error", code: "SPRITE_VIEW_POSE", path, message }],
17
+ });
18
+ if (typeof pose !== "object" ||
19
+ Array.isArray(pose) ||
20
+ !("clip" in pose) ||
21
+ typeof pose.clip !== "string" ||
22
+ !("phase" in pose) ||
23
+ typeof pose.phase !== "number" ||
24
+ !Number.isFinite(pose.phase))
25
+ throw bad("pose is { clip, phase } with a clip name and a finite phase");
26
+ const extra = Object.keys(pose).filter((k) => !["clip", "phase"].includes(k));
27
+ if (extra.length)
28
+ throw bad(`unknown pose field ${extra[0]}`);
29
+ if (!motionJson)
30
+ throw bad("the asset has no motion set");
31
+ const motion = compileMotion(motionJson, asset);
32
+ const clip = Object.hasOwn(motion.clips, pose.clip) ? motion.clips[pose.clip] : null;
33
+ if (!clip)
34
+ throw bad(`the asset's motion set has no clip ${pose.clip}`);
35
+ return poseAsset(asset, samplePose(asset, clip, { phase: pose.phase }));
36
+ }
37
+ function bboxHeight(img) {
38
+ let y0 = Infinity, y1 = -Infinity;
39
+ for (let y = 0; y < img.height; y++)
40
+ for (let x = 0; x < img.width; x++)
41
+ if (img.rgba[(y * img.width + x) * 4 + 3]) {
42
+ y0 = Math.min(y0, y);
43
+ y1 = Math.max(y1, y);
44
+ }
45
+ return y1 - y0 + 1;
46
+ }
47
+ /** Renders a compiled asset; `fitHeight` searches the density that gives that many rows. */
48
+ export function renderSpriteBase(asset, spec) {
49
+ if (spec.pixelsPerMetre !== undefined && !(Number.isFinite(spec.pixelsPerMetre) && spec.pixelsPerMetre > 0))
50
+ throw fail("PIXEL_DENSITY", "/pixelsPerMetre", "pixelsPerMetre must be a positive number");
51
+ const camera = typeof spec.camera === "string"
52
+ ? PIXEL_CAMERAS[spec.camera]
53
+ : spec.camera
54
+ ? pixelCamera(spec.camera.yaw, spec.camera.pitch)
55
+ : PIXEL_CAMERAS.front;
56
+ if (!camera)
57
+ throw new Error(`unknown camera ${spec.camera}`);
58
+ const profile = Object.hasOwn(PIXEL_PROFILES, spec.profile ?? "arcade")
59
+ ? PIXEL_PROFILES[(spec.profile ?? "arcade")]
60
+ : null;
61
+ if (!profile)
62
+ throw new Error(`unknown profile ${spec.profile}`);
63
+ const base = { camera, profile, margin: spec.margin ?? DEFAULT_MARGIN, features: spec.features ?? [] };
64
+ // Densities stay inside the renderer's accepted range (0, 512].
65
+ const draw = (ppm) => renderPixelArt(pixelInstances(asset), { ...base, pixelsPerMetre: Math.min(512, Math.max(0.25, ppm)) });
66
+ if (spec.fitHeight === undefined)
67
+ return draw(spec.pixelsPerMetre ?? 32);
68
+ if (!Number.isInteger(spec.fitHeight) || spec.fitHeight < 1 || spec.fitHeight > 1024)
69
+ throw fail("SPRITE_FIT_HEIGHT", "/fitHeight", "fitHeight must be an integer within 1..1024");
70
+ // Proportional steps, then a fine scan around the estimate; fail rather than return another height.
71
+ let ppm = 32;
72
+ let img = draw(ppm);
73
+ for (let i = 0; i < 6 && bboxHeight(img) !== spec.fitHeight; i++) {
74
+ ppm *= spec.fitHeight / bboxHeight(img);
75
+ img = draw(ppm);
76
+ }
77
+ for (let k = 1; k <= 200 && bboxHeight(img) !== spec.fitHeight; k++) {
78
+ const trial = ppm * (1 + (k % 2 ? 1 : -1) * Math.ceil(k / 2) * 0.002);
79
+ const t = draw(trial);
80
+ if (bboxHeight(t) === spec.fitHeight)
81
+ img = t;
82
+ }
83
+ if (bboxHeight(img) !== spec.fitHeight)
84
+ throw fail("SPRITE_FIT_HEIGHT", "/fitHeight", `no density gives exactly ${spec.fitHeight} rows`);
85
+ return img;
86
+ }
87
+ /** Builds the `renderBase` option of compileSprite for documents that name `asset`. */
88
+ export function spriteRenderResolver(asset) {
89
+ return (spec, path) => {
90
+ const extra = Object.keys(spec).filter((k) => !RENDER_FIELDS.includes(k));
91
+ if (extra.length)
92
+ throw Object.assign(new Error("unknown render field"), {
93
+ diagnostics: [
94
+ {
95
+ severity: "error",
96
+ code: "SPRITE_UNKNOWN_FIELD",
97
+ path: `${path}/${String(extra[0]).replace(/~/g, "~0").replace(/\//g, "~1")}`,
98
+ message: `unknown render field ${extra[0]}`,
99
+ },
100
+ ],
101
+ });
102
+ const img = renderSpriteBase(asset, spec);
103
+ return {
104
+ width: img.width,
105
+ height: img.height,
106
+ rgba: img.rgba,
107
+ owners: img.owners,
108
+ names: img.instances.map((i) => i.part),
109
+ };
110
+ };
111
+ }
112
+ const VIEW_FIELDS = ["camera", "pixelsPerMetre", "margin", "notes", "hide", "pose"];
113
+ /** An instance is cut from a view when its part, its id or an id prefix ("wall/") is listed. */
114
+ const isCut = (inst, hide) => hide.some((h) => h === inst.part || h === inst.id || (h.endsWith("/") && inst.id.startsWith(h)));
115
+ const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
116
+ /**
117
+ * Layout guide for one view of a compiled asset: the canvas the /pixel
118
+ * renderer would use at this density, and per part (merged across sides and
119
+ * repeats) and per instance id: visible bounding box, visible area, depth rank
120
+ * (0 = nearest) and visible pixel mask. Parts with no visible pixels are
121
+ * `hidden` when their projection covers at least one pixel (occluded) and are
122
+ * listed with `subpixel: true` otherwise (too small to show at this density).
123
+ */
124
+ export function layoutGuide(asset, view) {
125
+ if (!(Number.isFinite(view.pixelsPerMetre) && view.pixelsPerMetre > 0))
126
+ throw fail("PIXEL_DENSITY", "/pixelsPerMetre", "pixelsPerMetre must be a positive number");
127
+ const camera = typeof view.camera === "string"
128
+ ? PIXEL_CAMERAS[view.camera]
129
+ : pixelCamera(view.camera.yaw, view.camera.pitch);
130
+ if (!camera)
131
+ throw new Error(`unknown camera ${view.camera}`);
132
+ const ppm = Math.min(512, Math.max(0.25, view.pixelsPerMetre));
133
+ const margin = view.margin ?? DEFAULT_MARGIN;
134
+ const hide = view.hide ?? [];
135
+ const all = pixelInstances(asset);
136
+ const instances = all.filter((inst) => !isCut(inst, hide));
137
+ const cut = [
138
+ ...new Set(all.filter((inst) => isCut(inst, hide)).map((inst) => inst.part)),
139
+ ].sort();
140
+ const img = renderPixelArt(instances, {
141
+ camera,
142
+ profile: PIXEL_PROFILES.arcade,
143
+ pixelsPerMetre: ppm,
144
+ margin,
145
+ });
146
+ // Same framing as renderPixelArt, to project parts that are not visible.
147
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
148
+ for (const { mesh } of instances)
149
+ for (let v = 0; v < mesh.positions.length; v += 3) {
150
+ const p = [
151
+ requireValue(mesh.positions[v]),
152
+ requireValue(mesh.positions[v + 1]),
153
+ requireValue(mesh.positions[v + 2]),
154
+ ];
155
+ minX = Math.min(minX, dot3(p, camera.right));
156
+ maxX = Math.max(maxX, dot3(p, camera.right));
157
+ minY = Math.min(minY, dot3(p, camera.up));
158
+ maxY = Math.max(maxY, dot3(p, camera.up));
159
+ }
160
+ const originX = minX - Math.floor((img.width - (maxX - minX) * ppm) / 2) / ppm;
161
+ const originY = maxY + Math.floor((img.height - (maxY - minY) * ppm) / 2) / ppm;
162
+ const entries = new Map();
163
+ const entry = (name, instance) => {
164
+ if (!entries.has(name))
165
+ entries.set(name, { mask: [], proj: [Infinity, Infinity, -Infinity, -Infinity], close: [], instance });
166
+ return requireValue(entries.get(name));
167
+ };
168
+ instances.forEach((inst, i) => {
169
+ const names = inst.id === inst.part
170
+ ? [[inst.part, false]]
171
+ : [
172
+ [inst.part, false],
173
+ [inst.id, true],
174
+ ];
175
+ let cx = 0;
176
+ const n = inst.mesh.positions.length / 3;
177
+ const pr = [Infinity, Infinity, -Infinity, -Infinity];
178
+ const screen = [];
179
+ for (let v = 0; v < inst.mesh.positions.length; v += 3) {
180
+ const p = [
181
+ requireValue(inst.mesh.positions[v]),
182
+ requireValue(inst.mesh.positions[v + 1]),
183
+ requireValue(inst.mesh.positions[v + 2]),
184
+ ];
185
+ const x = (dot3(p, camera.right) - originX) * ppm, y = (originY - dot3(p, camera.up)) * ppm;
186
+ pr[0] = Math.min(pr[0], x);
187
+ pr[1] = Math.min(pr[1], y);
188
+ pr[2] = Math.max(pr[2], x);
189
+ pr[3] = Math.max(pr[3], y);
190
+ screen.push(x, y);
191
+ cx += dot3(p, camera.back);
192
+ }
193
+ // Projected area in square pixels: tells "too small to rasterise" from "occluded".
194
+ let area = 0;
195
+ const idx = inst.mesh.indices;
196
+ for (let t = 0; t + 2 < idx.length; t += 3) {
197
+ const [a, b, c] = [
198
+ requireValue(idx[t]) * 2,
199
+ requireValue(idx[t + 1]) * 2,
200
+ requireValue(idx[t + 2]) * 2,
201
+ ];
202
+ area +=
203
+ Math.abs((requireValue(screen[b]) - requireValue(screen[a])) *
204
+ (requireValue(screen[c + 1]) - requireValue(screen[a + 1])) -
205
+ (requireValue(screen[c]) - requireValue(screen[a])) *
206
+ (requireValue(screen[b + 1]) - requireValue(screen[a + 1]))) / 2;
207
+ }
208
+ for (const [name, instance] of names) {
209
+ const e = entry(name, instance);
210
+ e.proj = [
211
+ Math.min(e.proj[0], pr[0]),
212
+ Math.min(e.proj[1], pr[1]),
213
+ Math.max(e.proj[2], pr[2]),
214
+ Math.max(e.proj[3], pr[3]),
215
+ ];
216
+ e.close.push(cx / Math.max(1, n));
217
+ e.area = (e.area ?? 0) + area;
218
+ e.index ??= [];
219
+ e.index.push(i);
220
+ }
221
+ });
222
+ for (let q = 0; q < img.owners.length; q++) {
223
+ const o = requireValue(img.owners[q]);
224
+ if (o < 0)
225
+ continue;
226
+ const inst = requireValue(instances[o]);
227
+ entry(inst.part, false).mask.push(q);
228
+ if (inst.id !== inst.part)
229
+ entry(inst.id, true).mask.push(q);
230
+ }
231
+ // Mean distance toward the camera (metres along camera.back) of a part's
232
+ // instances: scenes composite parts by it, so an open door can be in front
233
+ // of a neighbour while its cabinet stays behind.
234
+ const meanZ = (e) => +(e.close.reduce((a, b) => a + b, 0) / Math.max(1, e.close.length)).toFixed(6);
235
+ const parts = Object.create(null);
236
+ const hidden = [];
237
+ const closeness = [...entries.entries()]
238
+ .filter(([, e]) => !e.instance)
239
+ .map(([name, e]) => [name, Math.max(...e.close)])
240
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
241
+ const rank = new Map(closeness.map(([name], i) => [name, i]));
242
+ for (const [name, e] of [...entries.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
243
+ const [px0, py0, px1, py1] = e.proj;
244
+ const projected = [
245
+ Math.floor(px0),
246
+ Math.floor(py0),
247
+ Math.max(1, Math.ceil(px1) - Math.floor(px0)),
248
+ Math.max(1, Math.ceil(py1) - Math.floor(py0)),
249
+ ];
250
+ const base = name.split("/")[0];
251
+ if (!e.mask.length) {
252
+ if ((e.area ?? 0) >= 1) {
253
+ hidden.push(name);
254
+ continue;
255
+ }
256
+ parts[name] = {
257
+ bbox: projected,
258
+ projected,
259
+ area: 0,
260
+ depth: rank.get(requireValue(base)) ?? 0,
261
+ z: meanZ(e),
262
+ mask: [],
263
+ subpixel: true,
264
+ instance: e.instance,
265
+ };
266
+ continue;
267
+ }
268
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
269
+ for (const q of e.mask) {
270
+ const x = q % img.width, y = Math.floor(q / img.width);
271
+ x0 = Math.min(x0, x);
272
+ y0 = Math.min(y0, y);
273
+ x1 = Math.max(x1, x);
274
+ y1 = Math.max(y1, y);
275
+ }
276
+ parts[name] = {
277
+ bbox: [x0, y0, x1 - x0 + 1, y1 - y0 + 1],
278
+ projected,
279
+ area: e.mask.length,
280
+ depth: rank.get(requireValue(base)) ?? 0,
281
+ z: meanZ(e),
282
+ mask: e.mask,
283
+ instance: e.instance,
284
+ };
285
+ }
286
+ return {
287
+ width: img.width,
288
+ height: img.height,
289
+ pixelsPerMetre: ppm,
290
+ camera: camera.name,
291
+ parts,
292
+ // Occluded parts, then parts cut from this view: drawing either is reported.
293
+ hidden: [...hidden, ...cut.filter((n) => !Object.hasOwn(parts, n) && !hidden.includes(n))],
294
+ cut,
295
+ owners: img.owners,
296
+ // Per pixel: distance toward the camera (metres along camera.back, in the
297
+ // asset's frame) of the visible surface, NaN where empty.
298
+ depth: img.depth,
299
+ // Per pixel: the light tone of the visible surface (/pixel roles), for `shade`.
300
+ roles: img.roles,
301
+ instances: instances.map((i) => i.id),
302
+ // World position of the canvas's top-left corner along camera right and up
303
+ // (metres): a scene places this cel at (originX, originY) plus its
304
+ // placement's projected translation.
305
+ originX,
306
+ originY,
307
+ };
308
+ }
309
+ /** Builds the `guide` option of compileSprite for documents that name `asset`. */
310
+ export function spriteGuideResolver(asset, { motion = null } = {}) {
311
+ return (view, path) => {
312
+ const extra = Object.keys(view).filter((k) => !VIEW_FIELDS.includes(k));
313
+ const bad = extra.length
314
+ ? `unknown view field ${extra[0]}`
315
+ : !(Number.isFinite(view.pixelsPerMetre) && view.pixelsPerMetre > 0)
316
+ ? "pixelsPerMetre must be a positive number"
317
+ : !view.camera
318
+ ? "camera is required"
319
+ : view.hide !== undefined &&
320
+ !(Array.isArray(view.hide) && view.hide.every((h) => typeof h === "string" && h))
321
+ ? "hide is a list of part names or instance ids"
322
+ : null;
323
+ if (bad)
324
+ throw Object.assign(new Error(bad), {
325
+ diagnostics: [{ severity: "error", code: "SPRITE_VIEW", path, message: bad }],
326
+ });
327
+ const posed = posedAsset(asset, motion, view.pose, `${path}/pose`);
328
+ return layoutGuide(posed, view);
329
+ };
330
+ }