@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,5 @@
1
+ import type { Diagnostic } from "./types.js";
2
+ export declare class AssetCompileError extends Error {
3
+ readonly diagnostics: Diagnostic[];
4
+ constructor(diagnostics: Diagnostic[]);
5
+ }
@@ -0,0 +1,8 @@
1
+ export class AssetCompileError extends Error {
2
+ diagnostics;
3
+ constructor(diagnostics) {
4
+ super(diagnostics.map((d) => `${d.code} ${d.path}: ${d.message}`).join("\n"));
5
+ this.diagnostics = diagnostics;
6
+ this.name = "AssetCompileError";
7
+ }
8
+ }
@@ -0,0 +1,54 @@
1
+ import type { Bounds, CompiledAsset, Vec3 } from "./types.js";
2
+ /** Version of the GLB layout and metadata written by `exportGLB`. */
3
+ export declare const GLB_EXPORT_VERSION = 1;
4
+ /** One compiled asset placed in an exported scene, e.g. a posed character and the chair it sits on. */
5
+ export interface SceneItem {
6
+ asset: CompiledAsset;
7
+ /** sRGB `#rrggbb` accent for tintable materials. */
8
+ tint?: string;
9
+ /** Translation in the asset frame. */
10
+ offset?: Vec3;
11
+ /** Node name. Defaults to `asset`, or `asset/<n>` when the scene has several items. */
12
+ name?: string;
13
+ }
14
+ export type Scene = CompiledAsset | SceneItem | readonly SceneItem[];
15
+ export interface GLBOptions {
16
+ /** Name of the scene and its root node (default `ludic-assembly`). */
17
+ name?: string;
18
+ }
19
+ export interface SceneView {
20
+ /** Square image size in pixels, 64–2048 (default 1024). */
21
+ size?: number;
22
+ /** Camera yaw and pitch in radians, as in `createPreview` (default: the preview's front-right view). */
23
+ yaw?: number;
24
+ pitch?: number;
25
+ /** Supersampling per axis, 1–4 (default 2). */
26
+ samples?: number;
27
+ /** World bounds to frame instead of the scene geometry, e.g. a close-up. */
28
+ fit?: Bounds;
29
+ /** Transparent margin on each side in pixels (default size / 22.5, rounded). */
30
+ padding?: number;
31
+ }
32
+ export interface SceneImage {
33
+ width: number;
34
+ height: number;
35
+ /** Straight (unpremultiplied) RGBA bytes, row-major from the top-left; the background is transparent. */
36
+ color: Uint8Array;
37
+ }
38
+ /**
39
+ * Experimental. Serialize a static scene as binary glTF 2.0 (GLB). The same scene always produces the
40
+ * same bytes: no timestamps or library versions are written.
41
+ *
42
+ * Each instance becomes a node and mesh named by its stable ID, with one material per asset material.
43
+ * Vertex colors are stored untinted in `COLOR_0`; a tintable material carries the tint as its
44
+ * `baseColorFactor`, which glTF multiplies with the vertex color. Geometry keeps the asset frame
45
+ * (+X right, +Y up, −Z front). A root node turns it 180° about +Y so the asset faces glTF's +Z front.
46
+ * Positions, normals and colors are stored as 32-bit floats. Units are recorded in `asset.extras` and
47
+ * never converted. Joints and clips are not exported: pass `poseAsset(…)` to export one frame.
48
+ */
49
+ export declare function exportGLB(scene: Scene, options?: GLBOptions): Uint8Array;
50
+ /**
51
+ * Experimental. Bake a square, transparent image of a scene with the CPU rasterizer used by previews,
52
+ * framed to its geometry or to `fit`. Pixel bytes are deterministic; image encoding belongs to the caller.
53
+ */
54
+ export declare function bakeScene(scene: Scene, view?: SceneView): SceneImage;
package/dist/export.js ADDED
@@ -0,0 +1,272 @@
1
+ import { requireValue } from "./required.js";
2
+ import { appendMesh, emptyMesh, meshForAsset } from "./mesh.js";
3
+ import { linear } from "./compat.js";
4
+ import { bakeMesh } from "./bake.js";
5
+ import { cross, dot, normalize } from "./math.js";
6
+ /** Version of the GLB layout and metadata written by `exportGLB`. */
7
+ export const GLB_EXPORT_VERSION = 1;
8
+ const DEFAULT_YAW = Math.atan2(24, -26), DEFAULT_PITCH = Math.atan2(28, Math.hypot(24, 26));
9
+ const hex = /^#[0-9a-fA-F]{6}$/;
10
+ const ARRAY_BUFFER = 34962, ELEMENT_ARRAY_BUFFER = 34963, FLOAT = 5126, UNSIGNED_SHORT = 5123, UNSIGNED_INT = 5125;
11
+ function items(scene) {
12
+ const list = Array.isArray(scene)
13
+ ? scene
14
+ : "layers" in scene
15
+ ? [{ asset: scene }]
16
+ : [scene];
17
+ if (!list.length)
18
+ throw new RangeError("Export requires at least one asset");
19
+ for (const item of list) {
20
+ if (!item || typeof item !== "object" || !item.asset || typeof item.asset.layers !== "object")
21
+ throw new TypeError("Export items require a compiled asset");
22
+ if (item.tint !== undefined && !hex.test(item.tint))
23
+ throw new RangeError("Tint must be #rrggbb");
24
+ if (item.offset !== undefined &&
25
+ (!Array.isArray(item.offset) || item.offset.length !== 3 || !item.offset.every(Number.isFinite)))
26
+ throw new RangeError("Offsets must be three finite numbers");
27
+ }
28
+ if (new Set(list.map((item) => item.asset.units)).size > 1)
29
+ throw new RangeError("Exported assets must share units; units are never converted");
30
+ return [...list];
31
+ }
32
+ /** Named meshes of one asset: an instance each, or a layer each for meshes without instances. */
33
+ function pieces(asset) {
34
+ const list = asset.instances.length
35
+ ? asset.instances.map((i) => ({ name: i.id, material: i.material, mesh: i.mesh }))
36
+ : Object.entries(asset.layers).map(([material, mesh]) => ({ name: material, material, mesh }));
37
+ return list.filter((piece) => piece.mesh.indices.length > 0);
38
+ }
39
+ /** Linear-space accent, rounded so that last-digit differences between engines cannot reach the file. */
40
+ const baseColor = (tint) => [
41
+ ...[1, 3, 5].map((i) => Math.round(linear(parseInt(tint.slice(i, i + 2), 16)) * 1e6) / 1e6),
42
+ 1,
43
+ ];
44
+ /**
45
+ * Experimental. Serialize a static scene as binary glTF 2.0 (GLB). The same scene always produces the
46
+ * same bytes: no timestamps or library versions are written.
47
+ *
48
+ * Each instance becomes a node and mesh named by its stable ID, with one material per asset material.
49
+ * Vertex colors are stored untinted in `COLOR_0`; a tintable material carries the tint as its
50
+ * `baseColorFactor`, which glTF multiplies with the vertex color. Geometry keeps the asset frame
51
+ * (+X right, +Y up, −Z front). A root node turns it 180° about +Y so the asset faces glTF's +Z front.
52
+ * Positions, normals and colors are stored as 32-bit floats. Units are recorded in `asset.extras` and
53
+ * never converted. Joints and clips are not exported: pass `poseAsset(…)` to export one frame.
54
+ */
55
+ export function exportGLB(scene, options = {}) {
56
+ const list = items(scene), name = options.name ?? "ludic-assembly";
57
+ if (typeof name !== "string")
58
+ throw new TypeError("Export name must be a string");
59
+ const chunks = [];
60
+ let byteLength = 0;
61
+ const bufferViews = [], accessors = [], meshes = [], materials = [], nodes = [{ name, rotation: [0, 1, 0, 0], children: [] }];
62
+ const view = (bytes, target) => {
63
+ chunks.push(bytes);
64
+ bufferViews.push({ buffer: 0, byteOffset: byteLength, byteLength: bytes.byteLength, target });
65
+ byteLength += bytes.byteLength;
66
+ const pad = (4 - (byteLength % 4)) % 4;
67
+ if (pad) {
68
+ chunks.push(new Uint8Array(pad));
69
+ byteLength += pad;
70
+ }
71
+ return bufferViews.length - 1;
72
+ };
73
+ const floats = (values, withBounds) => {
74
+ const data = new Float32Array(values.length), bytes = new Uint8Array(values.length * 4), dv = new DataView(bytes.buffer);
75
+ for (let i = 0; i < values.length; i++) {
76
+ if (!Number.isFinite(values[i]))
77
+ throw new RangeError("Export requires finite geometry");
78
+ data[i] = requireValue(values[i]);
79
+ dv.setFloat32(i * 4, requireValue(values[i]), true);
80
+ }
81
+ const accessor = {
82
+ bufferView: view(bytes, ARRAY_BUFFER),
83
+ componentType: FLOAT,
84
+ count: data.length / 3,
85
+ type: "VEC3",
86
+ };
87
+ if (withBounds) {
88
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
89
+ for (let i = 0; i < data.length; i++) {
90
+ min[i % 3] = Math.min(requireValue(min[i % 3]), requireValue(data[i]));
91
+ max[i % 3] = Math.max(requireValue(max[i % 3]), requireValue(data[i]));
92
+ }
93
+ accessor.min = min;
94
+ accessor.max = max;
95
+ }
96
+ accessors.push(accessor);
97
+ return accessors.length - 1;
98
+ };
99
+ let primitives = 0;
100
+ for (const [index, item] of list.entries()) {
101
+ const asset = item.asset, itemName = item.name ?? (list.length > 1 ? `asset/${index}` : "asset"), materialIndex = new Map(), children = [];
102
+ const extras = { schema: asset.schema, sourceHash: asset.sourceHash };
103
+ if (asset.identity)
104
+ extras.identity = asset.identity;
105
+ if (asset.slots)
106
+ extras.slots = asset.slots;
107
+ if (asset.pose)
108
+ extras.pose = asset.pose;
109
+ if (item.tint)
110
+ extras.tint = item.tint.toLowerCase();
111
+ const node = { name: itemName };
112
+ if (item.offset?.some((v) => v !== 0))
113
+ node.translation = [...item.offset];
114
+ node.children = children;
115
+ node.extras = extras;
116
+ nodes.push(node);
117
+ requireValue(nodes[0]).children.push(nodes.length - 1);
118
+ for (const piece of pieces(asset)) {
119
+ const { positions, normals, colors, indices } = piece.mesh, count = positions.length / 3;
120
+ if (!Number.isInteger(count) ||
121
+ normals.length !== positions.length ||
122
+ colors.length !== positions.length ||
123
+ indices.length % 3 ||
124
+ indices.some((i) => !Number.isInteger(i) || i < 0 || i >= count))
125
+ throw new RangeError(`Mesh ${piece.name} has inconsistent attributes`);
126
+ if (!materialIndex.has(piece.material)) {
127
+ const tintable = asset.materials[piece.material]?.tintable === true;
128
+ materials.push({
129
+ name: list.length > 1 ? `${itemName}/${piece.material}` : piece.material,
130
+ pbrMetallicRoughness: {
131
+ baseColorFactor: tintable && item.tint ? baseColor(item.tint) : [1, 1, 1, 1],
132
+ metallicFactor: 0,
133
+ roughnessFactor: 1,
134
+ },
135
+ extras: { tintable },
136
+ });
137
+ materialIndex.set(piece.material, materials.length - 1);
138
+ }
139
+ const position = floats(positions, true), normal = floats(normals, false), color = floats(colors, false);
140
+ // The largest index value of a component type is reserved (glTF 2.0, 3.7.2.1).
141
+ const wide = count > 65535, width = wide ? 4 : 2, bytes = new Uint8Array(indices.length * width), dv = new DataView(bytes.buffer);
142
+ for (let i = 0; i < indices.length; i++)
143
+ if (wide)
144
+ dv.setUint32(i * 4, requireValue(indices[i]), true);
145
+ else
146
+ dv.setUint16(i * 2, requireValue(indices[i]), true);
147
+ accessors.push({
148
+ bufferView: view(bytes, ELEMENT_ARRAY_BUFFER),
149
+ componentType: wide ? UNSIGNED_INT : UNSIGNED_SHORT,
150
+ count: indices.length,
151
+ type: "SCALAR",
152
+ });
153
+ meshes.push({
154
+ name: piece.name,
155
+ primitives: [
156
+ {
157
+ attributes: { POSITION: position, NORMAL: normal, COLOR_0: color },
158
+ indices: accessors.length - 1,
159
+ material: materialIndex.get(piece.material),
160
+ },
161
+ ],
162
+ });
163
+ // Some importers sanitize names (three.js drops "/"); extras keep the exact stable ID.
164
+ nodes.push({ name: piece.name, mesh: meshes.length - 1, extras: { id: piece.name } });
165
+ children.push(nodes.length - 1);
166
+ primitives++;
167
+ }
168
+ }
169
+ if (!primitives)
170
+ throw new RangeError("Export requires nonempty geometry");
171
+ const json = {
172
+ asset: {
173
+ version: "2.0",
174
+ generator: "Ludic Assembly",
175
+ extras: {
176
+ export: GLB_EXPORT_VERSION,
177
+ units: requireValue(list[0]).asset.units,
178
+ frame: "+X right, +Y up, -Z front",
179
+ },
180
+ },
181
+ scene: 0,
182
+ scenes: [{ name, nodes: [0] }],
183
+ nodes,
184
+ meshes,
185
+ materials,
186
+ accessors,
187
+ bufferViews,
188
+ buffers: [{ byteLength }],
189
+ };
190
+ const text = new TextEncoder().encode(JSON.stringify(json)), jsonLength = Math.ceil(text.length / 4) * 4, total = 12 + 8 + jsonLength + 8 + byteLength, out = new Uint8Array(total), dv = new DataView(out.buffer);
191
+ dv.setUint32(0, 0x46546c67, true);
192
+ dv.setUint32(4, 2, true);
193
+ dv.setUint32(8, total, true);
194
+ dv.setUint32(12, jsonLength, true);
195
+ dv.setUint32(16, 0x4e4f534a, true);
196
+ out.set(text, 20);
197
+ out.fill(0x20, 20 + text.length, 20 + jsonLength);
198
+ let at = 20 + jsonLength;
199
+ dv.setUint32(at, byteLength, true);
200
+ dv.setUint32(at + 4, 0x004e4942, true);
201
+ at += 8;
202
+ for (const chunk of chunks) {
203
+ out.set(chunk, at);
204
+ at += chunk.byteLength;
205
+ }
206
+ return out;
207
+ }
208
+ /**
209
+ * Experimental. Bake a square, transparent image of a scene with the CPU rasterizer used by previews,
210
+ * framed to its geometry or to `fit`. Pixel bytes are deterministic; image encoding belongs to the caller.
211
+ */
212
+ export function bakeScene(scene, view = {}) {
213
+ const list = items(scene), size = view.size ?? 1024, samples = view.samples ?? 2, padding = view.padding ?? Math.round(size / 22.5), yaw = view.yaw ?? DEFAULT_YAW, pitch = view.pitch ?? DEFAULT_PITCH;
214
+ if (!Number.isInteger(size) || size < 64 || size > 2048)
215
+ throw new RangeError("Scene images are 64–2048 pixels");
216
+ if (!Number.isInteger(samples) || samples < 1 || samples > 4)
217
+ throw new RangeError("Samples must be an integer 1–4");
218
+ if (!Number.isInteger(padding) || padding < 0 || padding * 2 >= size)
219
+ throw new RangeError("Padding must leave room for the image");
220
+ if (!Number.isFinite(yaw) || !Number.isFinite(pitch))
221
+ throw new RangeError("Camera must be finite");
222
+ const mesh = emptyMesh();
223
+ for (const item of list)
224
+ appendMesh(mesh, meshForAsset(item.asset, item.tint), item.offset ?? [0, 0, 0]);
225
+ if (!mesh.indices.length)
226
+ throw new RangeError("Export requires nonempty geometry");
227
+ const back = [Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)], right = normalize(cross([0, 1, 0], back)), basis = { back, right, up: cross(back, right) };
228
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
229
+ const extend = (p) => {
230
+ const x = dot(p, right), y = dot(p, basis.up);
231
+ minX = Math.min(minX, x);
232
+ maxX = Math.max(maxX, x);
233
+ minY = Math.min(minY, y);
234
+ maxY = Math.max(maxY, y);
235
+ };
236
+ const fit = view.fit;
237
+ if (fit) {
238
+ if (![...fit.min, ...fit.max].every(Number.isFinite))
239
+ throw new RangeError("Fit bounds must be finite");
240
+ for (const x of [fit.min[0], fit.max[0]])
241
+ for (const y of [fit.min[1], fit.max[1]])
242
+ for (const z of [fit.min[2], fit.max[2]])
243
+ extend([x, y, z]);
244
+ }
245
+ else
246
+ for (let i = 0; i < mesh.positions.length; i += 3)
247
+ extend(mesh.positions.slice(i, i + 3));
248
+ const extent = Math.max(maxX - minX, maxY - minY);
249
+ if (!Number.isFinite(extent) || extent <= 0)
250
+ throw new RangeError("Scene requires nonempty bounds");
251
+ const ppu = (size - padding * 2) / extent, viewport = fit
252
+ ? {
253
+ left: -Math.round((size - (maxX - minX) * ppu) / 2 - minX * ppu),
254
+ top: Math.round((size - (maxY - minY) * ppu) / 2 + maxY * ppu),
255
+ width: size,
256
+ height: size,
257
+ }
258
+ : null, frame = bakeMesh(mesh, ppu, samples, basis, viewport), ox = viewport ? Math.round(frame.left * ppu) - viewport.left : Math.floor((size - frame.width) / 2), oy = viewport ? viewport.top - Math.round(frame.top * ppu) : Math.floor((size - frame.height) / 2), color = new Uint8Array(size * size * 4);
259
+ for (let y = 0; y < frame.height; y++) {
260
+ const py = oy + y;
261
+ if (py < 0 || py >= size)
262
+ continue;
263
+ for (let x = 0; x < frame.width; x++) {
264
+ const px = ox + x;
265
+ if (px < 0 || px >= size)
266
+ continue;
267
+ const from = (x + y * frame.width) * 4;
268
+ color.set(frame.color.subarray(from, from + 4), (px + py * size) * 4);
269
+ }
270
+ }
271
+ return { width: size, height: size, color };
272
+ }
@@ -0,0 +1,4 @@
1
+ export type * from "./types.js";
2
+ export { SCHEMA, SCHEMA_V2, SCHEMA_V3, MAX_ARRAY_COUNT, ANCHORS, AXES, AssetCompileError, compileAsset, validateAsset, } from "./compiler.js";
3
+ export { appendMesh, emptyMesh, meshBounds, validateMesh, canonicalJSON, sourceHash, meshForAsset, } from "./mesh.js";
4
+ export { MOTION_SCHEMA, CONTACT_TOLERANCE, SLIP_TOLERANCE, VERIFICATION_SAMPLES, MEASURED_CODES, compileMotion, checkMotion, validateMotion, samplePose, poseAsset, posedMesh, seatOn, } from "./motion.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { SCHEMA, SCHEMA_V2, SCHEMA_V3, MAX_ARRAY_COUNT, ANCHORS, AXES, AssetCompileError, compileAsset, validateAsset, } from "./compiler.js";
2
+ export { appendMesh, emptyMesh, meshBounds, validateMesh, canonicalJSON, sourceHash, meshForAsset, } from "./mesh.js";
3
+ export { MOTION_SCHEMA, CONTACT_TOLERANCE, SLIP_TOLERANCE, VERIFICATION_SAMPLES, MEASURED_CODES, compileMotion, checkMotion, validateMotion, samplePose, poseAsset, posedMesh, seatOn, } from "./motion.js";
@@ -0,0 +1,36 @@
1
+ import type { Axis, CompiledAsset, MeshData, Vec3 } from "./types.js";
2
+ /** How one legacy per-vertex joint ID maps onto a schema 0.2 joint. */
3
+ export interface LegacyJointMapping {
4
+ id: string;
5
+ /**
6
+ * Map two legacy IDs onto one sided joint `<id>/left` and `<id>/right` (right axes mirrored, as in
7
+ * schema 0.2). Both entries must declare the same parent, pivot kind and DOFs.
8
+ */
9
+ side?: "left" | "right";
10
+ parent?: string;
11
+ /**
12
+ * `height` places the hinge line at Y = `mesh.rigPivots[height]`, Z = 0 (the legacy convention);
13
+ * X is the joint's vertex bounds center. `position` gives the left or unsided pivot explicitly.
14
+ */
15
+ pivot: {
16
+ height: 0 | 1;
17
+ } | {
18
+ position: Vec3;
19
+ };
20
+ dofs: {
21
+ name: string;
22
+ axis: Axis;
23
+ limits: [number, number];
24
+ }[];
25
+ }
26
+ export interface LegacyRig {
27
+ units?: "meters" | "tiles";
28
+ /** Every legacy joint ID present in the mesh must be listed; null keeps its vertices static. */
29
+ joints: Record<number, LegacyJointMapping | null>;
30
+ }
31
+ /**
32
+ * Wrap a compatibility mesh that carries per-vertex `joints` (and optionally `rigPivots`) as a rigged
33
+ * schema 0.2 compilation, so motion sets, sampling, posing, previews and bakes apply to it.
34
+ * The host owns the mapping: what each legacy joint ID and pivot height means is host knowledge.
35
+ */
36
+ export declare function fromLegacyRig(mesh: MeshData, rig: LegacyRig): CompiledAsset;
package/dist/legacy.js ADDED
@@ -0,0 +1,180 @@
1
+ import { requireValue } from "./required.js";
2
+ import { AssetCompileError } from "./diagnostics.js";
3
+ import { appendMesh, emptyMesh, meshBounds, sourceHash } from "./mesh.js";
4
+ const AXES = {
5
+ "+x": [1, 0, 0],
6
+ "-x": [-1, 0, 0],
7
+ "+y": [0, 1, 0],
8
+ "-y": [0, -1, 0],
9
+ "+z": [0, 0, 1],
10
+ "-z": [0, 0, -1],
11
+ };
12
+ /**
13
+ * Wrap a compatibility mesh that carries per-vertex `joints` (and optionally `rigPivots`) as a rigged
14
+ * schema 0.2 compilation, so motion sets, sampling, posing, previews and bakes apply to it.
15
+ * The host owns the mapping: what each legacy joint ID and pivot height means is host knowledge.
16
+ */
17
+ export function fromLegacyRig(mesh, rig) {
18
+ const fail = (code, path, message, extra = {}) => {
19
+ throw new AssetCompileError([{ code, path, message, ...extra }]);
20
+ };
21
+ if (!mesh.joints || mesh.joints.length !== mesh.positions.length / 3)
22
+ fail("UNRIGGED_ASSET", "/joints", "The mesh needs one legacy joint ID per vertex.");
23
+ const mapped = Object.keys(rig.joints).map(Number), declared = new Map(), refs = new Map();
24
+ for (const key of mapped) {
25
+ const m = rig.joints[key];
26
+ if (!m)
27
+ continue;
28
+ const path = `/joints/${key}`, ref = m.side ? `${m.id}/${m.side}` : m.id;
29
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,79}$/.test(m.id))
30
+ fail("SCHEMA_ERROR", `${path}/id`, "Invalid joint ID.");
31
+ if (m.id === "static" || [...refs.values()].includes(ref))
32
+ fail("DUPLICATE_NAME", `${path}/id`, "Each joint and side is mapped once; static is reserved.");
33
+ if (!m.dofs.length || m.dofs.length > 3)
34
+ fail("SCHEMA_ERROR", `${path}/dofs`, "Expected 1–3 DOFs.");
35
+ m.dofs.forEach((d, i) => {
36
+ if (!Object.hasOwn(AXES, d.axis))
37
+ fail("UNKNOWN_AXIS", `${path}/dofs/${i}/axis`, "Choose a signed axis.", {
38
+ received: d.axis,
39
+ expected: Object.keys(AXES),
40
+ });
41
+ const [lo, hi] = d.limits;
42
+ if (!(lo < hi) || lo > 0 || hi < 0 || lo < -360 || hi > 360)
43
+ fail("JOINT_LIMIT", `${path}/dofs/${i}/limits`, "Limits must contain the rest value 0.", {
44
+ received: d.limits,
45
+ });
46
+ });
47
+ if ("height" in m.pivot && !mesh.rigPivots)
48
+ fail("SCHEMA_ERROR", `${path}/pivot`, "Height pivots need mesh.rigPivots.");
49
+ const other = declared.get(m.id);
50
+ if (other) {
51
+ const shape = (x) => JSON.stringify([x.parent, "height" in x.pivot ? "height" : "position", x.dofs, !!x.side]);
52
+ if (!m.side || !other.side || shape(other) !== shape(m))
53
+ fail("SCHEMA_ERROR", path, "Two legacy IDs share a joint only as its left and right sides, with the same parent, pivot kind and DOFs.");
54
+ }
55
+ else
56
+ declared.set(m.id, m);
57
+ refs.set(key, ref);
58
+ }
59
+ for (const [id, m] of declared) {
60
+ if (m.side && ![...refs.values()].includes(`${id}/${m.side === "left" ? "right" : "left"}`))
61
+ fail("SCHEMA_ERROR", "/joints", `Sided joint ${id} needs both a left and a right legacy ID.`);
62
+ if (m.parent !== undefined && !declared.has(m.parent))
63
+ fail("UNKNOWN_JOINT", "/joints", "Unknown parent joint.", {
64
+ received: m.parent,
65
+ expected: [...declared.keys()].sort(),
66
+ });
67
+ }
68
+ // Group triangles by legacy joint ID, preserving triangle order.
69
+ const groups = new Map();
70
+ for (let t = 0; t < mesh.indices.length; t += 3) {
71
+ const legacy = requireValue(requireValue(mesh.joints)[requireValue(mesh.indices[t])]);
72
+ if (!Object.hasOwn(rig.joints, legacy))
73
+ fail("UNKNOWN_JOINT", "/joints", `Legacy joint ID ${legacy} is present in the mesh but not mapped.`, {
74
+ received: legacy,
75
+ expected: mapped.map(String),
76
+ });
77
+ const id = refs.get(legacy) ?? "static";
78
+ if (!groups.has(id))
79
+ groups.set(id, { legacy, triangles: [] });
80
+ requireValue(groups.get(id)).triangles.push(t);
81
+ }
82
+ const instances = [];
83
+ for (const [id, group] of [...groups].sort(([a], [b]) => (a < b ? -1 : 1))) {
84
+ const out = { positions: [], normals: [], colors: [], indices: [] }, remap = new Map();
85
+ for (const t of group.triangles)
86
+ for (const index of mesh.indices.slice(t, t + 3)) {
87
+ if (!remap.has(index)) {
88
+ remap.set(index, remap.size);
89
+ out.positions.push(...mesh.positions.slice(index * 3, index * 3 + 3));
90
+ out.normals.push(...mesh.normals.slice(index * 3, index * 3 + 3));
91
+ out.colors.push(...mesh.colors.slice(index * 3, index * 3 + 3));
92
+ }
93
+ out.indices.push(requireValue(remap.get(index)));
94
+ }
95
+ const bounds = meshBounds(out), c = bounds.min.map((v, i) => (v + requireValue(bounds.max[i])) / 2);
96
+ instances.push({
97
+ id,
98
+ part: requireValue(id.split("/")[0]),
99
+ material: "legacy",
100
+ translation: [0, 0, 0],
101
+ bounds,
102
+ anchors: {
103
+ center: c,
104
+ "top-center": [c[0], bounds.max[1], c[2]],
105
+ "bottom-center": [c[0], bounds.min[1], c[2]],
106
+ "left-center": [bounds.min[0], c[1], c[2]],
107
+ "right-center": [bounds.max[0], c[1], c[2]],
108
+ "front-center": [c[0], c[1], bounds.min[2]],
109
+ "back-center": [c[0], c[1], bounds.max[2]],
110
+ },
111
+ mesh: out,
112
+ joint: id === "static" ? null : id,
113
+ });
114
+ }
115
+ const compiled = new Map(), stack = new Set();
116
+ const build = (ref) => {
117
+ if (compiled.has(ref))
118
+ return requireValue(compiled.get(ref));
119
+ if (stack.has(ref))
120
+ return fail("DEPENDENCY_CYCLE", "/joints", "Cyclic joint hierarchy.", { received: ref });
121
+ stack.add(ref);
122
+ const [base, side] = ref.split("/"), m = requireValue(declared.get(base));
123
+ let parent = null;
124
+ if (m.parent !== undefined) {
125
+ parent = requireValue(declared.get(m.parent)).side ? `${m.parent}/${side ?? "left"}` : m.parent;
126
+ build(parent);
127
+ }
128
+ const b = instances.find((i) => i.id === ref)?.bounds;
129
+ let pivot;
130
+ if ("position" in m.pivot) {
131
+ const [x, y, z] = m.pivot.position;
132
+ pivot = [side === "right" && x !== 0 ? -x : x, y, z];
133
+ }
134
+ else
135
+ pivot = [b ? (b.min[0] + b.max[0]) / 2 : 0, requireValue(mesh.rigPivots)[m.pivot.height], 0];
136
+ stack.delete(ref);
137
+ const joint = {
138
+ id: ref,
139
+ joint: base,
140
+ ...(side ? { side } : {}),
141
+ parent,
142
+ pivot,
143
+ dofs: m.dofs.map((d) => {
144
+ const a = AXES[d.axis];
145
+ return {
146
+ name: d.name,
147
+ axis: side === "right" ? [a[0], a[1] === 0 ? 0 : -a[1], a[2] === 0 ? 0 : -a[2]] : [...a],
148
+ limits: [d.limits[0], d.limits[1]],
149
+ };
150
+ }),
151
+ };
152
+ compiled.set(ref, joint);
153
+ return joint;
154
+ };
155
+ for (const ref of [...new Set(refs.values())].sort())
156
+ build(ref);
157
+ const depth = (j) => j.parent === null ? 0 : 1 + depth(requireValue(compiled.get(j.parent)));
158
+ const joints = [...compiled.values()].sort((a, b) => depth(a) - depth(b) || (a.id < b.id ? -1 : 1)), index = new Map(joints.map((j, i) => [j.id, i]));
159
+ const layer = emptyMesh();
160
+ for (const instance of instances) {
161
+ instance.mesh.joints = new Array(instance.mesh.positions.length / 3).fill(instance.joint ? (index.get(instance.joint) ?? -1) : -1);
162
+ appendMesh(layer, instance.mesh);
163
+ }
164
+ return {
165
+ schema: "asset-assembly/0.2",
166
+ units: rig.units ?? "tiles",
167
+ sourceHash: sourceHash({ legacyRig: rig, mesh }),
168
+ layers: { legacy: layer },
169
+ materials: { legacy: { color: "#ffffff" } },
170
+ instances,
171
+ bounds: meshBounds(layer),
172
+ groundPivot: [0, 0, 0],
173
+ triangles: layer.indices.length / 3,
174
+ skeleton: { joints },
175
+ identity: null,
176
+ slots: {},
177
+ states: {},
178
+ alternates: [],
179
+ };
180
+ }
package/dist/math.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { Transform, Vec3 } from "./types.js";
2
+ export declare const add: (a: Vec3, b: Vec3) => Vec3;
3
+ export declare const sub: (a: Vec3, b: Vec3) => Vec3;
4
+ export declare const scale: (a: Vec3, n: number) => Vec3;
5
+ export declare const dot: (a: Vec3, b: Vec3) => number;
6
+ export declare const cross: (a: Vec3, b: Vec3) => Vec3;
7
+ export declare const normalize: (a: Vec3) => Vec3;
8
+ /** Fixed output precision shared by declarative geometry and sampled transforms. */
9
+ export declare const quantize: (v: number) => number;
10
+ export declare const IDENTITY: Transform;
11
+ /** Right-handed rotation about a unit axis by degrees (Rodrigues). */
12
+ export declare function rotation(axis: Vec3, degrees: number): Transform;
13
+ export declare function compose(a: Transform, b: Transform): Transform;
14
+ export declare const translation: (v: Vec3) => Transform;
15
+ export declare const applyPoint: (m: Transform, p: Vec3) => Vec3;
16
+ export declare const applyVector: (m: Transform, p: Vec3) => Vec3;
package/dist/math.js ADDED
@@ -0,0 +1,62 @@
1
+ import { requireValue } from "./required.js";
2
+ export const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
3
+ export const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
4
+ export const scale = (a, n) => [a[0] * n, a[1] * n, a[2] * n];
5
+ export const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
6
+ export const cross = (a, b) => [
7
+ a[1] * b[2] - a[2] * b[1],
8
+ a[2] * b[0] - a[0] * b[2],
9
+ a[0] * b[1] - a[1] * b[0],
10
+ ];
11
+ export const normalize = (a) => scale(a, 1 / Math.max(1e-12, Math.hypot(...a)));
12
+ /** Fixed output precision shared by declarative geometry and sampled transforms. */
13
+ export const quantize = (v) => {
14
+ const q = Math.round(v * 1e12) / 1e12;
15
+ return q === 0 ? 0 : q;
16
+ };
17
+ export const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0];
18
+ /** Right-handed rotation about a unit axis by degrees (Rodrigues). */
19
+ export function rotation(axis, degrees) {
20
+ const r = (degrees * Math.PI) / 180, c = Math.cos(r), s = Math.sin(r), t = 1 - c, [x, y, z] = axis;
21
+ return [
22
+ c + x * x * t,
23
+ x * y * t - z * s,
24
+ x * z * t + y * s,
25
+ 0,
26
+ y * x * t + z * s,
27
+ c + y * y * t,
28
+ y * z * t - x * s,
29
+ 0,
30
+ z * x * t - y * s,
31
+ z * y * t + x * s,
32
+ c + z * z * t,
33
+ 0,
34
+ ];
35
+ }
36
+ export function compose(a, b) {
37
+ const out = new Array(12);
38
+ for (let row = 0; row < 3; row++) {
39
+ for (let col = 0; col < 3; col++)
40
+ out[row * 4 + col] =
41
+ requireValue(a[row * 4]) * requireValue(b[col]) +
42
+ requireValue(a[row * 4 + 1]) * requireValue(b[4 + col]) +
43
+ requireValue(a[row * 4 + 2]) * requireValue(b[8 + col]);
44
+ out[row * 4 + 3] =
45
+ requireValue(a[row * 4]) * requireValue(b[3]) +
46
+ requireValue(a[row * 4 + 1]) * requireValue(b[7]) +
47
+ requireValue(a[row * 4 + 2]) * requireValue(b[11]) +
48
+ requireValue(a[row * 4 + 3]);
49
+ }
50
+ return out;
51
+ }
52
+ export const translation = (v) => [1, 0, 0, v[0], 0, 1, 0, v[1], 0, 0, 1, v[2]];
53
+ export const applyPoint = (m, p) => [
54
+ requireValue(m[0]) * p[0] + requireValue(m[1]) * p[1] + requireValue(m[2]) * p[2] + requireValue(m[3]),
55
+ requireValue(m[4]) * p[0] + requireValue(m[5]) * p[1] + requireValue(m[6]) * p[2] + requireValue(m[7]),
56
+ requireValue(m[8]) * p[0] + requireValue(m[9]) * p[1] + requireValue(m[10]) * p[2] + requireValue(m[11]),
57
+ ];
58
+ export const applyVector = (m, p) => [
59
+ requireValue(m[0]) * p[0] + requireValue(m[1]) * p[1] + requireValue(m[2]) * p[2],
60
+ requireValue(m[4]) * p[0] + requireValue(m[5]) * p[1] + requireValue(m[6]) * p[2],
61
+ requireValue(m[8]) * p[0] + requireValue(m[9]) * p[1] + requireValue(m[10]) * p[2],
62
+ ];
package/dist/mesh.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Bounds, CompiledAsset, MeshData, Vec3 } from "./types.js";
2
+ export declare function meshForAsset(asset: CompiledAsset, tint?: string): MeshData;
3
+ export declare const emptyMesh: () => MeshData;
4
+ export declare function appendMesh(target: MeshData, source: MeshData, offset?: Vec3): MeshData;
5
+ export declare function meshBounds(mesh: MeshData): Bounds;
6
+ /** Remove collapsed bevel bands without changing legacy MeshBuilder output. */
7
+ export declare function removeDegenerateTriangles(mesh: MeshData): MeshData;
8
+ export declare function validateMesh(mesh: MeshData): string[];
9
+ export declare function canonicalJSON(value: unknown): string;
10
+ /** Portable SHA-256 over canonical JSON. No Node, browser or asynchronous crypto dependency. */
11
+ export declare function sourceHash(value: unknown): string;