partforge 0.156.0 → 0.158.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/docs/AUTHORING-PARTS.md +41 -16
- package/package.json +1 -1
- package/src/framework/export/bundle.js +47 -26
- package/src/framework/export/drawing.js +35 -2
- package/src/framework/export/formats.js +10 -2
- package/src/framework/export/readme.js +20 -7
- package/src/framework/geometry/pose.js +20 -0
- package/src/framework/gltf/decode-preview.js +79 -7
- package/src/framework/gltf/encode-preview.js +196 -10
- package/src/framework/mount-preview.js +28 -2
- package/src/framework/mount.js +23 -6
- package/src/framework/oracle/build.js +30 -3
- package/src/framework/preview-animation.js +128 -0
- package/src/framework/preview-player.js +161 -0
- package/src/framework/process/cnc-router/descriptor.js +41 -19
- package/src/framework/process/cnc-router/export.js +54 -31
- package/src/framework/sheet/routed-resolve.js +16 -4
- package/src/oracle.js +3 -0
- package/types/geometry.d.ts +6 -4
- package/types/gltf.d.ts +14 -1
- package/types/index.d.ts +14 -0
- package/types/oracle.d.ts +78 -0
- package/types/part.d.ts +2 -0
- package/types/testing.d.ts +11 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -2037,7 +2037,7 @@ the assembled view, and `minWall` and the overhang check skip sheet parts.
|
|
|
2037
2037
|
|
|
2038
2038
|
### The kit
|
|
2039
2039
|
|
|
2040
|
-
|
|
2040
|
+
Sheet parts download as one ZIP, the **cut & print kit** (`format: "bundle"`): `README.txt`, `parts.csv`, laid-out `sheets/`, a `parts/` file per distinct piece (plus `-marks.dxf` for a cutting service), `print/` for printed parts. Identical pieces share one `-xN` file named from `export: { name }`, else the key. Destination (`"own-laser"`, `"service"`, `"cnc-router"`), kerf, stock size and a two-sided routed piece's flip and file count are chosen at download: draw nominal, bind `thickness` to the measured control. A choice it cannot honour fails with `cut kit options:`.
|
|
2041
2041
|
|
|
2042
2042
|
### Limits
|
|
2043
2043
|
|
|
@@ -2179,8 +2179,8 @@ renders and STEP all come from the one declaration — so model it once.
|
|
|
2179
2179
|
|
|
2180
2180
|
### When a sub-part is a routed part
|
|
2181
2181
|
|
|
2182
|
-
When it is machined from one
|
|
2183
|
-
holes, all straight down. A sculpted surface
|
|
2182
|
+
When it is machined from one or both faces of a flat board: an outline, pockets with
|
|
2183
|
+
flat floors, holes, all straight down from whichever face. A sculpted surface or
|
|
2184
2184
|
anything that is not flat stock is an ordinary `build` (export it as STEP). A laser-cut
|
|
2185
2185
|
plate is `sheetPart`.
|
|
2186
2186
|
|
|
@@ -2197,8 +2197,12 @@ plinth: routedPart({
|
|
|
2197
2197
|
pockets: (k, p, d) => [
|
|
2198
2198
|
{ region: k.shape2d(roundedRectProfile(d.vaultW, d.vaultL, 2)).translate([0, -9]), depth: 11.43, label: "peg vault" },
|
|
2199
2199
|
{ region: k.shape2d(roundedRectProfile(d.pocketW, d.pocketL, 4)).translate([-58, -10]), depth: p.inlayThickness, label: "inlay pocket" },
|
|
2200
|
+
{ region: k.shape2d(roundedRectProfile(40, 20, 3)).translate([60, 0]), depth: 4, face: "far", label: "felt recess" },
|
|
2201
|
+
],
|
|
2202
|
+
drills: (p, d) => [
|
|
2203
|
+
...d.pegHoles.map(([x, y]) => ({ at: [x, y], d: p.pegHoleDia, depth: 10 })),
|
|
2204
|
+
{ at: [-60, 30], d: 8, depth: 6, face: "far", label: "foot socket" }, // from the underside
|
|
2200
2205
|
],
|
|
2201
|
-
drills: (p, d) => d.pegHoles.map(([x, y]) => ({ at: [x, y], d: p.pegHoleDia, depth: 10 })),
|
|
2202
2206
|
extra: (k, p, d) => [{
|
|
2203
2207
|
solid: k.box({ size: [d.rackL, 12, 14] }).rotateX(-p.rackTilt).at([0, d.rackY, p.baseThickness - 9.5]),
|
|
2204
2208
|
footprint: k.shape2d(roundedRectProfile(d.rackL, 12, 2)).translate([0, d.rackY]),
|
|
@@ -2213,21 +2217,22 @@ plinth: routedPart({
|
|
|
2213
2217
|
`exportable` pass through. A field needing the kernel is `(k, p, d) => …`; `drills` is
|
|
2214
2218
|
`(p, d) => …`. `build` is supplied — passing one throws, as do `kerf`, `score`,
|
|
2215
2219
|
`engrave` and `process`; each error names the fix. The drawing is in XY with the
|
|
2216
|
-
|
|
2220
|
+
near face at z = `thickness` and the far face at z = 0; `pose` places it exactly as a
|
|
2221
|
+
sheet part's does.
|
|
2217
2222
|
|
|
2218
2223
|
### Pockets, drills, extra ops and reference lines
|
|
2219
2224
|
|
|
2220
|
-
- **`pockets`** — `[{ region, depth, label? }]`: a Shape2D or profile machined
|
|
2221
|
-
mm
|
|
2225
|
+
- **`pockets`** — `[{ region, depth, label?, face? }]`: a Shape2D or profile machined
|
|
2226
|
+
`depth` mm into the stock from its face, flat-floored. `depth` may equal `thickness` (a
|
|
2222
2227
|
through pocket); deeper is a build error naming the pocket. Pockets whose depths agree to
|
|
2223
2228
|
two decimals share a DXF layer.
|
|
2224
|
-
- **`drills`** — `[{ at: [x, y], d, depth, label? }]`: a round hole of diameter `d`.
|
|
2229
|
+
- **`drills`** — `[{ at: [x, y], d, depth, label?, face? }]`: a round hole of diameter `d`.
|
|
2225
2230
|
Set `depth` to the thickness to drill through — deeper is a `routed-drill-depth`
|
|
2226
2231
|
lint error. Written as a DXF `CIRCLE`, which is what lets
|
|
2227
2232
|
the CAM offer a drilling toolpath instead of a profile cut.
|
|
2228
|
-
- **`extra`** — `[{ solid, footprint, label, method }]`: an operation the router
|
|
2229
|
-
do straight down — a tilted slot, a scooped thumb hold, an undercut. `solid` is
|
|
2230
|
-
the preview and the STEP; `footprint` (its outline on
|
|
2233
|
+
- **`extra`** — `[{ solid, footprint, label, method, face? }]`: an operation the router
|
|
2234
|
+
cannot do straight down — a tilted slot, a scooped thumb hold, an undercut. `solid` is
|
|
2235
|
+
cut in the preview and the STEP; `footprint` (its outline on its face) is drawn on
|
|
2231
2236
|
the `SECONDARY` layer; `label` and `method` are REQUIRED and go in the README's
|
|
2232
2237
|
operations table so the operator knows what to make and how ("core box bit, 20 mm
|
|
2233
2238
|
radius"). Do not put a flat-floored pocket here to avoid stating its depth.
|
|
@@ -2248,10 +2253,27 @@ and depth (colours 3, 4, 2, 140, 170); `SECONDARY` (6) the extra ops' footprints
|
|
|
2248
2253
|
`REFERENCE` (8) layout lines. Depth rides both the layer name and the colour, and colours
|
|
2249
2254
|
are assigned per kit — pockets shallowest depth first, drills in the order they appear —
|
|
2250
2255
|
so one layer name has one colour in every file. A palette cycles past five depths; the
|
|
2251
|
-
README's LAYERS legend (layer, colour, depth) is the authority. Kerf
|
|
2256
|
+
README's LAYERS legend (layer, face, colour, depth) is the authority. Kerf
|
|
2252
2257
|
never applies — the CAM applies the tool offset, and the kit refuses a kerf for this
|
|
2253
2258
|
destination. The README's ROUTER OPERATIONS table lists every pocket, drill group and
|
|
2254
|
-
extra op with its depth and method.
|
|
2259
|
+
extra op with its face, depth and method.
|
|
2260
|
+
|
|
2261
|
+
### Both faces
|
|
2262
|
+
|
|
2263
|
+
Every pocket, drill and extra op takes `face`: `"near"` (the default, machined down
|
|
2264
|
+
from z = `thickness`) or `"far"` (machined up from z = 0 — the board turned over); any
|
|
2265
|
+
other value is a build error. `depth` is measured from the op's own face. Model the part
|
|
2266
|
+
in place, as assembled; the kit does the flipping. Far ops go on their own layers —
|
|
2267
|
+
`FAR_POCKET_<depth>MM`, `FAR_DRILL_D<dia>_…`, `FAR_SECONDARY` — mirrored about the
|
|
2268
|
+
piece's centre so they read as the router sees the far face. Two kit options apply to a
|
|
2269
|
+
piece with far work: `flip` — `"left-right"` (the default, VCarve's Flip Direction =
|
|
2270
|
+
Horizontal) or `"end-over-end"` (Vertical) — sets the mirror axis; `twoSided` —
|
|
2271
|
+
`"one-file"` (the default: both faces' layers in the piece's one DXF) or `"two-files"`
|
|
2272
|
+
(the far layers in `parts/<name>-far.dxf`, behind the mirrored outline on `REFERENCE`
|
|
2273
|
+
for alignment). In the CAM, line the far vectors' outline up on the piece's outline as
|
|
2274
|
+
seen from the bottom; the README says so and orders the work far side first, through
|
|
2275
|
+
cut last. `routedBreakthrough` counts a near and a far pocket that overlap and together
|
|
2276
|
+
go deeper than the stock.
|
|
2255
2277
|
|
|
2256
2278
|
### What lint and verify check
|
|
2257
2279
|
|
|
@@ -2259,14 +2281,17 @@ Lint: `routed-drill-depth` (error) — a drill deeper than the stock at the defa
|
|
|
2259
2281
|
Every sheet rule applies too (`sheet-thickness-literal`, `sheet-custom-build`, …); the
|
|
2260
2282
|
laser's thickness range does not. A pocket deeper than the stock is a build error.
|
|
2261
2283
|
|
|
2262
|
-
Verify volunteers
|
|
2284
|
+
Verify volunteers four warnings on every routed part, none counting toward
|
|
2263
2285
|
`declared`/`evaluated`: `routedOutside` (pocket, drill or footprint regions outside the
|
|
2264
|
-
profile, `0`), `routedEmpty` (pockets that resolved empty, `0`), `routedPieces` (`1`)
|
|
2286
|
+
profile, `0`), `routedEmpty` (pockets that resolved empty, `0`), `routedPieces` (`1`),
|
|
2287
|
+
`routedBreakthrough` (a near-face and a far-face pocket that overlap and together go
|
|
2288
|
+
deeper than the stock, `0`).
|
|
2265
2289
|
Each measured row carries `sheet.routed` — the operations table — beside the stock facts.
|
|
2266
2290
|
|
|
2267
2291
|
### Limits
|
|
2268
2292
|
|
|
2269
|
-
-
|
|
2293
|
+
- Far-side `reference` lines and far-side work in the sheets/ layouts are not drawn;
|
|
2294
|
+
two-sided jobs are per-piece files.
|
|
2270
2295
|
- No tool-radius, dogbone or inside-corner checks: the CAM owns those.
|
|
2271
2296
|
- No depth per extra op beyond its footprint and the solid's deepest point; the
|
|
2272
2297
|
`method` text is where the operator's instructions go.
|
package/package.json
CHANGED
|
@@ -19,15 +19,17 @@ import { resolveSheet } from "../sheet/resolve.js";
|
|
|
19
19
|
import { processById, SHEET_METRICS } from "../process/registry.js";
|
|
20
20
|
import { loadExporter } from "../process/exporters.js";
|
|
21
21
|
import { validateKitOptions, resolveStock, KIT_OPTIONS_ERROR, KIT_LIMITS } from "./formats.js";
|
|
22
|
-
import { canonicalDrawingKey, routedPalette } from "./drawing.js";
|
|
22
|
+
import { canonicalDrawingKey, routedPalette, splitFaces } from "./drawing.js";
|
|
23
23
|
import { renderSvg } from "./svg.js";
|
|
24
24
|
import { renderDxf } from "./dxf.js";
|
|
25
25
|
import { layoutGroup, marginFitsStock } from "./layout.js";
|
|
26
26
|
import { renderReadme, renderPartsCsv, cleanLabel } from "./readme.js";
|
|
27
27
|
|
|
28
28
|
// Every entry name the kit may write (spec D.10). test/kit-bundle.test.js pins every
|
|
29
|
-
// kit it builds against it, and buildBundle asserts it again before zipping.
|
|
30
|
-
|
|
29
|
+
// kit it builds against it, and buildBundle asserts it again before zipping. A piece's
|
|
30
|
+
// files are `<name>[-x<n>]` plus `-far` (a two-sided routed piece's far-face file, in
|
|
31
|
+
// the two-files mode) or `-marks` (a laser piece's score and engrave lines).
|
|
32
|
+
export const KIT_ENTRY_RE = /^(README\.txt|parts\.csv|sheets\/[a-z0-9][a-z0-9._-]*\/sheet-\d+-of-\d+\.(svg|dxf)|parts\/[a-z0-9][a-z0-9._-]*(-x\d+)?(-far)?(-marks)?\.(svg|dxf)|print\/[a-z0-9][a-z0-9._-]*(-x\d+)?\.(stl|3mf))$/;
|
|
31
33
|
|
|
32
34
|
// A path segment from an author string. safeName already lowercases, collapses runs and
|
|
33
35
|
// refuses a leading dot or dash; folding every run of dots on top means no entry name
|
|
@@ -192,27 +194,32 @@ function sheetMetaOrThrow(kernel, sp, p, d, name) {
|
|
|
192
194
|
throw new Error(`sheet part "${name}": its material or thickness could not be evaluated`);
|
|
193
195
|
}
|
|
194
196
|
|
|
195
|
-
// The README's layer legend: every routed layer name in the kit, once, with its
|
|
196
|
-
// (ACI in a DXF kit, hex in an SVG one) and depth — REFERENCE, SECONDARY, then
|
|
197
|
-
// palette order, then pockets shallowest first
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
197
|
+
// The README's layer legend: every routed layer name in the kit, once, with its face,
|
|
198
|
+
// colour (ACI in a DXF kit, hex in an SVG one) and depth — REFERENCE, SECONDARY, then
|
|
199
|
+
// drills in palette order, then pockets shallowest first; the far face's layers follow
|
|
200
|
+
// in the same order. The palette is kit-wide, so a name has one colour; the legend is
|
|
201
|
+
// the authority when a palette cycles. It reads each piece's whole drawing, so the
|
|
202
|
+
// far file's alignment outline (splitFaces' `far-outline`) is never listed.
|
|
203
|
+
const LEGEND_RANK = { reference: 0, secondary: 1, drill: 2, pocket: 3, "far-secondary": 4, "far-drill": 5, "far-pocket": 6 };
|
|
204
|
+
const legendKind = (l) => {
|
|
205
|
+
const m = /^(far-)?(drill|pocket)-/.exec(l.id);
|
|
206
|
+
return m ? `${m[1] ?? ""}${m[2]}` : l.id;
|
|
207
|
+
};
|
|
201
208
|
function routedLegend(pieces, cutExt) {
|
|
202
209
|
const rows = new Map();
|
|
203
210
|
for (const pc of pieces) {
|
|
204
211
|
for (const l of pc.drawing.layers) {
|
|
205
212
|
if (l.name === undefined || rows.has(l.name)) continue;
|
|
206
213
|
const kind = legendKind(l);
|
|
207
|
-
rows.set(l.name, { name: l.name, kind, order: rows.size,
|
|
214
|
+
rows.set(l.name, { name: l.name, kind, face: l.face === "far" ? "far" : "near", order: rows.size,
|
|
208
215
|
colour: cutExt === "svg" ? l.svgColor : String(l.color),
|
|
209
216
|
depth: l.through ? "through" : typeof l.depth === "number" ? `${l.depth.toFixed(2)} mm` : "—", mm: l.depth ?? 0 });
|
|
210
217
|
}
|
|
211
218
|
}
|
|
212
219
|
return [...rows.values()]
|
|
213
220
|
.sort((a, b) => (LEGEND_RANK[a.kind] ?? 9) - (LEGEND_RANK[b.kind] ?? 9)
|
|
214
|
-
|| (a.kind
|
|
215
|
-
.map(({ name, colour, depth }) => ({ name, colour, depth }));
|
|
221
|
+
|| (a.kind.endsWith("pocket") ? a.mm - b.mm : 0) || a.order - b.order)
|
|
222
|
+
.map(({ name, face, colour, depth }) => ({ name, face, colour, depth }));
|
|
216
223
|
}
|
|
217
224
|
|
|
218
225
|
// One export-bundle job's kit. `names` is the job's selected() — exportable, enabled, in
|
|
@@ -292,11 +299,14 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
292
299
|
// and the op labels, are not in its drawing — only the README carries them, so two
|
|
293
300
|
// pieces that draw alike but machine differently must stay two files.
|
|
294
301
|
const sameOps = (a, b) => JSON.stringify(a.facts?.routed ?? null) === JSON.stringify(b.facts?.routed ?? null);
|
|
302
|
+
// A routed piece with far-face work is two-sided: its sheets carry the near face only
|
|
303
|
+
// (`faces.near`), and the two-files mode writes its far face to a file of its own.
|
|
295
304
|
const pieces = dedupe(drawn, (x) => h(x.key), (a, b) => a.key === b.key && a.group === b.group && sameOps(a, b))
|
|
296
|
-
.map(({ item, members }) =>
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
305
|
+
.map(({ item, members }) => {
|
|
306
|
+
const faces = splitFaces(item.drawing);
|
|
307
|
+
return { ...item, labels: members.map((m) => m.label), qty: members.length * o.sets, faces, twoSided: faces.far !== null,
|
|
308
|
+
hasMarks: item.drawing.layers.some((l) => l.id === "score" || l.id === "engrave") };
|
|
309
|
+
});
|
|
300
310
|
|
|
301
311
|
// Names, in definition order. A name that was taken moves to name-2; the README's
|
|
302
312
|
// RENAMED FILES says which file it was and who held the name (spec D.10). A piece
|
|
@@ -321,10 +331,12 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
321
331
|
const sx = pc.qty > 1 ? `-x${pc.qty}` : "";
|
|
322
332
|
// A routed piece is never split: its depths are one layered file for the CAM.
|
|
323
333
|
const marks = (service || router) && pc.hasMarks && !pc.routed;
|
|
324
|
-
const
|
|
325
|
-
|
|
334
|
+
const far = pc.twoSided && o.twoSided === "two-files";
|
|
335
|
+
const name = claim(kitName(pc.fileKey), (n) => [`parts/${n}${sx}.${cutExt}`, ...(marks ? [`parts/${n}${sx}-marks.dxf`] : []),
|
|
336
|
+
...(far ? [`parts/${n}${sx}-far.${cutExt}`] : []), `parts/${n}/`], pc.labels);
|
|
326
337
|
pc.file = `parts/${name}${sx}.${cutExt}`;
|
|
327
338
|
pc.marksFile = marks ? `parts/${name}${sx}-marks.dxf` : null;
|
|
339
|
+
pc.farFile = far ? `parts/${name}${sx}-far.${cutExt}` : null;
|
|
328
340
|
}
|
|
329
341
|
|
|
330
342
|
// Lay out each stock group. An own laser needs every piece to fit (layoutGroup
|
|
@@ -339,7 +351,7 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
339
351
|
const stock = stockFor.get(g.group);
|
|
340
352
|
const laid = layoutGroup({
|
|
341
353
|
group: g.group, label: g.label, stock, margin: o.margin, spacing: o.spacing, strict: !(service || router),
|
|
342
|
-
pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.
|
|
354
|
+
pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.faces.near, qty: pc.qty })),
|
|
343
355
|
});
|
|
344
356
|
if (laid.sheets.length > KIT_LIMITS.sheetsPerGroup) {
|
|
345
357
|
throw new Error(`${KIT_OPTIONS_ERROR} ${g.material} ${fmtMm(g.thickness)} mm needs ${laid.sheets.length} sheets — at most ${KIT_LIMITS.sheetsPerGroup} per material; use larger stock or fewer sets`);
|
|
@@ -394,7 +406,8 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
394
406
|
// The files (spec D.8): own laser — an SVG per sheet and per piece; service and CNC
|
|
395
407
|
// router — a cut-only DXF per laser piece, a -marks.dxf where it has marks, and
|
|
396
408
|
// reference DXF sheets. A routed piece is one file with every layer, whatever the
|
|
397
|
-
// destination
|
|
409
|
+
// destination — or, two-sided in the two-files mode, its near face and its far face
|
|
410
|
+
// (behind a REFERENCE outline) as two.
|
|
398
411
|
say("writing cut files");
|
|
399
412
|
const title = cleanLabel(part.meta?.title ?? fileBase);
|
|
400
413
|
const files = [];
|
|
@@ -404,8 +417,11 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
404
417
|
put(sh.file, cutExt === "dxf" ? renderDxf(doc) : renderSvg(doc));
|
|
405
418
|
}
|
|
406
419
|
for (const pc of pieces) {
|
|
407
|
-
const
|
|
408
|
-
placements: [{ drawing
|
|
420
|
+
const docOf = (drawing) => ({ title: pc.labels.join(", "), size: sizeOf(drawing.bounds),
|
|
421
|
+
placements: [{ drawing, at: [0, 0], rotated: false }] });
|
|
422
|
+
const render = cutExt === "svg" ? renderSvg : renderDxf;
|
|
423
|
+
if (pc.farFile) { put(pc.file, render(docOf(pc.faces.near))); put(pc.farFile, render(docOf(pc.faces.far))); continue; }
|
|
424
|
+
const doc = docOf(pc.drawing);
|
|
409
425
|
if (cutExt === "svg") { put(pc.file, renderSvg(doc)); continue; }
|
|
410
426
|
if (pc.routed) { put(pc.file, renderDxf(doc)); continue; } // every layer, one file
|
|
411
427
|
put(pc.file, renderDxf(doc, { include: ["cut"] }));
|
|
@@ -416,15 +432,17 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
416
432
|
const controls = collectControls(part.parameters);
|
|
417
433
|
const readme = renderReadme({
|
|
418
434
|
title, destination: o.destination, cutFormat: o.cutFormat, kerf: o.kerf,
|
|
435
|
+
flip: o.flip, twoSided: o.twoSided, anyTwoSided: pieces.some((pc) => pc.twoSided),
|
|
419
436
|
stockNotes: [...groups.values()].map((g) => ({ material: g.material, thickness: g.thickness,
|
|
420
437
|
control: thicknessControlLabel(controls, part.parts[g.first], p, d) })),
|
|
421
438
|
scale: pieces.map((pc) => ({ label: pc.labels.join(", "), nominal: pc.drawing.nominal, drawn: sizeOf(pc.drawing.bounds) })),
|
|
422
439
|
sheets: sheets.map((sh) => ({ file: sh.file, material: sh.group.material, thickness: sh.group.thickness,
|
|
423
440
|
size: sh.stock, pieces: sh.placements.length, used: sh.used })),
|
|
424
|
-
pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, labels: pc.labels, material: groups.get(pc.group).material,
|
|
441
|
+
pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, farFile: pc.farFile, labels: pc.labels, material: groups.get(pc.group).material,
|
|
425
442
|
thickness: groups.get(pc.group).thickness, size: pc.drawing.nominal, quantity: pc.qty })),
|
|
426
443
|
router: pieces.filter((pc) => pc.routed && pc.facts?.routed).map((pc) => ({
|
|
427
|
-
labels: pc.labels, material: groups.get(pc.group).material, thickness: groups.get(pc.group).thickness, ...pc.facts.routed
|
|
444
|
+
labels: pc.labels, material: groups.get(pc.group).material, thickness: groups.get(pc.group).thickness, ...pc.facts.routed,
|
|
445
|
+
twoSided: pc.twoSided, farFile: pc.farFile })),
|
|
428
446
|
layers: routedLegend(pieces.filter((pc) => pc.routed), cutExt),
|
|
429
447
|
printed: prints.map((pp) => ({ file: pp.file, labels: pp.labels, quantity: pp.qty })),
|
|
430
448
|
clearances: controls.filter(isClearance)
|
|
@@ -434,8 +452,11 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
434
452
|
oversized, renamed,
|
|
435
453
|
});
|
|
436
454
|
const csv = renderPartsCsv([
|
|
437
|
-
...pieces.
|
|
438
|
-
|
|
455
|
+
...pieces.flatMap((pc) => {
|
|
456
|
+
const row = { file: pc.file, kind: "sheet", labels: pc.labels, material: groups.get(pc.group).material,
|
|
457
|
+
thickness: groups.get(pc.group).thickness, width: pc.drawing.nominal[0], height: pc.drawing.nominal[1], quantity: pc.qty };
|
|
458
|
+
return pc.farFile ? [row, { ...row, file: pc.farFile, kind: "far" }] : [row];
|
|
459
|
+
}),
|
|
439
460
|
...prints.map((pp) => ({ file: pp.file, kind: "print", labels: pp.labels, material: pp.material, thickness: null,
|
|
440
461
|
width: pp.size[0], height: pp.size[1], quantity: pp.qty })),
|
|
441
462
|
]);
|
|
@@ -359,11 +359,44 @@ export function applyKerf(regions, kerf, { label = "sheet part", gap = null } =
|
|
|
359
359
|
// Depth in millimetres as a layer-name token: up to two decimals, trailing zeros
|
|
360
360
|
// dropped (11.43 → "11.43", 3 → "3"). Two depths that round alike share a layer.
|
|
361
361
|
export const depthToken = (mm) => String(Number(mm.toFixed(2)));
|
|
362
|
+
// A far-face op's layer carries a FAR_ prefix; near names are unchanged.
|
|
362
363
|
export function routedLayerName(kind, op) {
|
|
363
|
-
|
|
364
|
-
if (kind === "
|
|
364
|
+
const far = op.face === "far" ? "FAR_" : "";
|
|
365
|
+
if (kind === "pocket") return `${far}POCKET_${depthToken(op.depth)}MM`;
|
|
366
|
+
if (kind === "drill") return far + (op.through ? `DRILL_D${depthToken(op.d)}_THRU` : `DRILL_D${depthToken(op.d)}_${depthToken(op.depth)}MM`);
|
|
365
367
|
throw new Error(`routedLayerName: unknown kind ${JSON.stringify(kind)}`);
|
|
366
368
|
}
|
|
369
|
+
// How a piece is turned over to cut its far face, as the mirror axis of its drawing.
|
|
370
|
+
export const FLIP_AXIS = Object.freeze({ "left-right": "x", "end-over-end": "y" });
|
|
371
|
+
// A ring mirrored about the line `axis = c` ("x": x -> 2c - x; "y": y -> 2c - y), every
|
|
372
|
+
// point mapped (start, to, via, c1, c2), then walked backwards so a CCW ring stays CCW:
|
|
373
|
+
// an arc keeps its mirrored via, a cubic swaps its mirrored c1/c2. Other fields (closed)
|
|
374
|
+
// carry over. Returns a new ring; the input is not touched.
|
|
375
|
+
export function mirrorRing(ring, axis, c) {
|
|
376
|
+
const m = axis === "x" ? ([x, y]) => [2 * c - x, y] : ([x, y]) => [x, 2 * c - y];
|
|
377
|
+
const pts = [ring.start, ...ring.segments.map((s) => s.to)].map(m);
|
|
378
|
+
const segments = [];
|
|
379
|
+
for (let i = ring.segments.length - 1; i >= 0; i--) {
|
|
380
|
+
const s = ring.segments[i], to = pts[i];
|
|
381
|
+
if (s.via) segments.push({ to, via: m(s.via) });
|
|
382
|
+
else if (s.c1) segments.push({ to, c1: m(s.c2), c2: m(s.c1) });
|
|
383
|
+
else segments.push({ to });
|
|
384
|
+
}
|
|
385
|
+
return { ...ring, start: pts[pts.length - 1], segments };
|
|
386
|
+
}
|
|
387
|
+
export const mirrorRings = (rings, axis, c) => rings.map((r) => mirrorRing(r, axis, c));
|
|
388
|
+
// A two-sided piece's drawing split by face. `near` keeps every layer not marked
|
|
389
|
+
// face "far" (the input itself for a one-sided piece); `far` is null for a one-sided
|
|
390
|
+
// piece, else the far layers behind a REFERENCE layer of `drawing.farOutline` (the
|
|
391
|
+
// mirrored outer rings the exporter sets), both with the input's bounds/nominal/kerf.
|
|
392
|
+
export function splitFaces(drawing) {
|
|
393
|
+
const farLayers = drawing.layers.filter((l) => l.face === "far");
|
|
394
|
+
if (farLayers.length === 0) return { near: drawing, far: null };
|
|
395
|
+
const { bounds, nominal, kerf } = drawing;
|
|
396
|
+
const near = { layers: drawing.layers.filter((l) => l.face !== "far"), bounds, nominal, kerf };
|
|
397
|
+
const outline = { id: "far-outline", name: "REFERENCE", ...ROUTED_COLORS.reference, face: "far", paths: drawing.farOutline };
|
|
398
|
+
return { near, far: { layers: [outline, ...farLayers], bounds, nominal, kerf } };
|
|
399
|
+
}
|
|
367
400
|
// A drill's tool setting as a key: diameter and depth (or THRU), two-decimal tokens —
|
|
368
401
|
// one layer per key, and the kit-wide palette's drill key.
|
|
369
402
|
export const drillKey = (dr) => `D${depthToken(dr.d)}-${dr.through ? "THRU" : depthToken(dr.depth)}`;
|
|
@@ -44,9 +44,10 @@ export const KIT_LIMITS = Object.freeze({
|
|
|
44
44
|
export const KIT_DEFAULTS = Object.freeze({
|
|
45
45
|
destination: "own-laser", kerf: 0, colors: "lightburn", stock: null,
|
|
46
46
|
margin: 5, spacing: 3, printFormat: "stl", sets: 1,
|
|
47
|
+
flip: "left-right", twoSided: "one-file",
|
|
47
48
|
});
|
|
48
49
|
|
|
49
|
-
const OPTION_KEYS = ["destination", "cutFormat", "kerf", "colors", "stock", "margin", "spacing", "printFormat", "sets"];
|
|
50
|
+
const OPTION_KEYS = ["destination", "cutFormat", "kerf", "colors", "stock", "margin", "spacing", "printFormat", "sets", "flip", "twoSided"];
|
|
50
51
|
|
|
51
52
|
const fail = (message) => new Error(`${KIT_OPTIONS_ERROR} ${message}`);
|
|
52
53
|
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
@@ -91,7 +92,14 @@ export function validateKitOptions(o) {
|
|
|
91
92
|
if (!Number.isInteger(sets) || sets < KIT_LIMITS.sets[0] || sets > KIT_LIMITS.sets[1])
|
|
92
93
|
throw fail(`sets must be a whole number from ${KIT_LIMITS.sets[0]} to ${KIT_LIMITS.sets[1]}, got ${JSON.stringify(sets)}`);
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
const flip = pick("flip");
|
|
96
|
+
if (flip !== "left-right" && flip !== "end-over-end")
|
|
97
|
+
throw fail(`flip must be "left-right" or "end-over-end", got ${JSON.stringify(flip)}`);
|
|
98
|
+
const twoSided = pick("twoSided");
|
|
99
|
+
if (twoSided !== "one-file" && twoSided !== "two-files")
|
|
100
|
+
throw fail(`twoSided must be "one-file" or "two-files", got ${JSON.stringify(twoSided)}`);
|
|
101
|
+
|
|
102
|
+
return { destination, cutFormat, kerf, colors, stock, margin, spacing, printFormat, sets, flip, twoSided };
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
// null/undefined → null ("every group gets DEFAULT_STOCK_SIZE"); otherwise fresh
|
|
@@ -15,7 +15,16 @@ export const CSV_HEADER = "file,kind,labels,material,thickness_mm,width_mm,heigh
|
|
|
15
15
|
|
|
16
16
|
const SVG_LEGEND = "Colours: engrave = filled black #000000, score = blue #0000FF, cut = red #FF0000 (inner cuts come before outer cuts).";
|
|
17
17
|
const DXF_LEGEND = "DXF layers: CUT (colour 1), SCORE (colour 5), ENGRAVE (colour 7) — set ENGRAVE to fill; R12 has no hatch.";
|
|
18
|
-
const ROUTER_LEGEND = "DXF layers: CUT (colour 1) is the through cut; POCKET_<depth>MM and DRILL_D<dia>_<depth>MM / _THRU carry one depth each; SECONDARY outlines operations done with another tool (see ROUTER OPERATIONS); REFERENCE is layout only. Laser pieces keep CUT/SCORE/ENGRAVE.";
|
|
18
|
+
const ROUTER_LEGEND = "DXF layers: CUT (colour 1) is the through cut; POCKET_<depth>MM and DRILL_D<dia>_<depth>MM / _THRU carry one depth each; SECONDARY outlines operations done with another tool (see ROUTER OPERATIONS); REFERENCE is layout only. FAR_* layers are the same operations on the far face, drawn mirrored (see the flip note under each two-sided piece). Laser pieces keep CUT/SCORE/ENGRAVE.";
|
|
19
|
+
// A two-sided routed piece: how its far layers were mirrored, how to line them up, and
|
|
20
|
+
// where its through cut is made.
|
|
21
|
+
const FLIP_NOTE = {
|
|
22
|
+
"left-right": "Far-side layers (FAR_*) are drawn as seen after flipping the board left-right, matching a VCarve two-sided job with Flip Direction = Horizontal.",
|
|
23
|
+
"end-over-end": "Far-side layers (FAR_*) are drawn as seen after flipping the board end over end, matching a VCarve two-sided job with Flip Direction = Vertical.",
|
|
24
|
+
};
|
|
25
|
+
const ALIGN_NOTE = "On the bottom side, move the imported far-side vectors so their outline sits on the piece's outline as the CAM shows it from that side. Shortcut: make the job's material the piece's size, then centre the imported vectors on the material (VCarve: Align Selected Objects → Centre); because the far layers are mirrored about the piece's own centre, they then land exactly.";
|
|
26
|
+
const ONE_FILE_NOTE = "On the bottom side the CUT outline is for alignment only — cut the piece out from the near side.";
|
|
27
|
+
const TWO_SIDED_ORDER = "Two-sided pieces: machine the far side first, flip, then the near side and the through cut last, so the board holds the piece until the end. Sheet layouts in sheets/ carry near-side work only; far-side work is in the per-piece files.";
|
|
19
28
|
const LIMITS_LINE = "Layout packs bounding boxes only: no nesting into holes, and grain direction is ignored.";
|
|
20
29
|
|
|
21
30
|
// C0 controls, DEL and C1 controls out of an author string, so a label can never ring a
|
|
@@ -48,6 +57,7 @@ function settingValue(v) {
|
|
|
48
57
|
|
|
49
58
|
const table = (header, rows) => (rows.length ? [header.join(" | "), ...rows.map((r) => r.join(" | "))] : []);
|
|
50
59
|
const mm2 = ([w, h]) => `${w.toFixed(2)} × ${h.toFixed(2)} mm`;
|
|
60
|
+
const faceOf = (op) => (op.face === "far" ? "far" : "near");
|
|
51
61
|
const labelsOf = (labels) => labels.map(cleanLabel).join(", ");
|
|
52
62
|
|
|
53
63
|
export function renderReadme(m) {
|
|
@@ -72,6 +82,7 @@ export function renderReadme(m) {
|
|
|
72
82
|
...m.scale.map((s) => `${cleanLabel(s.label)}: nominal ${s.nominal[0].toFixed(2)} × ${s.nominal[1].toFixed(2)} mm; this file draws ${
|
|
73
83
|
kerfed ? `${s.drawn[0].toFixed(2)} × ${s.drawn[1].toFixed(2)} with ${k.toFixed(2)} mm kerf` : "the same (no kerf applied)"}`),
|
|
74
84
|
router ? ROUTER_LEGEND : m.cutFormat === "dxf" ? DXF_LEGEND : SVG_LEGEND,
|
|
85
|
+
...(m.anyTwoSided ? [TWO_SIDED_ORDER] : []),
|
|
75
86
|
...(marksFiles.length ? [`Score and engrave marks are in their own files, not in the cut files — send them with the cut files if you want the marks made: ${marksFiles.join(", ")}.`] : []),
|
|
76
87
|
// A service kit whose every piece is oversized writes no sheets/ at all.
|
|
77
88
|
...(m.destination === "service" && m.sheets.length ? ["The sheets in sheets/ are for reference only — a cutting service lays out the pieces itself."] : []),
|
|
@@ -83,16 +94,18 @@ export function renderReadme(m) {
|
|
|
83
94
|
PIECES: table(["file", "labels", "material", "thickness", "size", "quantity"], m.pieces.map((pc) => [
|
|
84
95
|
pc.file, labelsOf(pc.labels), cleanLabel(pc.material), `${pc.thickness.toFixed(2)} mm`, mm2(pc.size), String(pc.quantity)])),
|
|
85
96
|
"ROUTER OPERATIONS": [...(m.router ?? []).flatMap((r) => [
|
|
86
|
-
`${labelsOf(r.labels)} (${cleanLabel(r.material)} ${r.thickness.toFixed(2)} mm) — depths from
|
|
87
|
-
...table(["op", "label", "size", "depth", "method"], [
|
|
88
|
-
...r.pockets.map((pk) => ["pocket", pk.label ? cleanLabel(pk.label) : "—", mm2([pk.w, pk.h]), `${pk.depth.toFixed(2)} mm`, "mill"]),
|
|
89
|
-
...r.drills.map((dr) => ["drill", dr.label ? cleanLabel(dr.label) : "—", `Ø${fmtMm(dr.d)} mm × ${dr.count}`, dr.through ? "through" : `${dr.depth.toFixed(2)} mm`, "drill"]),
|
|
90
|
-
...r.extra.map((x) => ["extra", cleanLabel(x.label), mm2([x.w, x.h]), `to ${x.maxDepth.toFixed(2)} mm`, cleanLabel(x.method)]),
|
|
97
|
+
`${labelsOf(r.labels)} (${cleanLabel(r.material)} ${r.thickness.toFixed(2)} mm) — depths from each op's own face:`,
|
|
98
|
+
...table(["op", "face", "label", "size", "depth", "method"], [
|
|
99
|
+
...r.pockets.map((pk) => ["pocket", faceOf(pk), pk.label ? cleanLabel(pk.label) : "—", mm2([pk.w, pk.h]), `${pk.depth.toFixed(2)} mm`, "mill"]),
|
|
100
|
+
...r.drills.map((dr) => ["drill", faceOf(dr), dr.label ? cleanLabel(dr.label) : "—", `Ø${fmtMm(dr.d)} mm × ${dr.count}`, dr.through ? "through" : `${dr.depth.toFixed(2)} mm`, "drill"]),
|
|
101
|
+
...r.extra.map((x) => ["extra", faceOf(x), cleanLabel(x.label), mm2([x.w, x.h]), `to ${x.maxDepth.toFixed(2)} mm`, cleanLabel(x.method)]),
|
|
91
102
|
]),
|
|
103
|
+
...(r.twoSided ? [FLIP_NOTE[m.flip] ?? FLIP_NOTE["left-right"], ALIGN_NOTE,
|
|
104
|
+
r.farFile ? `Far-side work is in ${r.farFile}; its REFERENCE outline is for alignment, not cutting.` : ONE_FILE_NOTE] : []),
|
|
92
105
|
]),
|
|
93
106
|
// The layer legend: one colour per layer name across the whole kit.
|
|
94
107
|
...((m.router ?? []).length && (m.layers ?? []).length
|
|
95
|
-
? ["LAYERS (one colour per layer across the kit):", ...table(["layer", "colour", "depth"], m.layers.map((l) => [l.name, l.colour, l.depth]))]
|
|
108
|
+
? ["LAYERS (one colour per layer across the kit):", ...table(["layer", "face", "colour", "depth"], m.layers.map((l) => [l.name, faceOf(l), l.colour, l.depth]))]
|
|
96
109
|
: [])],
|
|
97
110
|
"PRINTED PARTS": table(["file", "labels", "quantity"], m.printed.map((pp) => [pp.file, labelsOf(pp.labels), String(pp.quantity)])),
|
|
98
111
|
"CLEARANCES (design fit, not kerf)": m.clearances.map((c) =>
|
|
@@ -91,3 +91,23 @@ export function rotateNormals(normals, m) {
|
|
|
91
91
|
normals[i + 2] = m[2] * x + m[6] * y + m[10] * z;
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
// Whether a column-major mat4 is a rigid motion within `tol`: finite, affine
|
|
96
|
+
// (last row 0 0 0 1), an orthonormal 3x3 block (RᵀR = I) with det = +1 — no
|
|
97
|
+
// scale, shear or mirror. The preview GLB's encoder refuses any other pose, and
|
|
98
|
+
// the sampler (preview-animation.js) applies the same test so its output never
|
|
99
|
+
// trips that refusal.
|
|
100
|
+
export function isRigid(m, tol = 1e-6) {
|
|
101
|
+
if (!Array.isArray(m) && !ArrayBuffer.isView(m)) return false;
|
|
102
|
+
if (m.length !== 16 || !Array.prototype.every.call(m, Number.isFinite)) return false;
|
|
103
|
+
if (Math.abs(m[3]) > tol || Math.abs(m[7]) > tol || Math.abs(m[11]) > tol || Math.abs(m[15] - 1) > tol) return false;
|
|
104
|
+
const col = (c) => [m[c * 4], m[c * 4 + 1], m[c * 4 + 2]];
|
|
105
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
106
|
+
const cols = [col(0), col(1), col(2)];
|
|
107
|
+
for (let i = 0; i < 3; i++) {
|
|
108
|
+
for (let j = 0; j < 3; j++) if (Math.abs(dot(cols[i], cols[j]) - (i === j ? 1 : 0)) > tol) return false;
|
|
109
|
+
}
|
|
110
|
+
const [a, b, c] = cols;
|
|
111
|
+
const det = a[0] * (b[1] * c[2] - b[2] * c[1]) - b[0] * (a[1] * c[2] - a[2] * c[1]) + c[0] * (a[1] * b[2] - a[2] * b[1]);
|
|
112
|
+
return Math.abs(det - 1) <= tol;
|
|
113
|
+
}
|
|
@@ -24,9 +24,25 @@
|
|
|
24
24
|
// which the viewer reads as "no feature edges" (never undefined, which would
|
|
25
25
|
// make it derive lines by angle).
|
|
26
26
|
//
|
|
27
|
+
// The baked animation (encoder version 4) comes back as `animation`, null for a
|
|
28
|
+
// file without the partforgeAnimation extra (every version 1–3 file):
|
|
29
|
+
// { ...the extra (name, label, loop, duration, cues, opacity, steps, animated),
|
|
30
|
+
// tracks: { <sub>: { times, translations, rotations } }, rest: { <sub>: mat16 } }.
|
|
31
|
+
// `times` are seconds; `translations` xyz triples and `rotations` xyzw quads are
|
|
32
|
+
// the node's TRS keyframes, which the writer expresses in the root's LOCAL frame
|
|
33
|
+
// — the part's Z-up MODEL frame — so they need no conversion (positions do: they
|
|
34
|
+
// go through the node's world matrix, root rotation included). Only sub-parts
|
|
35
|
+
// that move have a track; `rest[name]` is the column-major mat4 of a track's
|
|
36
|
+
// first keyframe (t = 0), the pose already baked into that sub-part's
|
|
37
|
+
// positions — meshes decode at the OPENING pose. Channels are read off the raw
|
|
38
|
+
// glTF JSON and resolved through the parser, never by AnimationClip track name
|
|
39
|
+
// (three sanitizes node names). An animated sub-part's mesh sits on an unnamed
|
|
40
|
+
// child of its node (quantize() moves it there), so a mesh's sub-part is found
|
|
41
|
+
// by walking up to the nearest node carrying partforgeSubPart.
|
|
42
|
+
//
|
|
27
43
|
// Rejects on anything that is not a decodable GLB; the callers (mount's
|
|
28
44
|
// `preview`, mountPreview) decide what a failure means for them.
|
|
29
|
-
import { Matrix3, Matrix4, Vector3 } from "three";
|
|
45
|
+
import { Matrix3, Matrix4, Quaternion, Vector3 } from "three";
|
|
30
46
|
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
31
47
|
import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
|
|
32
48
|
|
|
@@ -64,16 +80,72 @@ function metaOf(extras) {
|
|
|
64
80
|
return Object.keys(out).length ? out : null;
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
// The object standing for a glTF node: the
|
|
68
|
-
//
|
|
69
|
-
//
|
|
83
|
+
// The object standing for a glTF node: the nearest of the object and its
|
|
84
|
+
// ancestors (below the scene root) carrying the writer's partforgeSubPart — an
|
|
85
|
+
// animated sub-part's mesh sits one or two levels under it (quantize()'s unnamed
|
|
86
|
+
// child, then GLTFLoader's Group for a multi-primitive mesh). Failing that, the
|
|
87
|
+
// object itself when its node had one primitive, else the Group GLTFLoader made
|
|
88
|
+
// for a multi-primitive mesh — the one carrying the node's extras / name (a
|
|
89
|
+
// primitive's own Mesh has neither).
|
|
70
90
|
function nodeObjectOf(obj) {
|
|
91
|
+
for (let o = obj; o && o.parent; o = o.parent) {
|
|
92
|
+
if (typeof o.userData?.partforgeSubPart === "string") return o;
|
|
93
|
+
}
|
|
71
94
|
const own = obj.userData ?? {};
|
|
72
95
|
if ("partforgeSubPart" in own || "name" in own) return obj;
|
|
73
96
|
const parent = obj.parent;
|
|
74
97
|
return parent?.isGroup && parent.userData && ("partforgeSubPart" in parent.userData || "name" in parent.userData) ? parent : obj;
|
|
75
98
|
}
|
|
76
99
|
|
|
100
|
+
// The sub-part key a node object stands for: the writer's own record of the key,
|
|
101
|
+
// then the unsanitized glTF node name.
|
|
102
|
+
function subPartNameOf(node) {
|
|
103
|
+
const { partforgeSubPart, name } = node.userData ?? {};
|
|
104
|
+
return typeof partforgeSubPart === "string" ? partforgeSubPart : (name ?? node.name);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// An accessor's elements as a Float32Array of `size` components each, through
|
|
108
|
+
// getX/getY/getZ/getW so a normalized output would be denormalized (this writer
|
|
109
|
+
// stores the channels as plain float32; the guard is for any other writer).
|
|
110
|
+
function readComponents(attr, size) {
|
|
111
|
+
const out = new Float32Array(attr.count * size);
|
|
112
|
+
const get = [attr.getX, attr.getY, attr.getZ, attr.getW].slice(0, size);
|
|
113
|
+
for (let i = 0; i < attr.count; i++) for (let k = 0; k < size; k++) out[i * size + k] = get[k].call(attr, i);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The baked animation (see the header): the partforgeAnimation extra plus the
|
|
118
|
+
// first glTF animation's channels as model-frame keyframes per sub-part.
|
|
119
|
+
async function readAnimation(gltf) {
|
|
120
|
+
const extra = gltf.userData?.partforgeAnimation;
|
|
121
|
+
if (!extra || typeof extra !== "object") return null;
|
|
122
|
+
const tracks = {};
|
|
123
|
+
const rest = {};
|
|
124
|
+
const def = gltf.parser.json.animations?.[0];
|
|
125
|
+
for (const channel of def?.channels ?? []) {
|
|
126
|
+
const path = channel.target?.path;
|
|
127
|
+
if ((path !== "translation" && path !== "rotation") || channel.target.node === undefined) continue;
|
|
128
|
+
const sampler = def.samplers[channel.sampler];
|
|
129
|
+
const node = await gltf.parser.getDependency("node", channel.target.node);
|
|
130
|
+
const [input, output] = await Promise.all([
|
|
131
|
+
gltf.parser.getDependency("accessor", sampler.input),
|
|
132
|
+
gltf.parser.getDependency("accessor", sampler.output),
|
|
133
|
+
]);
|
|
134
|
+
const track = (tracks[subPartNameOf(nodeObjectOf(node))] ??= { times: null, translations: null, rotations: null });
|
|
135
|
+
track.times ??= readComponents(input, 1);
|
|
136
|
+
if (path === "translation") track.translations = readComponents(output, 3);
|
|
137
|
+
else track.rotations = readComponents(output, 4);
|
|
138
|
+
}
|
|
139
|
+
for (const [name, track] of Object.entries(tracks)) {
|
|
140
|
+
const t = track.translations ?? new Float32Array(3);
|
|
141
|
+
const r = track.rotations ?? Float32Array.of(0, 0, 0, 1);
|
|
142
|
+
rest[name] = new Matrix4()
|
|
143
|
+
.compose(new Vector3(t[0], t[1], t[2]), new Quaternion(r[0], r[1], r[2], r[3]).normalize(), new Vector3(1, 1, 1))
|
|
144
|
+
.toArray();
|
|
145
|
+
}
|
|
146
|
+
return { ...extra, tracks, rest };
|
|
147
|
+
}
|
|
148
|
+
|
|
77
149
|
// Line segments as endpoint pairs in model frame: an indexed LINES primitive
|
|
78
150
|
// expanded back to pairs. Version 3's position attribute is the SURFACE's
|
|
79
151
|
// (GLTFLoader hands both objects the same BufferAttribute), so only the
|
|
@@ -137,10 +209,9 @@ export async function decodePreviewGlb(bytes) {
|
|
|
137
209
|
const indices = geometry.index ? Uint32Array.from(geometry.index.array) : null;
|
|
138
210
|
const count = surfaceVertexCount(position, geometry.index);
|
|
139
211
|
const node = nodeObjectOf(obj);
|
|
140
|
-
const {
|
|
212
|
+
const { partforgeDisplay } = node.userData;
|
|
141
213
|
const payload = {
|
|
142
|
-
|
|
143
|
-
name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ?? node.name),
|
|
214
|
+
name: subPartNameOf(node),
|
|
144
215
|
positions: readVec3(position, toModel, false, count),
|
|
145
216
|
normals: normal ? readVec3(normal, normalMatrix, true, count) : null,
|
|
146
217
|
indices,
|
|
@@ -166,5 +237,6 @@ export async function decodePreviewGlb(bytes) {
|
|
|
166
237
|
view: typeof extras.partforgeView === "string" ? extras.partforgeView : null,
|
|
167
238
|
meta: metaOf(extras),
|
|
168
239
|
meshes,
|
|
240
|
+
animation: await readAnimation(gltf),
|
|
169
241
|
};
|
|
170
242
|
}
|