@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/browser.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { requireValue } from "./required.js";
|
|
2
|
+
import { appendMesh, emptyMesh, meshForAsset } from "./mesh.js";
|
|
3
|
+
import { bakeMesh } from "./bake.js";
|
|
4
|
+
import { cross, dot, normalize } from "./math.js";
|
|
5
|
+
import { poseAsset, posedMesh, samplePose } from "./motion.js";
|
|
6
|
+
export const assetMesh = meshForAsset;
|
|
7
|
+
const DRAG_RADIANS_PER_PIXEL = 0.012;
|
|
8
|
+
const KEY_RADIANS = Math.PI / 12;
|
|
9
|
+
const clampPitch = (value) => Math.max(0.05, Math.min(1.5, value));
|
|
10
|
+
/**
|
|
11
|
+
* A CPU preview with optional scheduled clip playback. Call dispose when removed.
|
|
12
|
+
*
|
|
13
|
+
* Dragging grabs the model: it turns with the pointer horizontally and tilts vertically. When the
|
|
14
|
+
* canvas has focus, arrow keys orbit the same way (Shift for finer steps) and Home restores the
|
|
15
|
+
* initial view.
|
|
16
|
+
*/
|
|
17
|
+
export function createPreview(canvas, options = {}) {
|
|
18
|
+
const context = canvas.getContext("2d");
|
|
19
|
+
if (!context)
|
|
20
|
+
throw new Error("Canvas 2D is unavailable");
|
|
21
|
+
const size = options.size ?? 360;
|
|
22
|
+
const maxTriangles = options.maxTriangles ?? 20_000;
|
|
23
|
+
if (!Number.isInteger(maxTriangles) || maxTriangles < 1)
|
|
24
|
+
throw new RangeError("Invalid preview triangle budget");
|
|
25
|
+
if (!Number.isInteger(size) || size < 64 || size > 768)
|
|
26
|
+
throw new RangeError("Preview size must be 64–768 pixels");
|
|
27
|
+
const frameCacheBytes = options.frameCacheBytes ?? 24 * 1024 * 1024;
|
|
28
|
+
if (!Number.isSafeInteger(frameCacheBytes) || frameCacheBytes < 0)
|
|
29
|
+
throw new RangeError("Invalid preview frame cache budget");
|
|
30
|
+
canvas.width = size;
|
|
31
|
+
canvas.height = size;
|
|
32
|
+
let asset = null, tint = null, mesh = null, frame = null, basis = null;
|
|
33
|
+
const initialYaw = options.yaw ?? Math.atan2(24, -26), initialPitch = options.pitch ?? Math.atan2(28, Math.hypot(24, 26));
|
|
34
|
+
let yaw = initialYaw, pitch = initialPitch, disposed = false, drag = null, moved = false;
|
|
35
|
+
let ppu = 1, ox = 0, oy = 0, framing = null, pickOffset = [0, 0, 0];
|
|
36
|
+
let playback = null;
|
|
37
|
+
const frameCache = new Map();
|
|
38
|
+
let cacheBytes = 0, cacheConfig = "", currentPose = null, rendered = null;
|
|
39
|
+
const invalidateFrames = () => {
|
|
40
|
+
frameCache.clear();
|
|
41
|
+
cacheBytes = 0;
|
|
42
|
+
};
|
|
43
|
+
const identities = new WeakMap();
|
|
44
|
+
let nextIdentity = 0;
|
|
45
|
+
const identity = (value) => {
|
|
46
|
+
if (!identities.has(value))
|
|
47
|
+
identities.set(value, ++nextIdentity);
|
|
48
|
+
return requireValue(identities.get(value));
|
|
49
|
+
};
|
|
50
|
+
const draw = (value) => {
|
|
51
|
+
({ frame, basis, ppu, ox, oy } = value);
|
|
52
|
+
context.clearRect(0, 0, size, size);
|
|
53
|
+
if (value.image)
|
|
54
|
+
context.putImageData(value.image, ox, oy);
|
|
55
|
+
canvas.dataset.rendered = "true";
|
|
56
|
+
};
|
|
57
|
+
const playbackMesh = () => {
|
|
58
|
+
const p = requireValue(playback), combined = emptyMesh();
|
|
59
|
+
appendMesh(combined, posedMesh(p.source, requireValue(currentPose), p.options.tint), p.options.offset ?? [0, 0, 0]);
|
|
60
|
+
for (const prop of p.options.props ?? [])
|
|
61
|
+
appendMesh(combined, meshForAsset(prop.asset, prop.tint), prop.offset ?? [0, 0, 0]);
|
|
62
|
+
if (combined.indices.length / 3 > maxTriangles)
|
|
63
|
+
throw new RangeError("Preview triangle budget exceeded");
|
|
64
|
+
return combined;
|
|
65
|
+
};
|
|
66
|
+
const render = () => {
|
|
67
|
+
if (disposed)
|
|
68
|
+
return;
|
|
69
|
+
if (!mesh && playback && currentPose)
|
|
70
|
+
mesh = playbackMesh();
|
|
71
|
+
if (!mesh)
|
|
72
|
+
return;
|
|
73
|
+
const back = [Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)], right = normalize(cross([0, 1, 0], back));
|
|
74
|
+
basis = { back, right, up: cross(back, right) };
|
|
75
|
+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
76
|
+
const extend = (p) => {
|
|
77
|
+
const x = dot(p, requireValue(basis).right), y = dot(p, requireValue(basis).up);
|
|
78
|
+
minX = Math.min(minX, x);
|
|
79
|
+
maxX = Math.max(maxX, x);
|
|
80
|
+
minY = Math.min(minY, y);
|
|
81
|
+
maxY = Math.max(maxY, y);
|
|
82
|
+
};
|
|
83
|
+
// A playing clip uses fixed framing so the camera does not rescale as limbs move.
|
|
84
|
+
if (framing)
|
|
85
|
+
for (const x of [framing.min[0], framing.max[0]])
|
|
86
|
+
for (const y of [framing.min[1], framing.max[1]])
|
|
87
|
+
for (const z of [framing.min[2], framing.max[2]])
|
|
88
|
+
extend([x, y, z]);
|
|
89
|
+
else
|
|
90
|
+
for (let i = 0; i < mesh.positions.length; i += 3)
|
|
91
|
+
extend(mesh.positions.slice(i, i + 3));
|
|
92
|
+
const extent = Math.max(maxX - minX, maxY - minY);
|
|
93
|
+
if (!Number.isFinite(extent) || extent <= 0)
|
|
94
|
+
throw new RangeError("Preview requires nonempty geometry");
|
|
95
|
+
ppu = (size - 32) / extent;
|
|
96
|
+
const viewport = framing
|
|
97
|
+
? {
|
|
98
|
+
left: -Math.round((size - (maxX - minX) * ppu) / 2 - minX * ppu),
|
|
99
|
+
top: Math.round((size - (maxY - minY) * ppu) / 2 + maxY * ppu),
|
|
100
|
+
width: size,
|
|
101
|
+
height: size,
|
|
102
|
+
}
|
|
103
|
+
: null;
|
|
104
|
+
const raster = bakeMesh(mesh, ppu, 1, basis, viewport);
|
|
105
|
+
const { color, depth: _depth, ...metadata } = raster;
|
|
106
|
+
frame = metadata;
|
|
107
|
+
if (viewport) {
|
|
108
|
+
ox = Math.round(frame.left * ppu) - viewport.left;
|
|
109
|
+
oy = viewport.top - Math.round(frame.top * ppu);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
ox = Math.floor((size - frame.width) / 2);
|
|
113
|
+
oy = Math.floor((size - frame.height) / 2);
|
|
114
|
+
}
|
|
115
|
+
rendered = {
|
|
116
|
+
frame,
|
|
117
|
+
basis,
|
|
118
|
+
ppu,
|
|
119
|
+
ox,
|
|
120
|
+
oy,
|
|
121
|
+
image: frame.width && frame.height
|
|
122
|
+
? new ImageData(new Uint8ClampedArray(color.buffer), frame.width, frame.height)
|
|
123
|
+
: null,
|
|
124
|
+
};
|
|
125
|
+
draw(rendered);
|
|
126
|
+
};
|
|
127
|
+
const pick = (x, y) => {
|
|
128
|
+
if (disposed || !basis || !frame)
|
|
129
|
+
return null;
|
|
130
|
+
// Picking needs instance identity; material-only animation meshes do not.
|
|
131
|
+
if (!asset && playback && currentPose)
|
|
132
|
+
asset = poseAsset(playback.source, currentPose);
|
|
133
|
+
if (!asset)
|
|
134
|
+
return null;
|
|
135
|
+
const atX = (x - ox) / ppu + frame.left, atY = frame.top - (y - oy) / ppu;
|
|
136
|
+
let best = -Infinity, id = null;
|
|
137
|
+
for (const instance of asset.instances)
|
|
138
|
+
for (let i = 0; i < instance.mesh.indices.length; i += 3) {
|
|
139
|
+
const ps = instance.mesh.indices.slice(i, i + 3).map((index) => {
|
|
140
|
+
const s = instance.mesh.positions, p = [
|
|
141
|
+
requireValue(s[index * 3]) + pickOffset[0],
|
|
142
|
+
requireValue(s[index * 3 + 1]) + pickOffset[1],
|
|
143
|
+
requireValue(s[index * 3 + 2]) + pickOffset[2],
|
|
144
|
+
];
|
|
145
|
+
return [
|
|
146
|
+
dot(p, requireValue(basis).right),
|
|
147
|
+
dot(p, requireValue(basis).up),
|
|
148
|
+
dot(p, requireValue(basis).back),
|
|
149
|
+
];
|
|
150
|
+
});
|
|
151
|
+
const [a, b, c] = ps, area = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
|
|
152
|
+
if (Math.abs(area) < 1e-12)
|
|
153
|
+
continue;
|
|
154
|
+
const u = ((b[1] - c[1]) * (atX - c[0]) + (c[0] - b[0]) * (atY - c[1])) / area, v = ((c[1] - a[1]) * (atX - c[0]) + (a[0] - c[0]) * (atY - c[1])) / area, w = 1 - u - v;
|
|
155
|
+
const depth = u * a[2] + v * b[2] + w * c[2];
|
|
156
|
+
if (u >= 0 && v >= 0 && w >= 0 && depth > best) {
|
|
157
|
+
best = depth;
|
|
158
|
+
id = instance.id;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return id;
|
|
162
|
+
};
|
|
163
|
+
/** Turn the camera; the next frame is rendered from the new view. */
|
|
164
|
+
const orbit = (deltaYaw, deltaPitch) => {
|
|
165
|
+
const nextPitch = clampPitch(pitch + deltaPitch);
|
|
166
|
+
if (!deltaYaw && nextPitch === pitch)
|
|
167
|
+
return;
|
|
168
|
+
invalidateFrames();
|
|
169
|
+
yaw += deltaYaw;
|
|
170
|
+
pitch = nextPitch;
|
|
171
|
+
render();
|
|
172
|
+
};
|
|
173
|
+
// Grab-to-rotate: the model's near side follows the pointer, horizontally and vertically.
|
|
174
|
+
const down = (e) => {
|
|
175
|
+
if (disposed || drag || (e.pointerType === "mouse" && e.button !== 0))
|
|
176
|
+
return;
|
|
177
|
+
drag = { pointer: e.pointerId, x: e.clientX, y: e.clientY, startX: e.clientX, startY: e.clientY };
|
|
178
|
+
moved = false;
|
|
179
|
+
canvas.setPointerCapture(e.pointerId);
|
|
180
|
+
};
|
|
181
|
+
const move = (e) => {
|
|
182
|
+
if (!drag || drag.pointer !== e.pointerId || disposed)
|
|
183
|
+
return;
|
|
184
|
+
moved ||= Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) > 3;
|
|
185
|
+
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
|
|
186
|
+
drag.x = e.clientX;
|
|
187
|
+
drag.y = e.clientY;
|
|
188
|
+
orbit(-dx * DRAG_RADIANS_PER_PIXEL, dy * DRAG_RADIANS_PER_PIXEL);
|
|
189
|
+
};
|
|
190
|
+
const up = (e) => {
|
|
191
|
+
if (!drag || drag.pointer !== e.pointerId)
|
|
192
|
+
return;
|
|
193
|
+
drag = null;
|
|
194
|
+
if (!moved) {
|
|
195
|
+
const r = canvas.getBoundingClientRect();
|
|
196
|
+
options.onPick?.(pick(((e.clientX - r.left) * size) / r.width, ((e.clientY - r.top) * size) / r.height));
|
|
197
|
+
}
|
|
198
|
+
if (canvas.hasPointerCapture(e.pointerId))
|
|
199
|
+
canvas.releasePointerCapture(e.pointerId);
|
|
200
|
+
};
|
|
201
|
+
const cancel = (e) => {
|
|
202
|
+
if (drag?.pointer === e.pointerId)
|
|
203
|
+
drag = null;
|
|
204
|
+
};
|
|
205
|
+
// Arrow keys orbit like a drag in that direction; Home restores the initial view.
|
|
206
|
+
const key = (e) => {
|
|
207
|
+
if (disposed || e.altKey || e.ctrlKey || e.metaKey)
|
|
208
|
+
return;
|
|
209
|
+
const step = e.shiftKey ? KEY_RADIANS / 3 : KEY_RADIANS;
|
|
210
|
+
if (e.key === "ArrowLeft")
|
|
211
|
+
orbit(step, 0);
|
|
212
|
+
else if (e.key === "ArrowRight")
|
|
213
|
+
orbit(-step, 0);
|
|
214
|
+
else if (e.key === "ArrowUp")
|
|
215
|
+
orbit(0, -step / 2);
|
|
216
|
+
else if (e.key === "ArrowDown")
|
|
217
|
+
orbit(0, step / 2);
|
|
218
|
+
else if (e.key === "Home") {
|
|
219
|
+
invalidateFrames();
|
|
220
|
+
yaw = initialYaw;
|
|
221
|
+
pitch = initialPitch;
|
|
222
|
+
render();
|
|
223
|
+
}
|
|
224
|
+
else
|
|
225
|
+
return;
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
};
|
|
228
|
+
const showFrame = (n) => {
|
|
229
|
+
const p = requireValue(playback);
|
|
230
|
+
p.frame = n;
|
|
231
|
+
const pose = samplePose(p.source, p.clip, { frame: n, fps: p.fps }, { face: p.options.face });
|
|
232
|
+
currentPose = pose;
|
|
233
|
+
asset = null;
|
|
234
|
+
mesh = null;
|
|
235
|
+
pickOffset = p.options.offset ?? [0, 0, 0];
|
|
236
|
+
const config = JSON.stringify([
|
|
237
|
+
p.options.tint,
|
|
238
|
+
p.options.offset,
|
|
239
|
+
canvas.width,
|
|
240
|
+
canvas.height,
|
|
241
|
+
p.options.face && identity(p.options.face),
|
|
242
|
+
p.options.props?.map((prop) => [identity(prop.asset), prop.tint, prop.offset]),
|
|
243
|
+
]);
|
|
244
|
+
if (config !== cacheConfig) {
|
|
245
|
+
invalidateFrames();
|
|
246
|
+
cacheConfig = config;
|
|
247
|
+
}
|
|
248
|
+
// Exact sampled geometry guards against fractional loop lengths, floating-point phase
|
|
249
|
+
// drift and face overlays with independent periods. Travel is reported, never drawn.
|
|
250
|
+
const key = frameCacheBytes && p.clip.loop
|
|
251
|
+
? JSON.stringify([
|
|
252
|
+
n % Math.max(1, Math.round(p.clip.duration * p.fps)),
|
|
253
|
+
pose.transforms,
|
|
254
|
+
pose.root,
|
|
255
|
+
pose.states,
|
|
256
|
+
])
|
|
257
|
+
: null;
|
|
258
|
+
const cached = key === null ? null : frameCache.get(key);
|
|
259
|
+
if (cached) {
|
|
260
|
+
frameCache.delete(requireValue(key));
|
|
261
|
+
frameCache.set(requireValue(key), cached);
|
|
262
|
+
rendered = cached.rendered;
|
|
263
|
+
draw(rendered);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
render();
|
|
267
|
+
if (key !== null && rendered) {
|
|
268
|
+
const bytes = (rendered.image?.data.byteLength ?? 0) + key.length * 2 + 256;
|
|
269
|
+
if (bytes <= frameCacheBytes) {
|
|
270
|
+
while (cacheBytes + bytes > frameCacheBytes) {
|
|
271
|
+
const oldest = requireValue(frameCache.keys().next().value);
|
|
272
|
+
cacheBytes -= requireValue(frameCache.get(oldest)).bytes;
|
|
273
|
+
frameCache.delete(oldest);
|
|
274
|
+
}
|
|
275
|
+
frameCache.set(key, { rendered, bytes });
|
|
276
|
+
cacheBytes += bytes;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
options.onFrame?.(n, pose);
|
|
281
|
+
};
|
|
282
|
+
const lastFrame = () => requireValue(playback).clip.loop
|
|
283
|
+
? Infinity
|
|
284
|
+
: Math.round(requireValue(playback).clip.duration * requireValue(playback).fps);
|
|
285
|
+
const tick = (now) => {
|
|
286
|
+
const p = playback;
|
|
287
|
+
if (!p || disposed || !p.raf || !p.requested)
|
|
288
|
+
return;
|
|
289
|
+
// A frame timestamp can precede the moment playback started; never step backwards.
|
|
290
|
+
const n = Math.min(lastFrame(), p.startFrame + Math.max(0, Math.floor(((now - p.start) * p.fps) / 1000)));
|
|
291
|
+
if (n !== p.frame)
|
|
292
|
+
showFrame(n);
|
|
293
|
+
if (disposed || playback !== p)
|
|
294
|
+
return;
|
|
295
|
+
if (n >= lastFrame()) {
|
|
296
|
+
p.raf = 0;
|
|
297
|
+
p.requested = false;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!p.requested || hidden())
|
|
301
|
+
return;
|
|
302
|
+
p.raf = requestAnimationFrame(tick);
|
|
303
|
+
};
|
|
304
|
+
const halt = () => {
|
|
305
|
+
if (playback?.raf)
|
|
306
|
+
cancelAnimationFrame(playback.raf);
|
|
307
|
+
if (playback)
|
|
308
|
+
playback.raf = 0;
|
|
309
|
+
};
|
|
310
|
+
const start = () => {
|
|
311
|
+
const p = requireValue(playback);
|
|
312
|
+
halt();
|
|
313
|
+
p.requested = p.frame < lastFrame();
|
|
314
|
+
p.startFrame = p.frame;
|
|
315
|
+
p.start = performance.now();
|
|
316
|
+
if (p.requested && !hidden())
|
|
317
|
+
p.raf = requestAnimationFrame(tick);
|
|
318
|
+
};
|
|
319
|
+
const stopPlayback = () => {
|
|
320
|
+
halt();
|
|
321
|
+
playback = null;
|
|
322
|
+
currentPose = null;
|
|
323
|
+
rendered = null;
|
|
324
|
+
invalidateFrames();
|
|
325
|
+
framing = null;
|
|
326
|
+
pickOffset = [0, 0, 0];
|
|
327
|
+
};
|
|
328
|
+
const ownerDocument = canvas.ownerDocument;
|
|
329
|
+
let intersecting = true;
|
|
330
|
+
const hidden = () => options.pauseWhenHidden !== false && (!!ownerDocument?.hidden || !intersecting);
|
|
331
|
+
const visibilityChanged = () => {
|
|
332
|
+
if (!playback?.requested || disposed)
|
|
333
|
+
return;
|
|
334
|
+
if (hidden())
|
|
335
|
+
halt();
|
|
336
|
+
else if (!playback.raf)
|
|
337
|
+
start();
|
|
338
|
+
};
|
|
339
|
+
const observer = options.pauseWhenHidden !== false && typeof IntersectionObserver !== "undefined"
|
|
340
|
+
? new IntersectionObserver((entries) => {
|
|
341
|
+
for (const entry of entries)
|
|
342
|
+
if (entry.target === canvas)
|
|
343
|
+
intersecting = entry.isIntersecting;
|
|
344
|
+
visibilityChanged();
|
|
345
|
+
})
|
|
346
|
+
: null;
|
|
347
|
+
observer?.observe(canvas);
|
|
348
|
+
if (options.pauseWhenHidden !== false)
|
|
349
|
+
ownerDocument?.addEventListener("visibilitychange", visibilityChanged);
|
|
350
|
+
canvas.addEventListener("pointerdown", down);
|
|
351
|
+
canvas.addEventListener("pointermove", move);
|
|
352
|
+
canvas.addEventListener("pointerup", up);
|
|
353
|
+
canvas.addEventListener("pointercancel", cancel);
|
|
354
|
+
canvas.addEventListener("lostpointercapture", cancel);
|
|
355
|
+
canvas.addEventListener("keydown", key);
|
|
356
|
+
return {
|
|
357
|
+
setAsset(value, shownTint) {
|
|
358
|
+
if (disposed)
|
|
359
|
+
throw new Error("Preview is disposed");
|
|
360
|
+
if (value.triangles > maxTriangles)
|
|
361
|
+
throw new RangeError("Preview triangle budget exceeded");
|
|
362
|
+
stopPlayback();
|
|
363
|
+
asset = value;
|
|
364
|
+
mesh = assetMesh(value, shownTint);
|
|
365
|
+
tint = shownTint ?? null;
|
|
366
|
+
render();
|
|
367
|
+
},
|
|
368
|
+
setMesh(value) {
|
|
369
|
+
if (disposed)
|
|
370
|
+
throw new Error("Preview is disposed");
|
|
371
|
+
if (value.indices.length / 3 > maxTriangles)
|
|
372
|
+
throw new RangeError("Preview triangle budget exceeded");
|
|
373
|
+
stopPlayback();
|
|
374
|
+
asset = null;
|
|
375
|
+
tint = null;
|
|
376
|
+
mesh = value;
|
|
377
|
+
render();
|
|
378
|
+
},
|
|
379
|
+
/** Recolor the current asset without restarting its pose, framing or playback clock. */
|
|
380
|
+
setTint(value = null) {
|
|
381
|
+
if (disposed)
|
|
382
|
+
throw new Error("Preview is disposed");
|
|
383
|
+
if (value !== null && !/^#[a-fA-F0-9]{6}$/.test(value))
|
|
384
|
+
throw new RangeError("Tint must be #rrggbb");
|
|
385
|
+
if (!asset && !playback)
|
|
386
|
+
throw new Error("No asset is loaded");
|
|
387
|
+
if (value === (playback ? (playback.options.tint ?? null) : tint))
|
|
388
|
+
return;
|
|
389
|
+
invalidateFrames();
|
|
390
|
+
if (playback) {
|
|
391
|
+
const { tint: _oldTint, ...retained } = playback.options;
|
|
392
|
+
playback.options = value === null ? retained : { ...retained, tint: value };
|
|
393
|
+
asset = null;
|
|
394
|
+
mesh = null;
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
tint = value;
|
|
398
|
+
mesh = tint === null ? assetMesh(requireValue(asset)) : assetMesh(requireValue(asset), tint);
|
|
399
|
+
}
|
|
400
|
+
render();
|
|
401
|
+
},
|
|
402
|
+
setCamera(value) {
|
|
403
|
+
if (disposed)
|
|
404
|
+
throw new Error("Preview is disposed");
|
|
405
|
+
if (!Number.isFinite(value.yaw) || (value.pitch !== undefined && !Number.isFinite(value.pitch)))
|
|
406
|
+
throw new RangeError("Camera must be finite");
|
|
407
|
+
invalidateFrames();
|
|
408
|
+
yaw = value.yaw;
|
|
409
|
+
pitch = clampPitch(value.pitch ?? pitch);
|
|
410
|
+
render();
|
|
411
|
+
},
|
|
412
|
+
/** The current camera, including any orbit from dragging or the keyboard. */
|
|
413
|
+
get camera() {
|
|
414
|
+
return { yaw, pitch };
|
|
415
|
+
},
|
|
416
|
+
pick,
|
|
417
|
+
/** The current frame's scene and camera, e.g. for `exportGLB` or `bakeScene` from `/export`. */
|
|
418
|
+
snapshot() {
|
|
419
|
+
if (disposed)
|
|
420
|
+
throw new Error("Preview is disposed");
|
|
421
|
+
const scene = [], result = { scene, camera: { yaw, pitch } };
|
|
422
|
+
if (playback && currentPose) {
|
|
423
|
+
asset ??= poseAsset(playback.source, currentPose);
|
|
424
|
+
scene.push({ asset, tint: playback.options.tint, offset: playback.options.offset });
|
|
425
|
+
for (const prop of playback.options.props ?? [])
|
|
426
|
+
scene.push({ ...prop });
|
|
427
|
+
if (framing)
|
|
428
|
+
result.fit = { min: [...framing.min], max: [...framing.max] };
|
|
429
|
+
result.clip = playback.clip.name;
|
|
430
|
+
result.frame = playback.frame;
|
|
431
|
+
}
|
|
432
|
+
else if (asset)
|
|
433
|
+
scene.push({ asset, ...(tint === null ? {} : { tint }) });
|
|
434
|
+
return result;
|
|
435
|
+
},
|
|
436
|
+
/**
|
|
437
|
+
* Play a compiled clip of a rigged asset. Framing is fixed to the union of sampled
|
|
438
|
+
* bounds, travel is not applied (the clip plays in place), and frames are scheduled only while playing.
|
|
439
|
+
*/
|
|
440
|
+
play(source, clip, play = {}) {
|
|
441
|
+
if (disposed)
|
|
442
|
+
throw new Error("Preview is disposed");
|
|
443
|
+
const fps = play.fps ?? 30;
|
|
444
|
+
if (!Number.isInteger(fps) || fps < 1 || fps > 120)
|
|
445
|
+
throw new RangeError("Preview fps must be 1–120");
|
|
446
|
+
stopPlayback();
|
|
447
|
+
const offset = play.offset ?? [0, 0, 0], min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity], include = (b, o) => {
|
|
448
|
+
for (let i = 0; i < 3; i++) {
|
|
449
|
+
min[i] = Math.min(requireValue(min[i]), requireValue(b.min[i]) + requireValue(o[i]));
|
|
450
|
+
max[i] = Math.max(requireValue(max[i]), requireValue(b.max[i]) + requireValue(o[i]));
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
if (play.fit)
|
|
454
|
+
include(play.fit, [0, 0, 0]);
|
|
455
|
+
else {
|
|
456
|
+
for (let k = 0; k < 24; k++)
|
|
457
|
+
include(poseAsset(source, samplePose(source, clip, { phase: k / (clip.loop ? 24 : 23) })).bounds, offset);
|
|
458
|
+
for (const prop of play.props ?? [])
|
|
459
|
+
include(prop.asset.bounds, prop.offset ?? [0, 0, 0]);
|
|
460
|
+
}
|
|
461
|
+
framing = { min, max };
|
|
462
|
+
playback = {
|
|
463
|
+
source,
|
|
464
|
+
clip,
|
|
465
|
+
options: play,
|
|
466
|
+
fps,
|
|
467
|
+
frame: 0,
|
|
468
|
+
startFrame: 0,
|
|
469
|
+
start: 0,
|
|
470
|
+
raf: 0,
|
|
471
|
+
requested: false,
|
|
472
|
+
};
|
|
473
|
+
showFrame(0);
|
|
474
|
+
if (play.autoplay !== false)
|
|
475
|
+
start();
|
|
476
|
+
},
|
|
477
|
+
pause() {
|
|
478
|
+
if (playback)
|
|
479
|
+
playback.requested = false;
|
|
480
|
+
halt();
|
|
481
|
+
},
|
|
482
|
+
resume() {
|
|
483
|
+
if (disposed)
|
|
484
|
+
throw new Error("Preview is disposed");
|
|
485
|
+
if (playback && !playback.requested && playback.frame < lastFrame())
|
|
486
|
+
start();
|
|
487
|
+
},
|
|
488
|
+
/** Show one integer frame of the playing clip; playback continues from it if it was running. */
|
|
489
|
+
seek(n) {
|
|
490
|
+
if (disposed)
|
|
491
|
+
throw new Error("Preview is disposed");
|
|
492
|
+
if (!playback)
|
|
493
|
+
throw new Error("No clip is loaded");
|
|
494
|
+
if (!Number.isInteger(n) || n < 0)
|
|
495
|
+
throw new RangeError("Frames are nonnegative integers");
|
|
496
|
+
const running = playback.requested;
|
|
497
|
+
showFrame(Math.min(n, lastFrame()));
|
|
498
|
+
if (running)
|
|
499
|
+
start();
|
|
500
|
+
},
|
|
501
|
+
get playing() {
|
|
502
|
+
return !!playback?.requested;
|
|
503
|
+
},
|
|
504
|
+
get suspended() {
|
|
505
|
+
return !!playback?.requested && hidden();
|
|
506
|
+
},
|
|
507
|
+
get frame() {
|
|
508
|
+
return playback?.frame;
|
|
509
|
+
},
|
|
510
|
+
dispose() {
|
|
511
|
+
if (disposed)
|
|
512
|
+
return;
|
|
513
|
+
stopPlayback();
|
|
514
|
+
disposed = true;
|
|
515
|
+
asset = null;
|
|
516
|
+
mesh = null;
|
|
517
|
+
frame = null;
|
|
518
|
+
drag = null;
|
|
519
|
+
observer?.disconnect();
|
|
520
|
+
ownerDocument?.removeEventListener("visibilitychange", visibilityChanged);
|
|
521
|
+
canvas.removeEventListener("pointerdown", down);
|
|
522
|
+
canvas.removeEventListener("pointermove", move);
|
|
523
|
+
canvas.removeEventListener("pointerup", up);
|
|
524
|
+
canvas.removeEventListener("pointercancel", cancel);
|
|
525
|
+
canvas.removeEventListener("lostpointercapture", cancel);
|
|
526
|
+
canvas.removeEventListener("keydown", key);
|
|
527
|
+
context.clearRect(0, 0, size, size);
|
|
528
|
+
delete canvas.dataset.rendered;
|
|
529
|
+
},
|
|
530
|
+
get disposed() {
|
|
531
|
+
return disposed;
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
}
|
package/dist/compat.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MeshData, Vec3 } from "./types.js";
|
|
2
|
+
export { fromLegacyRig, type LegacyJointMapping, type LegacyRig } from "./legacy.js";
|
|
3
|
+
export declare const linear: (v: number) => number;
|
|
4
|
+
export declare class MeshBuilder {
|
|
5
|
+
readonly mesh: MeshData;
|
|
6
|
+
joint: number;
|
|
7
|
+
constructor(jointPivots?: readonly [number, number]);
|
|
8
|
+
face(points: Vec3[], color: number, center: Vec3): void;
|
|
9
|
+
box(x: number, y: number, z: number, w: number, h: number, d: number, color: number, bevel?: number): this;
|
|
10
|
+
beam(a: Vec3, b: Vec3, width: number, color: number): this;
|
|
11
|
+
/** A round shaft in any direction; the asset supplies its physical endpoints. */
|
|
12
|
+
tube(a: Vec3, b: Vec3, radius: number, color: number): this;
|
|
13
|
+
funnel(x: number, y: number, z: number, bottomRadius: number, topRadius: number, height: number, color: number): this;
|
|
14
|
+
/** Coil in the YZ plane. Small cross-section segments keep distant details economical. */
|
|
15
|
+
coil(x: number, y: number, z: number, radius: number, thickness: number, color: number): this;
|
|
16
|
+
cylinder(x: number, y: number, z: number, r: number, h: number, color: number): this;
|
|
17
|
+
}
|