@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.
- package/CHANGELOG.md +78 -0
- package/LICENSE +2 -0
- package/README.md +50 -2
- package/dist/bake.d.ts +80 -0
- package/dist/bake.js +214 -0
- package/dist/browser.d.ts +89 -0
- package/dist/browser.js +534 -0
- package/dist/compat.d.ts +17 -0
- package/dist/compat.js +153 -0
- package/dist/compiler.d.ts +18 -0
- package/dist/compiler.js +1264 -0
- package/dist/diagnostics.d.ts +5 -0
- package/dist/diagnostics.js +8 -0
- package/dist/export.d.ts +54 -0
- package/dist/export.js +272 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/legacy.d.ts +36 -0
- package/dist/legacy.js +180 -0
- package/dist/math.d.ts +16 -0
- package/dist/math.js +62 -0
- package/dist/mesh.d.ts +11 -0
- package/dist/mesh.js +153 -0
- package/dist/motion.d.ts +63 -0
- package/dist/motion.js +1476 -0
- package/dist/pixel.d.ts +268 -0
- package/dist/pixel.js +942 -0
- package/dist/required.d.ts +2 -0
- package/dist/required.js +6 -0
- package/dist/shapes.d.ts +71 -0
- package/dist/shapes.js +396 -0
- package/dist/sprite-brief.d.ts +5 -0
- package/dist/sprite-brief.js +47 -0
- package/dist/sprite-compiler.d.ts +62 -0
- package/dist/sprite-compiler.js +1745 -0
- package/dist/sprite-decompile.d.ts +68 -0
- package/dist/sprite-decompile.js +786 -0
- package/dist/sprite-format.d.ts +4 -0
- package/dist/sprite-format.js +155 -0
- package/dist/sprite-guide.d.ts +40 -0
- package/dist/sprite-guide.js +330 -0
- package/dist/sprite-scene.d.ts +277 -0
- package/dist/sprite-scene.js +664 -0
- package/dist/sprite-types.d.ts +110 -0
- package/dist/sprite-types.js +1 -0
- package/dist/sprite.d.ts +8 -0
- package/dist/sprite.js +8 -0
- package/dist/svg/schema.d.ts +71 -0
- package/dist/svg/schema.js +217 -0
- package/dist/svg/types.d.ts +148 -0
- package/dist/svg/types.js +1 -0
- package/dist/svg.d.ts +26 -0
- package/dist/svg.js +539 -0
- package/dist/text.d.ts +12 -0
- package/dist/text.js +75 -0
- package/dist/types.d.ts +478 -0
- package/dist/types.js +1 -0
- package/docs/svg/README.md +150 -0
- package/docs/svg/specification.md +108 -0
- package/examples/svg/arcane-shield.json +261 -0
- package/examples/svg/aurora-ribbon.json +158 -0
- package/examples/svg/bouncing-ember.json +76 -0
- package/examples/svg/ember-fountain.json +224 -0
- package/examples/svg/lantern-keeper.json +1159 -0
- package/examples/svg/night-workshop.json +1120 -0
- package/examples/svg/waygate-platform.json +64 -0
- package/examples/svg/waygate-spark.json +92 -0
- package/examples/svg/waygate-wisp.json +105 -0
- package/examples/svg/waygate.json +485 -0
- package/package.json +132 -4
- package/schemas/asset-assembly-0.1.schema.json +354 -0
- package/schemas/asset-assembly-0.2.schema.json +429 -0
- package/schemas/asset-assembly-0.3.schema.json +572 -0
- package/schemas/motion-set-0.1.schema.json +178 -0
- package/schemas/pixel-sprite-0.1.schema.json +892 -0
- package/schemas/pixel-sprite-scene-0.1.schema.json +149 -0
- package/schemas/svg-asset-0.1.schema.json +1157 -0
- package/tools/svg-asset.mjs +106 -0
package/dist/compiler.js
ADDED
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
import { requireValue } from "./required.js";
|
|
2
|
+
import { MeshBuilder } from "./compat.js";
|
|
3
|
+
import { appendMesh, emptyMesh, meshBounds, removeDegenerateTriangles, sourceHash, validateMesh, } from "./mesh.js";
|
|
4
|
+
import { add, applyPoint, applyVector, compose, quantize, sub, translation as moveBy } from "./math.js";
|
|
5
|
+
import { DEFAULT_SIDES, DEFAULT_SPHERE_SIDES, MAX_SIDES, MIN_SIDES, alongSegment, axisRotation, coil, legacyCoil, cone, ellipsoid, eulerRotation, extrude, frustum, isIdentity, lathe, orientProfile, outlineArea, outlineSelfIntersects, profileSelfIntersects, transformMesh, wedge, } from "./shapes.js";
|
|
6
|
+
import { AssetCompileError } from "./diagnostics.js";
|
|
7
|
+
export { AssetCompileError };
|
|
8
|
+
export const SCHEMA = "asset-assembly/0.1";
|
|
9
|
+
/** Characters: joints, mirrored sides, slots, identities, states and limbs. */
|
|
10
|
+
export const SCHEMA_V2 = "asset-assembly/0.2";
|
|
11
|
+
/** Schema 0.2 plus rotations, arrays, sided round shapes, spheres, cones, wedges, lathes and extrusions. */
|
|
12
|
+
export const SCHEMA_V3 = "asset-assembly/0.3";
|
|
13
|
+
export const AXES = {
|
|
14
|
+
"+x": [1, 0, 0],
|
|
15
|
+
"-x": [-1, 0, 0],
|
|
16
|
+
"+y": [0, 1, 0],
|
|
17
|
+
"-y": [0, -1, 0],
|
|
18
|
+
"+z": [0, 0, 1],
|
|
19
|
+
"-z": [0, 0, -1],
|
|
20
|
+
};
|
|
21
|
+
export const ANCHORS = [
|
|
22
|
+
"center",
|
|
23
|
+
"top-center",
|
|
24
|
+
"bottom-center",
|
|
25
|
+
"left-center",
|
|
26
|
+
"right-center",
|
|
27
|
+
"front-center",
|
|
28
|
+
"back-center",
|
|
29
|
+
];
|
|
30
|
+
const SHAPE_FIELDS = {
|
|
31
|
+
box: ["size", "bevel"],
|
|
32
|
+
cylinder: ["radius", "height"],
|
|
33
|
+
funnel: ["bottomRadius", "topRadius", "height"],
|
|
34
|
+
beam: ["from", "to", "width"],
|
|
35
|
+
tube: ["from", "to", "radius"],
|
|
36
|
+
coil: ["radius", "thickness"],
|
|
37
|
+
};
|
|
38
|
+
const SHAPE_FIELDS_V3 = {
|
|
39
|
+
...SHAPE_FIELDS,
|
|
40
|
+
cylinder: ["radius", "height", "sides"],
|
|
41
|
+
funnel: ["bottomRadius", "topRadius", "height", "sides"],
|
|
42
|
+
tube: ["from", "to", "radius", "sides"],
|
|
43
|
+
coil: ["radius", "thickness", "sides"],
|
|
44
|
+
sphere: ["radius", "sides"],
|
|
45
|
+
ellipsoid: ["size", "sides"],
|
|
46
|
+
cone: ["radius", "height", "sides"],
|
|
47
|
+
wedge: ["size"],
|
|
48
|
+
lathe: ["profile", "sides"],
|
|
49
|
+
extrude: ["outline", "height"],
|
|
50
|
+
};
|
|
51
|
+
/** Largest number of instances one array may create. */
|
|
52
|
+
export const MAX_ARRAY_COUNT = 256;
|
|
53
|
+
const pointer = (s) => s.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
54
|
+
/** A string value as itself; anything else as "", without coercion (which can throw or alias). */
|
|
55
|
+
const text = (v) => (typeof v === "string" ? v : "");
|
|
56
|
+
const strings = (v) => v.filter((o) => typeof o === "string");
|
|
57
|
+
const record = (v) => !!v &&
|
|
58
|
+
typeof v === "object" &&
|
|
59
|
+
!Array.isArray(v) &&
|
|
60
|
+
(Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
61
|
+
const has = (v, k) => Object.hasOwn(v, k);
|
|
62
|
+
const idPattern = /^[A-Za-z][A-Za-z0-9_-]{0,79}$/;
|
|
63
|
+
/** Strict runtime validation. Unknown fields fail instead of being silently ignored. */
|
|
64
|
+
function schemaDiagnostics(input) {
|
|
65
|
+
const diagnostics = [];
|
|
66
|
+
const fail = (path, message, code = "SCHEMA_ERROR", received) => diagnostics.push({ code, path, message, ...(received === undefined ? {} : { received }) });
|
|
67
|
+
const obj = (v, path, allowed, required = []) => {
|
|
68
|
+
if (!record(v)) {
|
|
69
|
+
fail(path, "Expected a plain JSON object.");
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
for (const k of Object.keys(v))
|
|
73
|
+
if (!allowed.includes(k))
|
|
74
|
+
fail(`${path}/${pointer(k)}`, "Unknown field or unsupported operation.", "UNKNOWN_FIELD");
|
|
75
|
+
for (const k of required)
|
|
76
|
+
if (!has(v, k))
|
|
77
|
+
fail(`${path}/${k}`, "Required field is missing.");
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
const v3 = record(input) && input.schema === SCHEMA_V3, v2 = v3 || (record(input) && input.schema === SCHEMA_V2);
|
|
81
|
+
if (!obj(input, "", [
|
|
82
|
+
"schema",
|
|
83
|
+
"units",
|
|
84
|
+
"parameters",
|
|
85
|
+
"materials",
|
|
86
|
+
"groups",
|
|
87
|
+
"parts",
|
|
88
|
+
"groundPivot",
|
|
89
|
+
...(v2 ? ["joints", "slots", "identities", "states", "limbs"] : []),
|
|
90
|
+
], ["schema", "units", "materials", "parts"]))
|
|
91
|
+
return diagnostics;
|
|
92
|
+
const root = input;
|
|
93
|
+
if (root.schema !== SCHEMA && !v2)
|
|
94
|
+
fail("/schema", `Expected ${SCHEMA}, ${SCHEMA_V2} or ${SCHEMA_V3}.`, "UNSUPPORTED_SCHEMA", root.schema);
|
|
95
|
+
if (!["meters", "tiles"].includes(text(root.units)))
|
|
96
|
+
fail("/units", "Choose meters or tiles.");
|
|
97
|
+
const params = record(root.parameters) ? root.parameters : {};
|
|
98
|
+
const scalar = (v, path) => {
|
|
99
|
+
if (typeof v === "number") {
|
|
100
|
+
if (!Number.isFinite(v))
|
|
101
|
+
fail(path, "Number must be finite.", "NON_FINITE_VALUE");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (obj(v, path, ["parameter"], ["parameter"])) {
|
|
105
|
+
const name = v.parameter;
|
|
106
|
+
if (typeof name !== "string" || !has(params, name))
|
|
107
|
+
fail(`${path}/parameter`, "Unknown scalar parameter.", "UNKNOWN_PARAMETER", name);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const point = (v, path) => {
|
|
111
|
+
if (obj(v, path, ["x", "y", "z"], ["x", "y", "z"]))
|
|
112
|
+
for (const a of ["x", "y", "z"])
|
|
113
|
+
scalar(v[a], `${path}/${a}`);
|
|
114
|
+
};
|
|
115
|
+
const dictionary = (v, path, fn, min = 0, max = 256) => {
|
|
116
|
+
if (!record(v)) {
|
|
117
|
+
fail(path, "Expected a dictionary of named objects.");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(v).length < min || Object.keys(v).length > max)
|
|
121
|
+
fail(path, `Expected ${min}–${max} entries.`, "GEOMETRY_BUDGET");
|
|
122
|
+
for (const [id, entry] of Object.entries(v)) {
|
|
123
|
+
if (!idPattern.test(id))
|
|
124
|
+
fail(`${path}/${pointer(id)}`, "IDs must start with a letter and contain letters, digits, underscores or hyphens.");
|
|
125
|
+
fn(entry, `${path}/${pointer(id)}`, id);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
if (root.parameters !== undefined)
|
|
129
|
+
dictionary(root.parameters, "/parameters", (v, p) => {
|
|
130
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
131
|
+
fail(p, "Parameter must be a finite numeric scalar.", "NON_FINITE_VALUE");
|
|
132
|
+
});
|
|
133
|
+
dictionary(root.materials, "/materials", (v, p) => {
|
|
134
|
+
if (obj(v, p, ["color", "tintable"], ["color"])) {
|
|
135
|
+
const m = v;
|
|
136
|
+
if (typeof m.color !== "string" || !/^#[0-9a-fA-F]{6}$/.test(m.color))
|
|
137
|
+
fail(`${p}/color`, "Expected #rrggbb.");
|
|
138
|
+
if (m.tintable !== undefined && typeof m.tintable !== "boolean")
|
|
139
|
+
fail(`${p}/tintable`, "Expected a boolean.");
|
|
140
|
+
}
|
|
141
|
+
}, 1);
|
|
142
|
+
if (root.groups !== undefined)
|
|
143
|
+
dictionary(root.groups, "/groups", (v, p) => {
|
|
144
|
+
if (obj(v, p, ["parent", "position"], ["position"])) {
|
|
145
|
+
const g = v;
|
|
146
|
+
point(g.position, `${p}/position`);
|
|
147
|
+
if (g.parent !== undefined && typeof g.parent !== "string")
|
|
148
|
+
fail(`${p}/parent`, "Expected a group ID.");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
if (root.groundPivot !== undefined)
|
|
152
|
+
point(root.groundPivot, "/groundPivot");
|
|
153
|
+
dictionary(root.parts, "/parts", (v, p) => {
|
|
154
|
+
if (!obj(v, p, [
|
|
155
|
+
"shape",
|
|
156
|
+
"material",
|
|
157
|
+
"placement",
|
|
158
|
+
"parent",
|
|
159
|
+
"anchors",
|
|
160
|
+
...(v2 ? ["joint", "sides", "slot", "option", "when"] : []),
|
|
161
|
+
...(v3 ? ["rotation", "array"] : []),
|
|
162
|
+
], ["shape", "material", "placement"]))
|
|
163
|
+
return;
|
|
164
|
+
const part = v;
|
|
165
|
+
if (v2) {
|
|
166
|
+
if (part.joint !== undefined && typeof part.joint !== "string")
|
|
167
|
+
fail(`${p}/joint`, "Expected a joint ID.");
|
|
168
|
+
if (part.sides !== undefined && part.sides !== "both")
|
|
169
|
+
fail(`${p}/sides`, 'Expected "both". Author the left (−X) side; the right side is mirrored.');
|
|
170
|
+
if ((part.slot === undefined) !== (part.option === undefined))
|
|
171
|
+
fail(`${p}/${part.slot === undefined ? "slot" : "option"}`, "Declare slot and option together.");
|
|
172
|
+
else if (part.slot !== undefined) {
|
|
173
|
+
const options = record(root.slots) ? root.slots[text(part.slot)] : null;
|
|
174
|
+
if (typeof part.slot !== "string" || !Array.isArray(options))
|
|
175
|
+
fail(`${p}/slot`, "Unknown slot.", "UNKNOWN_SLOT", part.slot);
|
|
176
|
+
else if (!options.includes(part.option))
|
|
177
|
+
diagnostics.push({
|
|
178
|
+
code: "UNKNOWN_SLOT_OPTION",
|
|
179
|
+
path: `${p}/option`,
|
|
180
|
+
message: "Choose an option declared by this slot.",
|
|
181
|
+
received: part.option,
|
|
182
|
+
expected: strings(options),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (part.when !== undefined) {
|
|
186
|
+
const states = record(root.states) ? root.states : {};
|
|
187
|
+
dictionary(part.when, `${p}/when`, (values, wp, channel) => {
|
|
188
|
+
const declared = states[channel];
|
|
189
|
+
if (!Array.isArray(declared)) {
|
|
190
|
+
diagnostics.push({
|
|
191
|
+
code: "UNKNOWN_STATE",
|
|
192
|
+
path: wp,
|
|
193
|
+
message: "Choose a state channel declared in states.",
|
|
194
|
+
received: channel,
|
|
195
|
+
expected: Object.keys(states).sort(),
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!Array.isArray(values) || values.length < 1 || new Set(values).size !== values.length)
|
|
200
|
+
fail(wp, "Expected an array of distinct states in which the part is visible.");
|
|
201
|
+
else
|
|
202
|
+
values.forEach((value, i) => {
|
|
203
|
+
if (!declared.includes(value))
|
|
204
|
+
diagnostics.push({
|
|
205
|
+
code: "UNKNOWN_STATE_VALUE",
|
|
206
|
+
path: `${wp}/${i}`,
|
|
207
|
+
message: "Choose a state declared by this channel.",
|
|
208
|
+
received: value,
|
|
209
|
+
expected: strings(declared),
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (part.sides !== undefined && record(part.placement) && part.placement.type === "repeat")
|
|
215
|
+
fail(`${p}/sides`, "Mirrored sides cannot be combined with corner repetition.", "UNSUPPORTED_COMBINATION");
|
|
216
|
+
}
|
|
217
|
+
if (typeof part.material !== "string" || !record(root.materials) || !has(root.materials, part.material))
|
|
218
|
+
fail(`${p}/material`, "Unknown material ID.", "UNKNOWN_MATERIAL", part.material);
|
|
219
|
+
if (part.parent !== undefined && typeof part.parent !== "string")
|
|
220
|
+
fail(`${p}/parent`, "Expected a group ID.");
|
|
221
|
+
if (part.anchors !== undefined)
|
|
222
|
+
dictionary(part.anchors, `${p}/anchors`, (v, p) => point(v, p));
|
|
223
|
+
const s = part.shape;
|
|
224
|
+
if (!record(s)) {
|
|
225
|
+
fail(`${p}/shape`, "Expected a shape object.");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const shapeFields = v3 ? SHAPE_FIELDS_V3 : SHAPE_FIELDS;
|
|
229
|
+
const kind = text(s.type), fields = has(shapeFields, kind) ? shapeFields[kind] : null;
|
|
230
|
+
if (!fields)
|
|
231
|
+
diagnostics.push({
|
|
232
|
+
code: "UNKNOWN_SHAPE",
|
|
233
|
+
path: `${p}/shape/type`,
|
|
234
|
+
message: v3 || !has(SHAPE_FIELDS_V3, kind) ? "Unknown primitive." : `${kind} requires ${SCHEMA_V3}.`,
|
|
235
|
+
received: s.type,
|
|
236
|
+
expected: Object.keys(shapeFields),
|
|
237
|
+
});
|
|
238
|
+
else if (obj(s, `${p}/shape`, ["type", ...fields], ["type", ...fields.filter((k) => k !== "bevel" && k !== "sides")]))
|
|
239
|
+
for (const k of fields) {
|
|
240
|
+
if (s[k] === undefined)
|
|
241
|
+
continue;
|
|
242
|
+
if (k === "size") {
|
|
243
|
+
if (obj(s[k], `${p}/shape/size`, ["width", "height", "depth"], ["width", "height", "depth"]))
|
|
244
|
+
for (const a of ["width", "height", "depth"])
|
|
245
|
+
scalar(s[k][a], `${p}/shape/size/${a}`);
|
|
246
|
+
}
|
|
247
|
+
else if (k === "from" || k === "to")
|
|
248
|
+
point(s[k], `${p}/shape/${k}`);
|
|
249
|
+
else if (k === "sides") {
|
|
250
|
+
if (!Number.isInteger(s[k]) || s[k] < MIN_SIDES || s[k] > MAX_SIDES)
|
|
251
|
+
fail(`${p}/shape/sides`, `Expected an integer from ${MIN_SIDES} to ${MAX_SIDES}.`, "SCHEMA_ERROR", s[k]);
|
|
252
|
+
}
|
|
253
|
+
else if (k === "profile" || k === "outline") {
|
|
254
|
+
const [a, b, min] = k === "profile" ? ["radius", "y", 2] : ["x", "z", 3];
|
|
255
|
+
if (!Array.isArray(s[k]) || s[k].length < min || s[k].length > 64)
|
|
256
|
+
fail(`${p}/shape/${k}`, `Expected an array of ${min}–64 { ${a}, ${b} } points.`);
|
|
257
|
+
else
|
|
258
|
+
s[k].forEach((v, i) => {
|
|
259
|
+
if (obj(v, `${p}/shape/${k}/${i}`, [a, b], [a, b]))
|
|
260
|
+
for (const c of [a, b])
|
|
261
|
+
scalar(v[c], `${p}/shape/${k}/${i}/${c}`);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
else
|
|
265
|
+
scalar(s[k], `${p}/shape/${k}`);
|
|
266
|
+
}
|
|
267
|
+
if (v3 && part.rotation !== undefined && obj(part.rotation, `${p}/rotation`, ["x", "y", "z"])) {
|
|
268
|
+
const r = part.rotation;
|
|
269
|
+
if (!Object.keys(r).length)
|
|
270
|
+
fail(`${p}/rotation`, "Give at least one of x, y or z in degrees.");
|
|
271
|
+
for (const a of ["x", "y", "z"])
|
|
272
|
+
if (r[a] !== undefined)
|
|
273
|
+
scalar(r[a], `${p}/rotation/${a}`);
|
|
274
|
+
}
|
|
275
|
+
if (v3 && part.array !== undefined)
|
|
276
|
+
arrayDiagnostics(part, p, fail, obj, scalar, point);
|
|
277
|
+
const placement = part.placement;
|
|
278
|
+
if (!record(placement)) {
|
|
279
|
+
fail(`${p}/placement`, "Expected a placement object.");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const pk = text(placement.type), pf = {
|
|
283
|
+
absolute: ["selfAnchor", "position"],
|
|
284
|
+
attach: ["selfAnchor", "target", "offset"],
|
|
285
|
+
repeat: ["selfAnchor", "pattern", "target", "edgeInset"],
|
|
286
|
+
};
|
|
287
|
+
if (!has(pf, pk)) {
|
|
288
|
+
fail(`${p}/placement/type`, "Unknown placement relationship.", "UNKNOWN_PLACEMENT", pk);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
obj(placement, `${p}/placement`, ["type", ...requireValue(pf[pk])], ["type", ...requireValue(pf[pk]).filter((k) => k !== "offset")]);
|
|
292
|
+
if (typeof placement.selfAnchor !== "string")
|
|
293
|
+
fail(`${p}/placement/selfAnchor`, "Expected an anchor name.");
|
|
294
|
+
if (pk === "absolute")
|
|
295
|
+
point(placement.position, `${p}/placement/position`);
|
|
296
|
+
else {
|
|
297
|
+
const t = placement.target, key = pk === "attach" ? "anchor" : "face";
|
|
298
|
+
if (obj(t, `${p}/placement/target`, ["part", key], ["part", key])) {
|
|
299
|
+
if (typeof t.part !== "string")
|
|
300
|
+
fail(`${p}/placement/target/part`, "Expected a part or instance ID.");
|
|
301
|
+
if (typeof t[key] !== "string")
|
|
302
|
+
fail(`${p}/placement/target/${key}`, "Expected a name.");
|
|
303
|
+
}
|
|
304
|
+
if (pk === "attach" && placement.offset !== undefined)
|
|
305
|
+
point(placement.offset, `${p}/placement/offset`);
|
|
306
|
+
if (pk === "repeat") {
|
|
307
|
+
if (placement.pattern !== "corners")
|
|
308
|
+
fail(`${p}/placement/pattern`, "Only corners repetition is supported.", "UNKNOWN_PATTERN", placement.pattern);
|
|
309
|
+
if (record(t) && t.face !== "top" && t.face !== "bottom")
|
|
310
|
+
fail(`${p}/placement/target/face`, "Choose top or bottom.");
|
|
311
|
+
if (obj(placement.edgeInset, `${p}/placement/edgeInset`, ["x", "z"], ["x", "z"]))
|
|
312
|
+
for (const a of ["x", "z"])
|
|
313
|
+
scalar(placement.edgeInset[a], `${p}/placement/edgeInset/${a}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}, 1);
|
|
317
|
+
if (v2)
|
|
318
|
+
jointDiagnostics(root, diagnostics, obj, point, dictionary, v3);
|
|
319
|
+
return diagnostics;
|
|
320
|
+
}
|
|
321
|
+
/** Structural rules for schema 0.3 arrays. */
|
|
322
|
+
function arrayDiagnostics(part, p, fail, obj, scalar, point) {
|
|
323
|
+
const a = part.array, path = `${p}/array`;
|
|
324
|
+
if (!record(a)) {
|
|
325
|
+
fail(path, "Expected an array object with a type of line, grid or radial.");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const count = (v, at, min) => {
|
|
329
|
+
if (!Number.isInteger(v) || v < min || v > MAX_ARRAY_COUNT)
|
|
330
|
+
fail(at, `Expected an integer count from ${min} to ${MAX_ARRAY_COUNT}.`, "SCHEMA_ERROR", v);
|
|
331
|
+
};
|
|
332
|
+
const flag = (k) => {
|
|
333
|
+
if (a[k] !== undefined && typeof a[k] !== "boolean")
|
|
334
|
+
fail(`${path}/${k}`, "Expected a boolean.");
|
|
335
|
+
};
|
|
336
|
+
if (a.type === "line") {
|
|
337
|
+
if (!obj(a, path, ["type", "count", "step", "centered"], ["type", "count", "step"]))
|
|
338
|
+
return;
|
|
339
|
+
count(a.count, `${path}/count`, 2);
|
|
340
|
+
point(a.step, `${path}/step`);
|
|
341
|
+
flag("centered");
|
|
342
|
+
}
|
|
343
|
+
else if (a.type === "grid") {
|
|
344
|
+
if (!obj(a, path, ["type", "count", "spacing", "centered"], ["type", "count", "spacing"]))
|
|
345
|
+
return;
|
|
346
|
+
if (obj(a.count, `${path}/count`, ["x", "y", "z"])) {
|
|
347
|
+
const c = a.count;
|
|
348
|
+
for (const k of ["x", "y", "z"])
|
|
349
|
+
if (c[k] !== undefined)
|
|
350
|
+
count(c[k], `${path}/count/${k}`, 1);
|
|
351
|
+
const total = ["x", "y", "z"].reduce((n, k) => n * (Number.isInteger(c[k]) ? c[k] : 1), 1);
|
|
352
|
+
if (total < 2 || total > MAX_ARRAY_COUNT)
|
|
353
|
+
fail(`${path}/count`, `A grid needs 2–${MAX_ARRAY_COUNT} instances in total.`, "GEOMETRY_BUDGET", total);
|
|
354
|
+
}
|
|
355
|
+
if (obj(a.spacing, `${path}/spacing`, ["x", "y", "z"])) {
|
|
356
|
+
const sp = a.spacing;
|
|
357
|
+
for (const k of ["x", "y", "z"])
|
|
358
|
+
if (sp[k] !== undefined)
|
|
359
|
+
scalar(sp[k], `${path}/spacing/${k}`);
|
|
360
|
+
}
|
|
361
|
+
flag("centered");
|
|
362
|
+
}
|
|
363
|
+
else if (a.type === "radial") {
|
|
364
|
+
if (!obj(a, path, ["type", "count", "radius", "axis", "start", "sweep", "orient"], ["type", "count", "radius"]))
|
|
365
|
+
return;
|
|
366
|
+
count(a.count, `${path}/count`, 2);
|
|
367
|
+
scalar(a.radius, `${path}/radius`);
|
|
368
|
+
if (a.axis !== undefined && !["x", "y", "z"].includes(text(a.axis)))
|
|
369
|
+
fail(`${path}/axis`, "Choose x, y or z.", "UNKNOWN_AXIS", a.axis);
|
|
370
|
+
for (const k of ["start", "sweep"])
|
|
371
|
+
if (a[k] !== undefined)
|
|
372
|
+
scalar(a[k], `${path}/${k}`);
|
|
373
|
+
flag("orient");
|
|
374
|
+
}
|
|
375
|
+
else
|
|
376
|
+
fail(`${path}/type`, "Choose line, grid or radial.", "UNKNOWN_PATTERN", a.type);
|
|
377
|
+
if (record(part.placement) && part.placement.type === "repeat")
|
|
378
|
+
fail(path, "Arrays cannot be combined with corner repetition.", "UNSUPPORTED_COMBINATION");
|
|
379
|
+
if (part.sides !== undefined)
|
|
380
|
+
fail(path, "Arrays cannot be combined with mirrored sides.", "UNSUPPORTED_COMBINATION");
|
|
381
|
+
}
|
|
382
|
+
/** Structural rules for the schema 0.2 fields. */
|
|
383
|
+
function jointDiagnostics(root, diagnostics, obj, point, dictionary, v3 = false) {
|
|
384
|
+
const fail = (path, message, code = "SCHEMA_ERROR", extra = {}) => diagnostics.push({ code, path, message, ...extra });
|
|
385
|
+
if (root.states !== undefined)
|
|
386
|
+
dictionary(root.states, "/states", (v, p) => {
|
|
387
|
+
if (!Array.isArray(v) || v.length < 2 || v.length > 16) {
|
|
388
|
+
fail(p, "Expected an array of 2–16 states; the first is the rest state.");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
v.forEach((state, i) => {
|
|
392
|
+
if (typeof state !== "string" || !idPattern.test(state))
|
|
393
|
+
fail(`${p}/${i}`, "State IDs must start with a letter and contain letters, digits, _ or -.");
|
|
394
|
+
else if (v.indexOf(state) !== i)
|
|
395
|
+
fail(`${p}/${i}`, "Duplicate state.", "DUPLICATE_NAME");
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
if (root.limbs !== undefined)
|
|
399
|
+
dictionary(root.limbs, "/limbs", (v, p) => {
|
|
400
|
+
if (!obj(v, p, ["sides", "effector", "reach", "level"], ["effector", "reach"]))
|
|
401
|
+
return;
|
|
402
|
+
const limb = v;
|
|
403
|
+
if (limb.sides !== undefined && limb.sides !== "both")
|
|
404
|
+
fail(`${p}/sides`, 'Expected "both".');
|
|
405
|
+
if (obj(limb.effector, `${p}/effector`, ["part", "anchor"], ["part", "anchor"])) {
|
|
406
|
+
const e = limb.effector;
|
|
407
|
+
if (typeof e.part !== "string")
|
|
408
|
+
fail(`${p}/effector/part`, "Expected a part or instance ID.");
|
|
409
|
+
else {
|
|
410
|
+
const target = record(root.parts) ? root.parts[requireValue(e.part.split("/")[0])] : null;
|
|
411
|
+
if (record(target) && target.slot !== undefined)
|
|
412
|
+
fail(`${p}/effector/part`, "Limb effectors cannot be slotted parts.", "SLOTTED_LIMB");
|
|
413
|
+
}
|
|
414
|
+
if (typeof e.anchor !== "string")
|
|
415
|
+
fail(`${p}/effector/anchor`, "Expected an anchor name.");
|
|
416
|
+
}
|
|
417
|
+
const ref = (r, rp) => {
|
|
418
|
+
if (obj(r, rp, ["joint", "dof"], ["joint", "dof"]))
|
|
419
|
+
for (const k of ["joint", "dof"])
|
|
420
|
+
if (typeof r[k] !== "string")
|
|
421
|
+
fail(`${rp}/${k}`, "Expected a name.");
|
|
422
|
+
};
|
|
423
|
+
if (!Array.isArray(limb.reach) || limb.reach.length < 1 || limb.reach.length > 3)
|
|
424
|
+
fail(`${p}/reach`, "Expected one to three { joint, dof } entries, from the chain root outward.");
|
|
425
|
+
else
|
|
426
|
+
limb.reach.forEach((r, i) => ref(r, `${p}/reach/${i}`));
|
|
427
|
+
if (limb.level !== undefined)
|
|
428
|
+
ref(limb.level, `${p}/level`);
|
|
429
|
+
});
|
|
430
|
+
const slots = record(root.slots) ? root.slots : {};
|
|
431
|
+
if (root.slots !== undefined)
|
|
432
|
+
dictionary(root.slots, "/slots", (v, p) => {
|
|
433
|
+
if (!Array.isArray(v) || v.length < 1 || v.length > 32) {
|
|
434
|
+
fail(p, "Expected an array of 1–32 option IDs; the first option is the default.");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
v.forEach((option, i) => {
|
|
438
|
+
if (typeof option !== "string" || !idPattern.test(option))
|
|
439
|
+
fail(`${p}/${i}`, "Option IDs must start with a letter and contain letters, digits, _ or -.");
|
|
440
|
+
else if (v.indexOf(option) !== i)
|
|
441
|
+
fail(`${p}/${i}`, "Duplicate option.", "DUPLICATE_NAME");
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
if (root.joints !== undefined)
|
|
445
|
+
dictionary(root.joints, "/joints", (v, p) => {
|
|
446
|
+
if (!obj(v, p, ["parent", "sides", "face", "pivot", "dofs"], ["pivot", "dofs"]))
|
|
447
|
+
return;
|
|
448
|
+
const joint = v;
|
|
449
|
+
if (joint.face !== undefined && typeof joint.face !== "boolean")
|
|
450
|
+
fail(`${p}/face`, "Expected a boolean.");
|
|
451
|
+
if (joint.parent !== undefined && typeof joint.parent !== "string")
|
|
452
|
+
fail(`${p}/parent`, "Expected a joint ID.");
|
|
453
|
+
if (joint.sides !== undefined && joint.sides !== "both")
|
|
454
|
+
fail(`${p}/sides`, 'Expected "both". Author the left (−X) side; the right side is mirrored.');
|
|
455
|
+
const pivot = joint.pivot;
|
|
456
|
+
if (record(pivot) && has(pivot, "position")) {
|
|
457
|
+
if (obj(pivot, `${p}/pivot`, ["position"], ["position"]))
|
|
458
|
+
point(pivot.position, `${p}/pivot/position`);
|
|
459
|
+
}
|
|
460
|
+
else if (obj(pivot, `${p}/pivot`, ["part", "anchor"], ["part", "anchor"])) {
|
|
461
|
+
const r = pivot;
|
|
462
|
+
if (typeof r.part !== "string")
|
|
463
|
+
fail(`${p}/pivot/part`, "Expected a part or instance ID.");
|
|
464
|
+
else {
|
|
465
|
+
const target = record(root.parts) ? root.parts[requireValue(r.part.split("/")[0])] : null;
|
|
466
|
+
if (record(target) && target.slot !== undefined)
|
|
467
|
+
fail(`${p}/pivot/part`, "Every identity shares one skeleton, so joints cannot pivot on slotted parts. Pivot on a part outside slots or use a position.", "SLOTTED_PIVOT", { received: r.part });
|
|
468
|
+
}
|
|
469
|
+
if (typeof r.anchor !== "string")
|
|
470
|
+
fail(`${p}/pivot/anchor`, "Expected an anchor name.");
|
|
471
|
+
}
|
|
472
|
+
if (!Array.isArray(joint.dofs) || joint.dofs.length < 1 || joint.dofs.length > 3) {
|
|
473
|
+
fail(`${p}/dofs`, "Expected an ordered array of 1–3 degrees of freedom.");
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const names = [];
|
|
477
|
+
joint.dofs.forEach((dof, i) => {
|
|
478
|
+
const dp = `${p}/dofs/${i}`;
|
|
479
|
+
if (!obj(dof, dp, ["name", "axis", "limits", ...(v3 ? ["continuous"] : [])], ["name", "axis", "limits"]))
|
|
480
|
+
return;
|
|
481
|
+
const d = dof;
|
|
482
|
+
if (d.continuous !== undefined && typeof d.continuous !== "boolean")
|
|
483
|
+
fail(`${dp}/continuous`, "Expected a boolean.");
|
|
484
|
+
if (typeof d.name !== "string" || !idPattern.test(d.name))
|
|
485
|
+
fail(`${dp}/name`, "DOF names must start with a letter and contain letters, digits, _ or -.");
|
|
486
|
+
else if (names.includes(d.name))
|
|
487
|
+
fail(`${dp}/name`, "Duplicate DOF name.", "DUPLICATE_NAME");
|
|
488
|
+
else
|
|
489
|
+
names.push(d.name);
|
|
490
|
+
if (typeof d.axis !== "string" || !has(AXES, d.axis))
|
|
491
|
+
fail(`${dp}/axis`, "Choose a signed axis.", "UNKNOWN_AXIS", {
|
|
492
|
+
received: d.axis,
|
|
493
|
+
expected: Object.keys(AXES),
|
|
494
|
+
});
|
|
495
|
+
const l = d.limits;
|
|
496
|
+
if (!Array.isArray(l) ||
|
|
497
|
+
l.length !== 2 ||
|
|
498
|
+
!l.every((n) => typeof n === "number" && Number.isFinite(n)))
|
|
499
|
+
fail(`${dp}/limits`, "Expected [minimum, maximum] in degrees.");
|
|
500
|
+
else {
|
|
501
|
+
// Continuous DOFs (schema 0.3) may key up to ten turns per cycle.
|
|
502
|
+
const bound = v3 && d.continuous === true ? 3600 : 360;
|
|
503
|
+
if (!(l[0] < l[1]) || l[0] > 0 || l[1] < 0 || l[0] < -bound || l[1] > bound)
|
|
504
|
+
fail(`${dp}/limits`, `Limits must satisfy −${bound} ≤ minimum ≤ 0 ≤ maximum ≤ ${bound} with minimum < maximum; the rest value is 0.`, "JOINT_LIMIT", { received: l });
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
const params = record(root.parameters) ? root.parameters : {}, materials = record(root.materials) ? root.materials : {};
|
|
509
|
+
if (root.identities !== undefined)
|
|
510
|
+
dictionary(root.identities, "/identities", (v, p) => {
|
|
511
|
+
if (!obj(v, p, ["parameters", "materials", "slots"]))
|
|
512
|
+
return;
|
|
513
|
+
const identity = v;
|
|
514
|
+
const overrides = (field, known, check) => {
|
|
515
|
+
if (identity[field] === undefined)
|
|
516
|
+
return;
|
|
517
|
+
dictionary(identity[field], `${p}/${field}`, (value, vp, key) => {
|
|
518
|
+
if (!has(known, key))
|
|
519
|
+
fail(vp, `Identities may only override declared ${field}.`, "UNKNOWN_IDENTITY_OVERRIDE", {
|
|
520
|
+
received: key,
|
|
521
|
+
expected: Object.keys(known).sort(),
|
|
522
|
+
});
|
|
523
|
+
else
|
|
524
|
+
check(value, vp, key);
|
|
525
|
+
});
|
|
526
|
+
};
|
|
527
|
+
overrides("parameters", params, (value, vp) => {
|
|
528
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
529
|
+
fail(vp, "Parameter must be a finite numeric scalar.", "NON_FINITE_VALUE");
|
|
530
|
+
});
|
|
531
|
+
overrides("materials", materials, (value, vp) => {
|
|
532
|
+
if (obj(value, vp, ["color"], ["color"])) {
|
|
533
|
+
const c = value.color;
|
|
534
|
+
if (typeof c !== "string" || !/^#[0-9a-fA-F]{6}$/.test(c))
|
|
535
|
+
fail(`${vp}/color`, "Expected #rrggbb.");
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
overrides("slots", slots, (value, vp, key) => {
|
|
539
|
+
const options = slots[key];
|
|
540
|
+
if (Array.isArray(options) && !options.includes(value))
|
|
541
|
+
fail(vp, "Choose an option declared by this slot.", "UNKNOWN_SLOT_OPTION", {
|
|
542
|
+
received: value,
|
|
543
|
+
expected: strings(options),
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const neg = (v) => (v === 0 ? 0 : -v);
|
|
549
|
+
const mirrorPoint = (p) => [neg(p[0]), p[1], p[2]];
|
|
550
|
+
const swapSide = (ref) => ref.endsWith("/left")
|
|
551
|
+
? `${ref.slice(0, -5)}/right`
|
|
552
|
+
: ref.endsWith("/right")
|
|
553
|
+
? `${ref.slice(0, -6)}/left`
|
|
554
|
+
: ref;
|
|
555
|
+
/** Exact reflection across the asset's X = 0 plane, with winding reversed to keep faces outward. */
|
|
556
|
+
function mirrorInstance(left, id) {
|
|
557
|
+
const m = left.mesh, mesh = {
|
|
558
|
+
positions: m.positions.map((v, i) => (i % 3 === 0 ? neg(v) : v)),
|
|
559
|
+
normals: m.normals.map((v, i) => (i % 3 === 0 ? neg(v) : v)),
|
|
560
|
+
colors: [...m.colors],
|
|
561
|
+
indices: [],
|
|
562
|
+
};
|
|
563
|
+
for (let i = 0; i < m.indices.length; i += 3)
|
|
564
|
+
mesh.indices.push(requireValue(m.indices[i]), requireValue(m.indices[i + 2]), requireValue(m.indices[i + 1]));
|
|
565
|
+
const swap = { "left-center": "right-center", "right-center": "left-center" };
|
|
566
|
+
return {
|
|
567
|
+
id,
|
|
568
|
+
part: left.part,
|
|
569
|
+
material: left.material,
|
|
570
|
+
translation: mirrorPoint(left.translation),
|
|
571
|
+
bounds: {
|
|
572
|
+
min: [neg(left.bounds.max[0]), left.bounds.min[1], left.bounds.min[2]],
|
|
573
|
+
max: [neg(left.bounds.min[0]), left.bounds.max[1], left.bounds.max[2]],
|
|
574
|
+
},
|
|
575
|
+
anchors: Object.fromEntries(Object.entries(left.anchors).map(([k, v]) => [swap[k] ?? k, mirrorPoint(v)])),
|
|
576
|
+
mesh,
|
|
577
|
+
joint: left.joint === undefined || left.joint === null ? left.joint : swapSide(left.joint),
|
|
578
|
+
...(left.when ? { when: structuredClone(left.when) } : {}),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function resolve(spec, options) {
|
|
582
|
+
const stop = (code, path, message, extra = {}) => {
|
|
583
|
+
throw new AssetCompileError([{ code, path, message, ...extra }]);
|
|
584
|
+
};
|
|
585
|
+
const v3 = spec.schema === SCHEMA_V3, v2 = v3 || spec.schema === SCHEMA_V2;
|
|
586
|
+
let identity = null;
|
|
587
|
+
if (options.identity !== undefined) {
|
|
588
|
+
if (!v2 || !spec.identities || !has(spec.identities, options.identity))
|
|
589
|
+
stop("UNKNOWN_IDENTITY", "/identities", "Choose an identity declared by this asset.", {
|
|
590
|
+
received: options.identity,
|
|
591
|
+
expected: Object.keys(spec.identities ?? {}).sort(),
|
|
592
|
+
});
|
|
593
|
+
identity = options.identity;
|
|
594
|
+
}
|
|
595
|
+
const chosen = identity === null ? null : requireValue(requireValue(spec.identities)[identity]);
|
|
596
|
+
const params = { ...spec.parameters, ...chosen?.parameters };
|
|
597
|
+
const materials = structuredClone(spec.materials);
|
|
598
|
+
for (const [id, m] of Object.entries(chosen?.materials ?? {}))
|
|
599
|
+
requireValue(materials[id]).color = m.color;
|
|
600
|
+
const slots = {};
|
|
601
|
+
for (const id of Object.keys(spec.slots ?? {}).sort())
|
|
602
|
+
slots[id] = chosen?.slots?.[id] ?? requireValue(requireValue(requireValue(spec.slots)[id])[0]);
|
|
603
|
+
const active = (id) => {
|
|
604
|
+
const part = requireValue(spec.parts[id]);
|
|
605
|
+
return part.slot === undefined || slots[part.slot] === part.option;
|
|
606
|
+
};
|
|
607
|
+
/** Instances an array creates (schema 0.3). */
|
|
608
|
+
const arrayCount = (a) => a.type === "grid" ? (a.count.x ?? 1) * (a.count.y ?? 1) * (a.count.z ?? 1) : a.count;
|
|
609
|
+
/**
|
|
610
|
+
* Schema 0.3: a joint whose pivot names an arrayed part without an instance index is copied once per
|
|
611
|
+
* instance, as <joint>/0 … <joint>/n−1. Returns the number of copies, or 0.
|
|
612
|
+
*/
|
|
613
|
+
const copiesOf = (id) => {
|
|
614
|
+
const j = v3 && spec.joints && has(spec.joints, id) ? requireValue(spec.joints[id]) : null;
|
|
615
|
+
if (!j || !("part" in j.pivot) || j.pivot.part.includes("/"))
|
|
616
|
+
return 0;
|
|
617
|
+
const target = has(spec.parts, j.pivot.part) ? requireValue(spec.parts[j.pivot.part]) : null;
|
|
618
|
+
return target?.array ? arrayCount(target.array) : 0;
|
|
619
|
+
};
|
|
620
|
+
const jointIds = () => Object.keys(spec.joints ?? {})
|
|
621
|
+
.flatMap((id) => {
|
|
622
|
+
const n = copiesOf(id);
|
|
623
|
+
if (n)
|
|
624
|
+
return Array.from({ length: n }, (_, k) => `${id}/${k}`);
|
|
625
|
+
return requireValue(requireValue(spec.joints)[id]).sides === "both"
|
|
626
|
+
? [`${id}/left`, `${id}/right`]
|
|
627
|
+
: [id];
|
|
628
|
+
})
|
|
629
|
+
.sort();
|
|
630
|
+
/**
|
|
631
|
+
* Resolve a joint reference. Inside a left-side declaration a bare sided joint means the same side;
|
|
632
|
+
* inside copy k of an array a bare copied joint means its copy k.
|
|
633
|
+
*/
|
|
634
|
+
const bindJoint = (ref, path, side, copy = null, count = null) => {
|
|
635
|
+
const [id, suffix, extra] = ref.split("/"), joint = spec.joints && id && has(spec.joints, id) ? spec.joints[id] : null, unknown = () => stop("UNKNOWN_JOINT", path, "Choose a joint declared by this asset.", {
|
|
636
|
+
received: ref,
|
|
637
|
+
expected: jointIds(),
|
|
638
|
+
});
|
|
639
|
+
if (!joint || extra !== undefined)
|
|
640
|
+
return unknown();
|
|
641
|
+
const copies = copiesOf(requireValue(id));
|
|
642
|
+
if (copies) {
|
|
643
|
+
if (suffix !== undefined)
|
|
644
|
+
return /^(0|[1-9][0-9]*)$/.test(suffix) && Number(suffix) < copies ? ref : unknown();
|
|
645
|
+
// A bare copied name pairs copy k with copy k only between joints copied over the same count.
|
|
646
|
+
if (copy === null || (count !== null && count !== copies))
|
|
647
|
+
return stop("AMBIGUOUS_JOINT", path, copy === null
|
|
648
|
+
? `Joint ${id} has one copy per array instance; choose one, for example ${id}/0.`
|
|
649
|
+
: `Joint ${id} has ${copies} copies and this joint has ${count}; choose one, for example ${id}/0.`, {
|
|
650
|
+
received: ref,
|
|
651
|
+
expected: Array.from({ length: copies }, (_, k) => `${id}/${k}`),
|
|
652
|
+
});
|
|
653
|
+
if (copy >= copies)
|
|
654
|
+
return stop("JOINT_ARRAY_MISMATCH", path, `Joint ${id} has ${copies} copies; this array has more instances.`, {
|
|
655
|
+
received: copy,
|
|
656
|
+
expected: [String(copies)],
|
|
657
|
+
});
|
|
658
|
+
return `${id}/${copy}`;
|
|
659
|
+
}
|
|
660
|
+
if (joint.sides !== "both")
|
|
661
|
+
return suffix === undefined ? requireValue(id) : unknown();
|
|
662
|
+
if (suffix === undefined)
|
|
663
|
+
return side
|
|
664
|
+
? `${id}/${side}`
|
|
665
|
+
: stop("AMBIGUOUS_JOINT", path, `Choose a side, for example ${id}/left.`, {
|
|
666
|
+
received: ref,
|
|
667
|
+
expected: [`${id}/left`, `${id}/right`],
|
|
668
|
+
});
|
|
669
|
+
return suffix === "left" || suffix === "right" ? ref : unknown();
|
|
670
|
+
};
|
|
671
|
+
const number = (s, path) => {
|
|
672
|
+
const n = typeof s === "number" ? s : requireValue(params[s.parameter]);
|
|
673
|
+
if (!Number.isFinite(n))
|
|
674
|
+
stop("NON_FINITE_VALUE", path, "Resolved value must be finite.");
|
|
675
|
+
return n;
|
|
676
|
+
};
|
|
677
|
+
const point = (p, path) => [
|
|
678
|
+
number(p.x, `${path}/x`),
|
|
679
|
+
number(p.y, `${path}/y`),
|
|
680
|
+
number(p.z, `${path}/z`),
|
|
681
|
+
];
|
|
682
|
+
const positive = (s, path, allowZero = false) => {
|
|
683
|
+
const n = number(s, path);
|
|
684
|
+
if (n < 0 || (!allowZero && n === 0) || n > 1e6)
|
|
685
|
+
stop("IMPOSSIBLE_BOUNDS", path, `Expected ${allowZero ? "nonnegative" : "positive"} dimension at most 1000000.`);
|
|
686
|
+
return n;
|
|
687
|
+
};
|
|
688
|
+
const limits = { instances: options.maxInstances ?? 1024, triangles: options.maxTriangles ?? 200000 };
|
|
689
|
+
if (!Number.isInteger(limits.instances) ||
|
|
690
|
+
limits.instances < 1 ||
|
|
691
|
+
!Number.isInteger(limits.triangles) ||
|
|
692
|
+
limits.triangles < 1)
|
|
693
|
+
stop("GEOMETRY_BUDGET", "", "Budgets must be positive integers.");
|
|
694
|
+
const groupMap = new Map(), groupStack = new Set();
|
|
695
|
+
const group = (id) => {
|
|
696
|
+
if (id === undefined)
|
|
697
|
+
return [0, 0, 0];
|
|
698
|
+
if (groupMap.has(id))
|
|
699
|
+
return requireValue(groupMap.get(id));
|
|
700
|
+
const path = `/groups/${pointer(id)}`;
|
|
701
|
+
if (!spec.groups || !has(spec.groups, id))
|
|
702
|
+
return stop("UNKNOWN_REFERENCE", path, "Unknown parent group.", { received: id });
|
|
703
|
+
if (groupStack.has(id))
|
|
704
|
+
return stop("DEPENDENCY_CYCLE", path, "Cyclic group hierarchy.");
|
|
705
|
+
groupStack.add(id);
|
|
706
|
+
const g = requireValue(spec.groups[id]), p = add(group(g.parent), point(g.position, `${path}/position`));
|
|
707
|
+
groupStack.delete(id);
|
|
708
|
+
groupMap.set(id, p);
|
|
709
|
+
return p;
|
|
710
|
+
};
|
|
711
|
+
for (const id of Object.keys(spec.groups ?? {}).sort())
|
|
712
|
+
group(id);
|
|
713
|
+
const built = new Map(), visiting = new Set();
|
|
714
|
+
let triangles = 0, instanceCount = 0;
|
|
715
|
+
/** Schema 0.3 part orientation, or null when the part is not rotated. */
|
|
716
|
+
const partRotation = (part, path) => {
|
|
717
|
+
if (!v3 || !part.rotation)
|
|
718
|
+
return null;
|
|
719
|
+
const angle = (k) => {
|
|
720
|
+
const v = requireValue(part.rotation)[k];
|
|
721
|
+
if (v === undefined)
|
|
722
|
+
return 0;
|
|
723
|
+
const n = number(v, `${path}/rotation/${k}`);
|
|
724
|
+
if (Math.abs(n) > 360)
|
|
725
|
+
stop("INVALID_ROTATION", `${path}/rotation/${k}`, "Rotations are degrees from −360 to 360.", {
|
|
726
|
+
received: n,
|
|
727
|
+
});
|
|
728
|
+
return n;
|
|
729
|
+
};
|
|
730
|
+
const m = eulerRotation(angle("x"), angle("y"), angle("z"));
|
|
731
|
+
return isIdentity(m) ? null : m;
|
|
732
|
+
};
|
|
733
|
+
const sidesOf = (s, fallback = DEFAULT_SIDES) => s.sides ?? fallback;
|
|
734
|
+
const localMesh = (part, path, rotation) => {
|
|
735
|
+
const s = part.shape, m = new MeshBuilder(), color = parseInt(requireValue(materials[part.material]).color.slice(1), 16);
|
|
736
|
+
const dim = (v, k, zero = false) => positive(v, `${path}/shape/${k}`, zero);
|
|
737
|
+
switch (s.type) {
|
|
738
|
+
case "box": {
|
|
739
|
+
const w = dim(s.size.width, "size/width"), h = dim(s.size.height, "size/height"), d = dim(s.size.depth, "size/depth"), bevel = s.bevel === undefined ? 0.018 : dim(s.bevel, "bevel", true);
|
|
740
|
+
if (bevel > 0)
|
|
741
|
+
m.box(0, 0, 0, w, h, d, color, bevel);
|
|
742
|
+
else {
|
|
743
|
+
const bottom = [
|
|
744
|
+
[-w / 2, 0, -d / 2],
|
|
745
|
+
[w / 2, 0, -d / 2],
|
|
746
|
+
[w / 2, 0, d / 2],
|
|
747
|
+
[-w / 2, 0, d / 2],
|
|
748
|
+
], top = bottom.map(([x, , z]) => [x, h, z]), center = [0, h / 2, 0];
|
|
749
|
+
m.face(bottom, color, center);
|
|
750
|
+
m.face(top, color, center);
|
|
751
|
+
for (let i = 0; i < 4; i++)
|
|
752
|
+
m.face([
|
|
753
|
+
requireValue(bottom[i]),
|
|
754
|
+
requireValue(bottom[(i + 1) % 4]),
|
|
755
|
+
requireValue(top[(i + 1) % 4]),
|
|
756
|
+
requireValue(top[i]),
|
|
757
|
+
], color, center);
|
|
758
|
+
}
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
case "cylinder": {
|
|
762
|
+
const r = dim(s.radius, "radius"), h = dim(s.height, "height");
|
|
763
|
+
if (sidesOf(s) === DEFAULT_SIDES)
|
|
764
|
+
m.cylinder(0, 0, 0, r, h, color);
|
|
765
|
+
else
|
|
766
|
+
frustum(m, sidesOf(s), r, r, h, color);
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
case "funnel": {
|
|
770
|
+
const b = dim(s.bottomRadius, "bottomRadius"), t = dim(s.topRadius, "topRadius"), h = dim(s.height, "height");
|
|
771
|
+
if (sidesOf(s) === DEFAULT_SIDES)
|
|
772
|
+
m.funnel(0, 0, 0, b, t, h, color);
|
|
773
|
+
else
|
|
774
|
+
frustum(m, sidesOf(s), b, t, h, color);
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
case "sphere": {
|
|
778
|
+
const r = dim(s.radius, "radius");
|
|
779
|
+
ellipsoid(m, sidesOf(s, DEFAULT_SPHERE_SIDES), 2 * r, 2 * r, 2 * r, color);
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
case "ellipsoid":
|
|
783
|
+
ellipsoid(m, sidesOf(s, DEFAULT_SPHERE_SIDES), dim(s.size.width, "size/width"), dim(s.size.height, "size/height"), dim(s.size.depth, "size/depth"), color);
|
|
784
|
+
break;
|
|
785
|
+
case "cone":
|
|
786
|
+
cone(m, sidesOf(s), dim(s.radius, "radius"), dim(s.height, "height"), color);
|
|
787
|
+
break;
|
|
788
|
+
case "wedge":
|
|
789
|
+
wedge(m, dim(s.size.width, "size/width"), dim(s.size.height, "size/height"), dim(s.size.depth, "size/depth"), color);
|
|
790
|
+
break;
|
|
791
|
+
case "lathe": {
|
|
792
|
+
const profile = s.profile.map((q, i) => [
|
|
793
|
+
dim(q.radius, `profile/${i}/radius`, true),
|
|
794
|
+
number(q.y, `${path}/shape/profile/${i}/y`),
|
|
795
|
+
]);
|
|
796
|
+
profile.forEach(([r, y], i) => {
|
|
797
|
+
if (i === 0)
|
|
798
|
+
return;
|
|
799
|
+
const [pr, py] = requireValue(profile[i - 1]);
|
|
800
|
+
if (y === py && r === pr)
|
|
801
|
+
stop("IMPOSSIBLE_BOUNDS", `${path}/shape/profile/${i}`, "Consecutive profile points must differ.", {
|
|
802
|
+
received: [r, y],
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
if (!profile.some(([r]) => r > 0))
|
|
806
|
+
stop("IMPOSSIBLE_BOUNDS", `${path}/shape/profile`, "At least one profile radius must be positive.");
|
|
807
|
+
const heights = profile.map(([, y]) => y);
|
|
808
|
+
if (heights.some((y) => Math.abs(y) > 1e6))
|
|
809
|
+
stop("IMPOSSIBLE_BOUNDS", `${path}/shape/profile`, "Profile heights must be within ±1000000.");
|
|
810
|
+
if (profileSelfIntersects(profile, orientProfile(profile).closed))
|
|
811
|
+
stop("SELF_INTERSECTING_PROFILE", `${path}/shape/profile`, "Profile segments must not cross or touch. Walk the outline once; repeat the first point at the end only to close a ring.");
|
|
812
|
+
lathe(m, sidesOf(s), profile, color);
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
case "extrude": {
|
|
816
|
+
const outline = s.outline.map((q, i) => [
|
|
817
|
+
number(q.x, `${path}/shape/outline/${i}/x`),
|
|
818
|
+
number(q.z, `${path}/shape/outline/${i}/z`),
|
|
819
|
+
]);
|
|
820
|
+
if (outline.some(([x, z]) => Math.abs(x) > 1e6 || Math.abs(z) > 1e6))
|
|
821
|
+
stop("IMPOSSIBLE_BOUNDS", `${path}/shape/outline`, "Outline coordinates must be within ±1000000.");
|
|
822
|
+
if (outlineSelfIntersects(outline))
|
|
823
|
+
stop("SELF_INTERSECTING_OUTLINE", `${path}/shape/outline`, "Outline edges must not cross, touch or repeat a point. List the corners once, in order around the shape.");
|
|
824
|
+
if (Math.abs(outlineArea(outline)) < 1e-12)
|
|
825
|
+
stop("IMPOSSIBLE_BOUNDS", `${path}/shape/outline`, "The outline must enclose a positive area.");
|
|
826
|
+
extrude(m, outline, dim(s.height, "height"), color);
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
case "beam":
|
|
830
|
+
case "tube": {
|
|
831
|
+
const a = point(s.from, `${path}/shape/from`), b = point(s.to, `${path}/shape/to`);
|
|
832
|
+
if (Math.hypot(...sub(b, a)) < 1e-9)
|
|
833
|
+
stop("IMPOSSIBLE_BOUNDS", `${path}/shape/to`, "Endpoints must be distinct.");
|
|
834
|
+
if (s.type === "beam")
|
|
835
|
+
m.beam(a, b, dim(s.width, "width"), color);
|
|
836
|
+
else if (sidesOf(s) === DEFAULT_SIDES)
|
|
837
|
+
m.tube(a, b, dim(s.radius, "radius"), color);
|
|
838
|
+
else {
|
|
839
|
+
const r = dim(s.radius, "radius"), shaft = new MeshBuilder();
|
|
840
|
+
frustum(shaft, sidesOf(s), r, r, Math.hypot(...sub(b, a)), color);
|
|
841
|
+
alongSegment(shaft.mesh, a, b, m);
|
|
842
|
+
}
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
case "coil": {
|
|
846
|
+
const r = dim(s.radius, "radius"), t = dim(s.thickness, "thickness");
|
|
847
|
+
if (sidesOf(s) === DEFAULT_SIDES)
|
|
848
|
+
legacyCoil(m, r, t, color);
|
|
849
|
+
else
|
|
850
|
+
coil(m, sidesOf(s), r, t, color);
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
removeDegenerateTriangles(m.mesh);
|
|
855
|
+
if (rotation)
|
|
856
|
+
transformMesh(m.mesh, rotation);
|
|
857
|
+
// libm trig/hypot may differ by an ULP between architectures. The declarative
|
|
858
|
+
// contract fixes output precision; the compatibility builder preserves raw floats.
|
|
859
|
+
for (const values of [m.mesh.positions, m.mesh.normals, m.mesh.colors])
|
|
860
|
+
for (let i = 0; i < values.length; i++)
|
|
861
|
+
values[i] = Math.round(requireValue(values[i]) * 1e12) / 1e12;
|
|
862
|
+
const invalid = validateMesh(m.mesh);
|
|
863
|
+
if (invalid.length)
|
|
864
|
+
stop("INVALID_GEOMETRY", `${path}/shape`, invalid.join(", "));
|
|
865
|
+
return m.mesh;
|
|
866
|
+
};
|
|
867
|
+
const anchors = (bounds) => {
|
|
868
|
+
const c = bounds.min.map((v, i) => (v + requireValue(bounds.max[i])) / 2);
|
|
869
|
+
return {
|
|
870
|
+
center: c,
|
|
871
|
+
"top-center": [c[0], bounds.max[1], c[2]],
|
|
872
|
+
"bottom-center": [c[0], bounds.min[1], c[2]],
|
|
873
|
+
"left-center": [bounds.min[0], c[1], c[2]],
|
|
874
|
+
"right-center": [bounds.max[0], c[1], c[2]],
|
|
875
|
+
"front-center": [c[0], c[1], bounds.min[2]],
|
|
876
|
+
"back-center": [c[0], c[1], bounds.max[2]],
|
|
877
|
+
};
|
|
878
|
+
};
|
|
879
|
+
const anchor = (map, key, path, part) => has(map, key)
|
|
880
|
+
? requireValue(map[key])
|
|
881
|
+
: stop("UNKNOWN_ANCHOR", path, "Choose an anchor exported by this part.", {
|
|
882
|
+
part,
|
|
883
|
+
received: key,
|
|
884
|
+
expected: Object.keys(map).sort(),
|
|
885
|
+
});
|
|
886
|
+
const target = (ref, path, sided = false) => {
|
|
887
|
+
const [id, suffix] = ref.split("/");
|
|
888
|
+
if (!id || !has(spec.parts, id))
|
|
889
|
+
return stop("UNKNOWN_REFERENCE", path, "Unknown target part.", { targetPart: ref });
|
|
890
|
+
const declared = requireValue(spec.parts[id]);
|
|
891
|
+
if (!active(id))
|
|
892
|
+
return stop("INACTIVE_REFERENCE", path, `Part ${id} exists only when slot ${declared.slot} selects ${declared.option}.`, {
|
|
893
|
+
targetPart: ref,
|
|
894
|
+
received: slots[requireValue(declared.slot)],
|
|
895
|
+
expected: [requireValue(declared.option)],
|
|
896
|
+
});
|
|
897
|
+
if (sided && suffix === undefined && declared.sides === "both")
|
|
898
|
+
ref = `${id}/left`;
|
|
899
|
+
const choices = build(id), selected = choices.find((v) => v.id === ref);
|
|
900
|
+
if (selected)
|
|
901
|
+
return selected;
|
|
902
|
+
return stop(suffix === undefined ? "AMBIGUOUS_TARGET" : "UNKNOWN_REFERENCE", path, declared.sides === "both"
|
|
903
|
+
? `Reference a side explicitly, for example ${id}/left.`
|
|
904
|
+
: "Reference a stable instance ID, for example legs/0.", { targetPart: ref, expected: choices.map((v) => v.id) });
|
|
905
|
+
};
|
|
906
|
+
/** Turns applied to radial array instances, by instance ID; copied joints turn their axes with them. */
|
|
907
|
+
const instanceTurns = new Map();
|
|
908
|
+
/** Copy a local mesh with a rotation followed by a translation, at the declarative precision. */
|
|
909
|
+
const placeTurned = (mesh, turn, t) => {
|
|
910
|
+
const out = transformMesh(structuredClone(mesh), compose(moveBy(t), turn));
|
|
911
|
+
for (const values of [out.positions, out.normals])
|
|
912
|
+
for (let i = 0; i < values.length; i++)
|
|
913
|
+
values[i] = quantize(requireValue(values[i]));
|
|
914
|
+
return out;
|
|
915
|
+
};
|
|
916
|
+
/**
|
|
917
|
+
* Translations (and turns) for every array instance. `base` places the part as its placement
|
|
918
|
+
* describes, and `self` is its local selfAnchor, so `base + self` is the placement point.
|
|
919
|
+
*/
|
|
920
|
+
const expandArray = (part, path, base, self) => {
|
|
921
|
+
const a = requireValue(part.array), ap = `${path}/array`;
|
|
922
|
+
if (a.type === "line" || a.type === "grid") {
|
|
923
|
+
const counts = a.type === "line" ? [a.count, 1, 1] : [a.count.x ?? 1, a.count.y ?? 1, a.count.z ?? 1], step = a.type === "line"
|
|
924
|
+
? point(a.step, `${ap}/step`)
|
|
925
|
+
: ["x", "y", "z"].map((k) => {
|
|
926
|
+
const v = a.spacing[k];
|
|
927
|
+
return v === undefined ? 0 : number(v, `${ap}/spacing/${k}`);
|
|
928
|
+
});
|
|
929
|
+
if (a.type === "line" && step.every((v) => v === 0))
|
|
930
|
+
stop("IMPOSSIBLE_BOUNDS", `${ap}/step`, "A line step must be nonzero so instances do not coincide.");
|
|
931
|
+
if (a.type === "grid")
|
|
932
|
+
["x", "y", "z"].forEach((k, axis) => {
|
|
933
|
+
if (requireValue(counts[axis]) > 1 && step[axis] === 0)
|
|
934
|
+
stop("IMPOSSIBLE_BOUNDS", `${ap}/spacing/${k}`, `Give a nonzero ${k} spacing for ${counts[axis]} instances.`);
|
|
935
|
+
});
|
|
936
|
+
const shift = counts.map((c) => (a.centered ? (c - 1) / 2 : 0)), offsets = [];
|
|
937
|
+
// Grid order matches corner repetition: X outermost, then Y, then Z.
|
|
938
|
+
for (let ix = 0; ix < counts[0]; ix++)
|
|
939
|
+
for (let iy = 0; iy < counts[1]; iy++)
|
|
940
|
+
for (let iz = 0; iz < counts[2]; iz++) {
|
|
941
|
+
const along = a.type === "line"
|
|
942
|
+
? [ix - requireValue(shift[0]), ix - requireValue(shift[0]), ix - requireValue(shift[0])]
|
|
943
|
+
: [ix - requireValue(shift[0]), iy - requireValue(shift[1]), iz - requireValue(shift[2])];
|
|
944
|
+
offsets.push([
|
|
945
|
+
base[0] + step[0] * along[0],
|
|
946
|
+
base[1] + step[1] * along[1],
|
|
947
|
+
base[2] + step[2] * along[2],
|
|
948
|
+
]);
|
|
949
|
+
}
|
|
950
|
+
return [offsets, offsets.map(() => null)];
|
|
951
|
+
}
|
|
952
|
+
const axis = a.axis ?? "y", radius = positive(a.radius, `${ap}/radius`, true), start = a.start === undefined ? 0 : number(a.start, `${ap}/start`), sweep = a.sweep === undefined ? 360 : number(a.sweep, `${ap}/sweep`);
|
|
953
|
+
if (!(Math.abs(sweep) > 0 && Math.abs(sweep) <= 360))
|
|
954
|
+
stop("IMPOSSIBLE_BOUNDS", `${ap}/sweep`, "Sweep must be nonzero degrees from −360 to 360.", {
|
|
955
|
+
received: sweep,
|
|
956
|
+
});
|
|
957
|
+
if (Math.abs(start) > 360)
|
|
958
|
+
stop("INVALID_ROTATION", `${ap}/start`, "Start is degrees from −360 to 360.", { received: start });
|
|
959
|
+
const full = Math.abs(sweep) === 360, reference = axis === "x" ? [0, 1, 0] : [1, 0, 0], center = add(base, self), offsets = [], turns = [];
|
|
960
|
+
for (let k = 0; k < a.count; k++) {
|
|
961
|
+
const turn = axisRotation(axis, start + (k * sweep) / (full ? a.count : a.count - 1)), out = applyPoint(turn, reference).map((v, i) => requireValue(center[i]) + radius * v);
|
|
962
|
+
if (a.orient === false) {
|
|
963
|
+
offsets.push(sub(out, self).map(quantize));
|
|
964
|
+
turns.push(null);
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
// Turn the part about its selfAnchor, then move that anchor onto the circle.
|
|
968
|
+
offsets.push(sub(out, applyPoint(turn, self)).map(quantize));
|
|
969
|
+
turns.push(isIdentity(turn) ? null : turn);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return [offsets, turns];
|
|
973
|
+
};
|
|
974
|
+
const build = (id) => {
|
|
975
|
+
if (built.has(id))
|
|
976
|
+
return requireValue(built.get(id));
|
|
977
|
+
const path = `/parts/${pointer(id)}`, part = requireValue(spec.parts[id]);
|
|
978
|
+
if (visiting.has(id))
|
|
979
|
+
return stop("DEPENDENCY_CYCLE", `${path}/placement/target/part`, "Cyclic attachment dependency.", {
|
|
980
|
+
part: id,
|
|
981
|
+
});
|
|
982
|
+
visiting.add(id);
|
|
983
|
+
const p = part.placement, rotation = partRotation(part, path), mesh = localMesh(part, path, rotation), bounds = meshBounds(mesh), own = anchors(bounds);
|
|
984
|
+
for (const [name, v] of Object.entries(part.anchors ?? {})) {
|
|
985
|
+
if (has(own, name))
|
|
986
|
+
stop("CONFLICTING_ANCHOR", `${path}/anchors/${name}`, "Built-in anchors cannot be overridden.");
|
|
987
|
+
const local = point(v, `${path}/anchors/${name}`);
|
|
988
|
+
own[name] = rotation ? applyPoint(rotation, local).map(quantize) : local;
|
|
989
|
+
}
|
|
990
|
+
const self = anchor(own, p.selfAnchor, `${path}/placement/selfAnchor`, id), parent = group(part.parent), sided = v2 && part.sides === "both";
|
|
991
|
+
let offsets, binding = null;
|
|
992
|
+
// A bare copied joint on an arrayed part binds instance k to copy k (resolved per instance below).
|
|
993
|
+
const copied = v3 && part.joint !== undefined && !part.joint.includes("/") && copiesOf(part.joint) > 0;
|
|
994
|
+
if (v2 && part.joint !== undefined && !copied)
|
|
995
|
+
binding = bindJoint(part.joint, `${path}/joint`, sided ? "left" : null);
|
|
996
|
+
let when = {};
|
|
997
|
+
for (const [channel, values] of Object.entries(part.when ?? {}))
|
|
998
|
+
when[channel] = [...values];
|
|
999
|
+
if (p.type === "absolute")
|
|
1000
|
+
offsets = [sub(add(parent, point(p.position, `${path}/placement/position`)), self)];
|
|
1001
|
+
else {
|
|
1002
|
+
const t = target(p.target.part, `${path}/placement/target/part`, sided);
|
|
1003
|
+
// Attached and repeated parts follow their target's joint unless they declare their own.
|
|
1004
|
+
if (v2 && part.joint === undefined)
|
|
1005
|
+
binding = t.joint ?? null;
|
|
1006
|
+
// ...and are visible only while their target is.
|
|
1007
|
+
for (const [channel, values] of Object.entries(t.when ?? {})) {
|
|
1008
|
+
const merged = when[channel] ? when[channel].filter((v) => values.includes(v)) : [...values];
|
|
1009
|
+
if (!merged.length)
|
|
1010
|
+
stop("CONTRADICTORY_STATES", `${path}/when/${pointer(channel)}`, `This part would never be visible: its target ${t.id} is visible only in ${values.join(", ")}.`, { part: id, targetPart: t.id, received: when[channel], expected: values });
|
|
1011
|
+
when[channel] = merged;
|
|
1012
|
+
}
|
|
1013
|
+
if (p.type === "attach")
|
|
1014
|
+
offsets = [
|
|
1015
|
+
sub(add(anchor(t.anchors, p.target.anchor, `${path}/placement/target/anchor`, t.part), p.offset ? point(p.offset, `${path}/placement/offset`) : [0, 0, 0]), self),
|
|
1016
|
+
];
|
|
1017
|
+
else {
|
|
1018
|
+
const x = positive(p.edgeInset.x, `${path}/placement/edgeInset/x`, true), z = positive(p.edgeInset.z, `${path}/placement/edgeInset/z`, true);
|
|
1019
|
+
const w = bounds.max[0] - bounds.min[0], d = bounds.max[2] - bounds.min[2];
|
|
1020
|
+
if (t.bounds.max[0] - t.bounds.min[0] < 2 * x + 2 * w - 1e-10 ||
|
|
1021
|
+
t.bounds.max[2] - t.bounds.min[2] < 2 * z + 2 * d - 1e-10)
|
|
1022
|
+
stop("IMPOSSIBLE_BOUNDS", `${path}/placement/edgeInset`, "Target cannot contain four distinct supports with these insets and bounds.", { part: id, targetPart: t.part });
|
|
1023
|
+
const y = (p.target.face === "top" ? t.bounds.max[1] : t.bounds.min[1]) - self[1];
|
|
1024
|
+
offsets = [];
|
|
1025
|
+
for (const sx of [-1, 1])
|
|
1026
|
+
for (const sz of [-1, 1])
|
|
1027
|
+
offsets.push([
|
|
1028
|
+
sx < 0 ? t.bounds.min[0] + x - bounds.min[0] : t.bounds.max[0] - x - bounds.max[0],
|
|
1029
|
+
y,
|
|
1030
|
+
sz < 0 ? t.bounds.min[2] + z - bounds.min[2] : t.bounds.max[2] - z - bounds.max[2],
|
|
1031
|
+
]);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
// Schema 0.3 arrays copy the placed part; a radial array may also turn each copy.
|
|
1035
|
+
let turns = offsets.map(() => null);
|
|
1036
|
+
if (v3 && part.array)
|
|
1037
|
+
[offsets, turns] = expandArray(part, path, requireValue(offsets[0]), self);
|
|
1038
|
+
if (copied) {
|
|
1039
|
+
const copies = copiesOf(requireValue(part.joint));
|
|
1040
|
+
if (!part.array || offsets.length !== copies)
|
|
1041
|
+
stop("JOINT_ARRAY_MISMATCH", `${path}/joint`, `Joint ${part.joint} has ${copies} copies, one per instance of its pivot part; bind it from an array with ${copies} instances, or name one copy such as ${part.joint}/0.`, { part: id, received: offsets.length, expected: [String(copies)] });
|
|
1042
|
+
}
|
|
1043
|
+
instanceCount += offsets.length * (sided ? 2 : 1);
|
|
1044
|
+
triangles += (mesh.indices.length / 3) * offsets.length * (sided ? 2 : 1);
|
|
1045
|
+
if (instanceCount > limits.instances || triangles > limits.triangles)
|
|
1046
|
+
stop("GEOMETRY_BUDGET", path, "Asset exceeds its instance or triangle budget.");
|
|
1047
|
+
const instances = offsets.map((translation, i) => {
|
|
1048
|
+
const turn = turns[i] ?? null;
|
|
1049
|
+
if (turn)
|
|
1050
|
+
instanceTurns.set(`${id}/${i}`, turn);
|
|
1051
|
+
const m = turn ? placeTurned(mesh, turn, translation) : appendMesh(emptyMesh(), mesh, translation), b = meshBounds(m), a = Object.fromEntries(Object.entries(own).map(([k, v]) => [
|
|
1052
|
+
k,
|
|
1053
|
+
turn
|
|
1054
|
+
? applyPoint(compose(moveBy(translation), turn), v).map(quantize)
|
|
1055
|
+
: add(v, translation),
|
|
1056
|
+
]));
|
|
1057
|
+
if (m.positions.some((v) => !Number.isFinite(v)))
|
|
1058
|
+
stop("NON_FINITE_GEOMETRY", path, "Resolved coordinates overflow.");
|
|
1059
|
+
return {
|
|
1060
|
+
id: p.type === "repeat" || (v3 && part.array) ? `${id}/${i}` : sided ? `${id}/left` : id,
|
|
1061
|
+
part: id,
|
|
1062
|
+
material: part.material,
|
|
1063
|
+
translation,
|
|
1064
|
+
bounds: b,
|
|
1065
|
+
anchors: a,
|
|
1066
|
+
mesh: m,
|
|
1067
|
+
...(v2 ? { joint: copied ? `${part.joint}/${i}` : binding } : {}),
|
|
1068
|
+
...(v2 && Object.keys(when).length ? { when: structuredClone(when) } : {}),
|
|
1069
|
+
};
|
|
1070
|
+
});
|
|
1071
|
+
if (sided) {
|
|
1072
|
+
const left = requireValue(instances[0]), center = (left.bounds.min[0] + left.bounds.max[0]) / 2;
|
|
1073
|
+
if (!(center < -1e-9))
|
|
1074
|
+
stop("SIDE_MISMATCH", `${path}/placement`, "Sided parts describe the character's left side, which is −X (characters face −Z). Place this part at X < 0.", { part: id, received: center });
|
|
1075
|
+
instances.push(mirrorInstance(left, `${id}/right`));
|
|
1076
|
+
}
|
|
1077
|
+
visiting.delete(id);
|
|
1078
|
+
built.set(id, instances);
|
|
1079
|
+
return instances;
|
|
1080
|
+
};
|
|
1081
|
+
for (const id of Object.keys(spec.parts).sort())
|
|
1082
|
+
if (active(id))
|
|
1083
|
+
build(id);
|
|
1084
|
+
const rest = Object.fromEntries(Object.entries(spec.states ?? {}).map(([k, v]) => [k, requireValue(v[0])]));
|
|
1085
|
+
const visible = (i) => Object.entries(i.when ?? {}).every(([channel, values]) => values.includes(requireValue(rest[channel])));
|
|
1086
|
+
const everything = [...built.values()].flat().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)), instances = everything.filter(visible), alternates = everything.filter((i) => !visible(i)), layers = Object.create(null), all = emptyMesh();
|
|
1087
|
+
let skeleton;
|
|
1088
|
+
const limbs = [];
|
|
1089
|
+
if (v2) {
|
|
1090
|
+
const compiled = new Map(), stack = new Set();
|
|
1091
|
+
const compileJoint = (ref) => {
|
|
1092
|
+
if (compiled.has(ref))
|
|
1093
|
+
return requireValue(compiled.get(ref));
|
|
1094
|
+
const [id, suffix] = ref.split("/"), copy = suffix !== undefined && /^[0-9]+$/.test(suffix) ? Number(suffix) : null, side = copy === null ? suffix : null, joint = requireValue(requireValue(spec.joints)[id]), path = `/joints/${pointer(id)}`;
|
|
1095
|
+
if (stack.has(ref))
|
|
1096
|
+
return stop("DEPENDENCY_CYCLE", `${path}/parent`, "Cyclic joint hierarchy.");
|
|
1097
|
+
stack.add(ref);
|
|
1098
|
+
if (copy !== null && joint.sides === "both")
|
|
1099
|
+
stop("UNSUPPORTED_COMBINATION", `${path}/sides`, "A joint copied per array instance cannot also be sided.");
|
|
1100
|
+
let parent = null;
|
|
1101
|
+
if (joint.parent !== undefined) {
|
|
1102
|
+
const left = bindJoint(joint.parent, `${path}/parent`, side ? "left" : null, copy, copy === null ? null : copiesOf(id));
|
|
1103
|
+
parent = side === "right" ? swapSide(left) : left;
|
|
1104
|
+
compileJoint(parent);
|
|
1105
|
+
}
|
|
1106
|
+
let pivot, turn = null;
|
|
1107
|
+
if (side === "right")
|
|
1108
|
+
pivot = mirrorPoint(compileJoint(`${id}/left`).pivot);
|
|
1109
|
+
else if ("position" in joint.pivot)
|
|
1110
|
+
pivot = point(joint.pivot.position, `${path}/pivot/position`);
|
|
1111
|
+
else {
|
|
1112
|
+
const part = copy === null ? joint.pivot.part : `${joint.pivot.part}/${copy}`, t = target(part, `${path}/pivot/part`, side === "left");
|
|
1113
|
+
pivot = anchor(t.anchors, joint.pivot.anchor, `${path}/pivot/anchor`, t.part);
|
|
1114
|
+
if (copy !== null)
|
|
1115
|
+
turn = instanceTurns.get(t.id) ?? null;
|
|
1116
|
+
}
|
|
1117
|
+
// A pivot on the mirror plane is allowed (e.g. mouth corners turning about the mouth center).
|
|
1118
|
+
if (side === "left" && pivot[0] > 1e-9)
|
|
1119
|
+
stop("SIDE_MISMATCH", `${path}/pivot`, "Sided joints describe the character's left side, which is −X. Place the pivot at X ≤ 0.", { received: pivot[0] });
|
|
1120
|
+
const result = {
|
|
1121
|
+
id: ref,
|
|
1122
|
+
joint: id,
|
|
1123
|
+
...(side ? { side } : {}),
|
|
1124
|
+
...(copy !== null ? { copy } : {}),
|
|
1125
|
+
...(joint.face ? { face: true } : {}),
|
|
1126
|
+
parent,
|
|
1127
|
+
pivot,
|
|
1128
|
+
dofs: joint.dofs.map((d) => {
|
|
1129
|
+
// A copy of a radially oriented array turns its axes with its instance.
|
|
1130
|
+
const a = turn ? applyVector(turn, AXES[d.axis]).map(quantize) : AXES[d.axis];
|
|
1131
|
+
return {
|
|
1132
|
+
name: d.name,
|
|
1133
|
+
axis: side === "right" ? [a[0], neg(a[1]), neg(a[2])] : [a[0], a[1], a[2]],
|
|
1134
|
+
limits: [d.limits[0], d.limits[1]],
|
|
1135
|
+
...(v3 && d.continuous ? { continuous: true } : {}),
|
|
1136
|
+
};
|
|
1137
|
+
}),
|
|
1138
|
+
};
|
|
1139
|
+
stack.delete(ref);
|
|
1140
|
+
compiled.set(ref, result);
|
|
1141
|
+
return result;
|
|
1142
|
+
};
|
|
1143
|
+
for (const id of jointIds())
|
|
1144
|
+
compileJoint(id);
|
|
1145
|
+
// Limbs: chains a clip may solve. Sided limbs resolve bare sided references to the same side.
|
|
1146
|
+
for (const id of Object.keys(spec.limbs ?? {}).sort()) {
|
|
1147
|
+
const limb = requireValue(requireValue(spec.limbs)[id]), path = `/limbs/${pointer(id)}`;
|
|
1148
|
+
for (const side of limb.sides === "both" ? ["left", "right"] : [null]) {
|
|
1149
|
+
const swap = (ref) => (side === "right" ? swapSide(ref) : ref);
|
|
1150
|
+
const dof = (r, rp) => {
|
|
1151
|
+
const joint = requireValue(compiled.get(swap(bindJoint(r.joint, `${rp}/joint`, side ? "left" : null))));
|
|
1152
|
+
if (!joint.dofs.some((d) => d.name === r.dof))
|
|
1153
|
+
stop("UNKNOWN_DOF", `${rp}/dof`, `Choose a degree of freedom exported by joint ${joint.joint}.`, {
|
|
1154
|
+
received: r.dof,
|
|
1155
|
+
expected: joint.dofs.map((d) => d.name),
|
|
1156
|
+
});
|
|
1157
|
+
return { joint: joint.id, dof: r.dof };
|
|
1158
|
+
};
|
|
1159
|
+
const leftEffector = target(limb.effector.part, `${path}/effector/part`, side !== null), effector = side === "right" ? swapSide(leftEffector.id) : leftEffector.id;
|
|
1160
|
+
anchor(leftEffector.anchors, limb.effector.anchor, `${path}/effector/anchor`, leftEffector.part);
|
|
1161
|
+
const reach = limb.reach.map((r, i) => dof(r, `${path}/reach/${i}`)), level = limb.level ? dof(limb.level, `${path}/level`) : null;
|
|
1162
|
+
const bound = requireValue(everything.find((i) => i.id === effector)).joint ?? null, chain = [];
|
|
1163
|
+
for (let j = bound; j !== null; j = requireValue(compiled.get(j)).parent)
|
|
1164
|
+
chain.push(j);
|
|
1165
|
+
for (const [k, r] of [
|
|
1166
|
+
...reach.map((r, i) => [`reach/${i}`, r]),
|
|
1167
|
+
...(level ? [["level", level]] : []),
|
|
1168
|
+
])
|
|
1169
|
+
if (!chain.includes(r.joint))
|
|
1170
|
+
stop("LIMB_CHAIN", `${path}/${k}/joint`, `Joint ${r.joint} does not move effector ${effector}; choose the effector's joint or one of its ancestors.`, { received: r.joint, expected: chain });
|
|
1171
|
+
limbs.push({
|
|
1172
|
+
id: side ? `${id}/${side}` : id,
|
|
1173
|
+
effector,
|
|
1174
|
+
anchor: limb.effector.anchor,
|
|
1175
|
+
reach,
|
|
1176
|
+
...(level ? { level } : {}),
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
const depth = (j) => j.parent === null ? 0 : 1 + depth(requireValue(compiled.get(j.parent)));
|
|
1181
|
+
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]));
|
|
1182
|
+
for (const instance of everything)
|
|
1183
|
+
instance.mesh.joints = new Array(instance.mesh.positions.length / 3).fill(instance.joint ? requireValue(index.get(instance.joint)) : -1);
|
|
1184
|
+
skeleton = { joints };
|
|
1185
|
+
}
|
|
1186
|
+
for (const instance of instances) {
|
|
1187
|
+
if (!has(layers, instance.material))
|
|
1188
|
+
layers[instance.material] = emptyMesh();
|
|
1189
|
+
appendMesh(requireValue(layers[instance.material]), instance.mesh);
|
|
1190
|
+
appendMesh(all, instance.mesh);
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
schema: v3 ? SCHEMA_V3 : v2 ? SCHEMA_V2 : SCHEMA,
|
|
1194
|
+
units: spec.units,
|
|
1195
|
+
sourceHash: identity === null ? sourceHash(spec) : sourceHash({ identity, spec }),
|
|
1196
|
+
layers,
|
|
1197
|
+
materials,
|
|
1198
|
+
instances,
|
|
1199
|
+
bounds: meshBounds(all),
|
|
1200
|
+
groundPivot: spec.groundPivot ? point(spec.groundPivot, "/groundPivot") : [0, 0, 0],
|
|
1201
|
+
// Budgets count every instance; the reported triangles are those visible in the rest states.
|
|
1202
|
+
triangles: alternates.length ? all.indices.length / 3 : triangles,
|
|
1203
|
+
...(v2
|
|
1204
|
+
? { skeleton, identity, slots, states: structuredClone(spec.states ?? {}), alternates, limbs }
|
|
1205
|
+
: {}),
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
export function compileAsset(input, options = {}) {
|
|
1209
|
+
const diagnostics = schemaDiagnostics(input);
|
|
1210
|
+
if (diagnostics.length)
|
|
1211
|
+
throw new AssetCompileError(diagnostics);
|
|
1212
|
+
try {
|
|
1213
|
+
return resolve(input, options);
|
|
1214
|
+
}
|
|
1215
|
+
catch (error) {
|
|
1216
|
+
if (error instanceof AssetCompileError) {
|
|
1217
|
+
if (options.identity !== undefined)
|
|
1218
|
+
throw new AssetCompileError(error.diagnostics.map((d) => ({ ...d, identity: options.identity })));
|
|
1219
|
+
throw error;
|
|
1220
|
+
}
|
|
1221
|
+
throw new AssetCompileError([
|
|
1222
|
+
{
|
|
1223
|
+
code: "INVALID_SPEC",
|
|
1224
|
+
path: "",
|
|
1225
|
+
message: error instanceof Error ? error.message : "Invalid asset specification.",
|
|
1226
|
+
},
|
|
1227
|
+
]);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Returns [] when the description compiles. For schema 0.2 without an explicit identity, the base
|
|
1232
|
+
* description and every declared identity are compiled; diagnostics carry the identity they came from.
|
|
1233
|
+
*/
|
|
1234
|
+
export function validateAsset(input, options = {}) {
|
|
1235
|
+
const attempt = (identity) => {
|
|
1236
|
+
try {
|
|
1237
|
+
compileAsset(input, identity === undefined ? options : { ...options, identity });
|
|
1238
|
+
return [];
|
|
1239
|
+
}
|
|
1240
|
+
catch (error) {
|
|
1241
|
+
if (error instanceof AssetCompileError)
|
|
1242
|
+
return error.diagnostics;
|
|
1243
|
+
throw error;
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
const base = attempt(options.identity);
|
|
1247
|
+
if (options.identity !== undefined ||
|
|
1248
|
+
!record(input) ||
|
|
1249
|
+
(input.schema !== SCHEMA_V2 && input.schema !== SCHEMA_V3) ||
|
|
1250
|
+
!record(input.identities) ||
|
|
1251
|
+
schemaDiagnostics(input).length)
|
|
1252
|
+
return base;
|
|
1253
|
+
const all = [...base], seen = new Set(all.map((d) => JSON.stringify(d)));
|
|
1254
|
+
for (const id of Object.keys(input.identities).sort())
|
|
1255
|
+
for (const d of attempt(id)) {
|
|
1256
|
+
const { identity: _identity, ...withoutIdentity } = d;
|
|
1257
|
+
const key = JSON.stringify(withoutIdentity);
|
|
1258
|
+
if (seen.has(key))
|
|
1259
|
+
continue;
|
|
1260
|
+
seen.add(key);
|
|
1261
|
+
all.push(d);
|
|
1262
|
+
}
|
|
1263
|
+
return all;
|
|
1264
|
+
}
|