partforge 0.133.3 → 0.135.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 +80 -19
- package/docs/KERNEL-CONTRACT.md +1 -1
- package/package.json +1 -1
- package/src/app-materials.js +3 -1
- package/src/framework/export/bundle.js +413 -0
- package/src/framework/export/drawing.js +356 -0
- package/src/framework/export/dxf.js +150 -0
- package/src/framework/export/formats.js +142 -0
- package/src/framework/export/layout.js +92 -0
- package/src/framework/export/placement.js +14 -0
- package/src/framework/export/readme.js +103 -0
- package/src/framework/export/svg.js +97 -0
- package/src/framework/export-controller.js +7 -2
- package/src/framework/export-rows.js +34 -0
- package/src/framework/jobs.js +16 -0
- package/src/framework/lint/rules-materials.js +9 -2
- package/src/framework/materials/assets/SOURCES.md +10 -4
- package/src/framework/materials/assets/pattern-plywood-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-plywood-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-plywood-rough.jpg +0 -0
- package/src/framework/materials/assets.js +3 -0
- package/src/framework/materials/patterns.js +103 -0
- package/src/framework/materials/physical.js +5 -2
- package/src/framework/materials/presets.js +11 -5
- package/src/framework/materials/resolve.js +4 -2
- package/src/framework/materials/sheet-look.js +204 -0
- package/src/framework/mount.js +74 -37
- package/src/framework/process/exporters.js +18 -0
- package/src/framework/process/laser/export.js +53 -0
- package/src/framework/sheet/part.js +32 -17
- package/src/framework/viewer.js +53 -29
- package/src/index.js +4 -0
- package/src/parts/laser-box.js +1 -1
- package/src/parts/material-swatches.js +58 -2
- package/types/index.d.ts +72 -5
- package/types/part.d.ts +4 -0
- package/types/testing.d.ts +7 -3
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -334,9 +334,10 @@ meta: { title: "…", units: "mm", environment: "studio" },
|
|
|
334
334
|
- `material` — a preset id from the table below. Without one (or with one the
|
|
335
335
|
library does not know) the sub-part keeps the CAD view's look — its `color`,
|
|
336
336
|
else the viewer's blue-grey — and **realistic mode shows it as a PLA print**
|
|
337
|
-
(`pla-print`'s finish and layer lines) in that same colour
|
|
338
|
-
|
|
339
|
-
|
|
337
|
+
(`pla-print`'s finish and layer lines) in that same colour — except a laser
|
|
338
|
+
`sheetPart`, which shows its stock instead (below). So an untouched part looks
|
|
339
|
+
printed in realistic mode; name a material when it is made some other way. The
|
|
340
|
+
PLA look is realistic-only: the CAD view, export colours and
|
|
340
341
|
`declaresMaterials` are unaffected, and a part that names no material is not
|
|
341
342
|
treated as declaring one.
|
|
342
343
|
- `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
|
|
@@ -349,6 +350,23 @@ meta: { title: "…", units: "mm", environment: "studio" },
|
|
|
349
350
|
- **One material per sub-part.** Something that needs two finishes (a knurled
|
|
350
351
|
grip in rubber on an aluminium body) is two sub-parts.
|
|
351
352
|
|
|
353
|
+
**Sheet parts default to their stock.** A laser `sheetPart` that names no material (or
|
|
354
|
+
one the library does not know) is not drawn as a PLA print: realistic mode shows it as the
|
|
355
|
+
sheet its stock label names — the first row with a stem that starts a word of the label,
|
|
356
|
+
ignoring case (so `Plexiglas`, `Acrylite` and `poly-carbonate` match; `perplexing` does not):
|
|
357
|
+
|
|
358
|
+
| A word of the stock label starts with | Realistic look |
|
|
359
|
+
| --- | --- |
|
|
360
|
+
| `acryl`, `perspex`, `plexi`, `pmma`, `methacryl`, `polycarb`, `lexan`, `makrolon`, `lucite` | `clear-acrylic` |
|
|
361
|
+
| anything else — plywood, birch, basswood, poplar, MDF, hardboard — or no readable label | `plywood` |
|
|
362
|
+
|
|
363
|
+
Stock that is neither wood nor acrylic — felt, leather, card, cork — takes the `plywood`
|
|
364
|
+
look too, charred edges and all. A `color` tints the look, as it tints any preset (a
|
|
365
|
+
stained plywood, a coloured acrylic); the CAD view still shows the `color` itself. Only a
|
|
366
|
+
string label is read: a stock written as a `(p, d)` function counts as unreadable. Name
|
|
367
|
+
`display.material` to choose the look yourself — for such stock, or any other. Like the
|
|
368
|
+
PLA look, this is realistic-only and never makes a part declare a material.
|
|
369
|
+
|
|
352
370
|
**Where it shows.** The CAD view (with feature lines) shows each material's
|
|
353
371
|
colour, flattened so dark materials stay readable. **Realistic** mode — the
|
|
354
372
|
viewer's toggle — shows the full material under environment lighting, with a
|
|
@@ -365,6 +383,15 @@ displayed. If the lines run the wrong way, fix the export pose with `place()`
|
|
|
365
383
|
fixed to the sub-part, so they never slide when the camera or an animation
|
|
366
384
|
moves.
|
|
367
385
|
|
|
386
|
+
**Laser-cut wood.** In realistic mode a laser `sheetPart` in a wood — `plywood`, `oak` or
|
|
387
|
+
`walnut`, named or its stock's default (so a plywood sheet that names no material burns too)
|
|
388
|
+
— shows what the laser did: its cut edges are charred, darker on thicker stock (on
|
|
389
|
+
`plywood` the plies show through), and its engraving and score lines are scorched, while its
|
|
390
|
+
faces stay wood. There is nothing to set, and it is still one material: the char follows
|
|
391
|
+
from the sheet and the wood it is drawn in. It needs the sheet's own frame, so a sheet part
|
|
392
|
+
with a custom `build` or its own `place` shows plain wood. The CAD view and every export are
|
|
393
|
+
unchanged.
|
|
394
|
+
|
|
368
395
|
**`textureScale` is millimetres, and what it measures depends on the preset's
|
|
369
396
|
pattern** — so a value copied from one preset family is wrong on another (0.2
|
|
370
397
|
on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
|
|
@@ -373,7 +400,7 @@ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
|
|
|
373
400
|
| --- | --- | --- | --- |
|
|
374
401
|
| layer lines | `pla-print`, `petg-print` | layer height | 0.2 |
|
|
375
402
|
| SLS grain | `nylon-sls` | grain size | 0.15 |
|
|
376
|
-
| wood | `oak`, `walnut` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`) |
|
|
403
|
+
| wood | `oak`, `walnut`, `plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`) |
|
|
377
404
|
| carbon weave | `carbon-fiber` | size of one texture tile | 48 |
|
|
378
405
|
|
|
379
406
|
Presets without a pattern ignore it.
|
|
@@ -409,6 +436,7 @@ the viewer's blue-grey.
|
|
|
409
436
|
| `rubber` | Rubber | yes | Matte elastomer: gaskets, feet, grips; tint with `color`. |
|
|
410
437
|
| `oak` | Oak | — | Light oak with open grain. |
|
|
411
438
|
| `walnut` | Walnut | — | Dark oiled walnut. |
|
|
439
|
+
| `plywood` | Birch plywood | — | Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges. |
|
|
412
440
|
| `carbon-fiber` | Carbon fibre | — | 2x2 twill carbon fibre under clear coat. |
|
|
413
441
|
|
|
414
442
|
**Environments** (`meta.environment`, default `studio`; viewers can switch):
|
|
@@ -1808,8 +1836,8 @@ needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d)
|
|
|
1808
1836
|
`material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
|
|
1809
1837
|
`process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
|
|
1810
1838
|
`enabled`, `exportable`, `reference` pass through; your own `place` runs after the
|
|
1811
|
-
pose. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut`
|
|
1812
|
-
`
|
|
1839
|
+
pose. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` and
|
|
1840
|
+
`quantity`; each error names the fix.
|
|
1813
1841
|
|
|
1814
1842
|
### Cut, score and engrave
|
|
1815
1843
|
|
|
@@ -1898,7 +1926,7 @@ PRINT pose, `place` it for display, and cut the slot where the tongue lands:
|
|
|
1898
1926
|
| `verify-process-sheets-only` | warning | `verify.process` is set but every exportable part is a sheet part |
|
|
1899
1927
|
| `laser-thickness-range` | warning | a laser sheet is thinner than 0.5 mm or thicker than 12 mm |
|
|
1900
1928
|
|
|
1901
|
-
**Verify** runs the laser checks on every sheet part
|
|
1929
|
+
**Verify** runs the laser checks on every sheet part, as
|
|
1902
1930
|
*volunteered* warnings: none counts toward `declared`/`evaluated`, so none makes
|
|
1903
1931
|
`verify.ok` true on its own. Declare one in `expect` to make it count:
|
|
1904
1932
|
|
|
@@ -1920,16 +1948,21 @@ unevaluated. In a forge mixing sheet and printed
|
|
|
1920
1948
|
parts, the profile's bed fits each **printed** part in its print (export) pose, not
|
|
1921
1949
|
the assembled view, and `minWall` and the overhang check skip sheet parts.
|
|
1922
1950
|
|
|
1951
|
+
### The kit
|
|
1952
|
+
|
|
1953
|
+
A host downloads sheet parts as one ZIP, the **cut & print kit** (`format: "bundle"`): `README.txt`, `parts.csv`, laid-out `sheets/`, one `parts/` file per distinct piece (plus a `-marks.dxf` for a cutting service) and `print/` for printed parts. Identical pieces merge into one `-xN` file named from `export: { name }`, else the key. Destination (`"own-laser"` or `"service"`), kerf and stock size are chosen at download, so draw nominal and bind `thickness` to the measured control. A choice the kit cannot honour fails with `cut kit options:`.
|
|
1954
|
+
|
|
1923
1955
|
### Limits
|
|
1924
1956
|
|
|
1925
1957
|
- One thickness per joint: fingers, tabs and T-slots join panels cut from the
|
|
1926
1958
|
same sheet.
|
|
1927
|
-
- Poses are rigid and axis-aligned: `face` and `up` are the six axis words.
|
|
1928
|
-
panel
|
|
1959
|
+
- Poses are rigid and axis-aligned: `face` and `up` are the six axis words. An
|
|
1960
|
+
angled panel is an author `place` after the pose (realistic mode then shows
|
|
1961
|
+
no laser burns), or a printed part.
|
|
1929
1962
|
- No bends, folds, living hinges or grain direction: `folds`, `bends` and
|
|
1930
1963
|
`grain` are reserved keys and throw.
|
|
1931
|
-
- Cut and score are vector lines; engraving is filled regions
|
|
1932
|
-
image
|
|
1964
|
+
- Cut and score are vector lines; engraving is filled regions, never a raster
|
|
1965
|
+
image.
|
|
1933
1966
|
- The laser checks are warnings. Sharp-corner shrinking can over-report at acute
|
|
1934
1967
|
tips, and a web within 0.05 mm of the floor can read as passing.
|
|
1935
1968
|
|
|
@@ -1982,7 +2015,7 @@ const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
|
|
|
1982
2015
|
[hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
|
|
1983
2016
|
k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
|
|
1984
2017
|
|
|
1985
|
-
const PLY = { views: ["box"], display: { material: "
|
|
2018
|
+
const PLY = { views: ["box"], display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
|
|
1986
2019
|
const panel = (name, label, extra = {}) => sheetPart({
|
|
1987
2020
|
...PLY, label,
|
|
1988
2021
|
profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
|
|
@@ -2995,15 +3028,41 @@ view-bound export UI. An embedder that wants its own export UI (e.g. a "pick whi
|
|
|
2995
3028
|
pick a format" modal) can skip those buttons and drive export off the handle `mount()`
|
|
2996
3029
|
returns instead:
|
|
2997
3030
|
|
|
2998
|
-
- `runtime.listExportableParts() → [{ name, label }]` — every exportable sub-part
|
|
3031
|
+
- `runtime.listExportableParts() → [{ name, label, sheet? }]` — every exportable sub-part
|
|
2999
3032
|
(excludes any `exportable: false` part, respects each part's `enabled(params)`),
|
|
3000
|
-
**independent of the active view**. Use it to populate an export checklist.
|
|
3001
|
-
|
|
3002
|
-
the
|
|
3033
|
+
**independent of the active view**. Use it to populate an export checklist. A sheet
|
|
3034
|
+
part's row also carries `sheet: { process, material, thickness, group }`, evaluated at
|
|
3035
|
+
the current params (omitted when that throws) — enough to tag it in the list and to
|
|
3036
|
+
name its stock group in the kit's `stock` option.
|
|
3037
|
+
- `runtime.listExportFormats() → [{ id, label, ext, mime, needsSheet }]` — the formats
|
|
3038
|
+
`exportParts` writes in this version, as fresh copies of `EXPORT_FORMATS`. `needsSheet`
|
|
3039
|
+
marks the one that needs a sheet part checked: `"bundle"`, the cut & print kit.
|
|
3040
|
+
- `runtime.exportParts({ parts, format, quality?, onProgress, options? }) → Promise<void>` — build
|
|
3041
|
+
the given `parts` (sub-part names) in `format` (`"stl" | "step" | "3mf" | "bundle"`), streaming
|
|
3003
3042
|
phase strings to `onProgress(phase)`. Resolves once the file is written (handed to your
|
|
3004
3043
|
`onDownload` sink, or downloaded directly if you don't supply one); rejects on
|
|
3005
3044
|
build/export failure or an empty selection. Placement uses the current
|
|
3006
|
-
view. STEP is routed to OCCT automatically.
|
|
3045
|
+
view. STEP is routed to OCCT automatically. Only `"bundle"` reads `options`.
|
|
3046
|
+
- **The cut & print kit** (`format: "bundle"`) writes one `<title>-kit.zip`: `README.txt`
|
|
3047
|
+
(the thickness each material assumes, the kerf applied, a scale check per piece, the
|
|
3048
|
+
colour or DXF-layer legend, every 2-D check that warned), `parts.csv`,
|
|
3049
|
+
`sheets/<material>-<t>mm/sheet-<i>-of-<n>.svg` (the pieces laid out on the user's
|
|
3050
|
+
stock), `parts/<name>[-xN].svg` (one per distinct piece; identical pieces merge) and
|
|
3051
|
+
`print/<name>[-xN].stl` or `.3mf` (the printed parts, in their export pose). With
|
|
3052
|
+
`destination: "service"` each piece is a cut-only `.dxf` plus a `-marks.dxf` for its
|
|
3053
|
+
score and engrave lines, and the DXF sheets are for reference. Every `options` key is
|
|
3054
|
+
optional: `destination` (`"own-laser"`, the default, or `"service"`), `kerf` (0–0.5 mm,
|
|
3055
|
+
applied to the cut lines only), `stock: [{ group, size: [w, h] }]` (a row's
|
|
3056
|
+
`sheet.group`; a `"*"` entry sizes every group that has no entry of its own; 300 × 300
|
|
3057
|
+
mm when `stock` is omitted), `margin` (5 mm),
|
|
3058
|
+
`spacing` (3 mm), `printFormat` (`"stl"` or `"3mf"`) and `sets` (1–20). The main entry
|
|
3059
|
+
exports `EXPORT_FORMATS`, `KIT_DEFAULTS`, `validateKitOptions` and `KIT_OPTIONS_ERROR`
|
|
3060
|
+
for a host that draws its own options screen. An option the kit cannot honour — an
|
|
3061
|
+
unknown key, a kerf that closes a slot, a piece bigger than an own laser's sheet, a
|
|
3062
|
+
margin that leaves no room on the sheet, a stock entry naming no group, more than 200
|
|
3063
|
+
pieces or 50 sheets of one material —
|
|
3064
|
+
rejects with a message starting `cut kit options:`: the user's setting to change, not
|
|
3065
|
+
the part's code.
|
|
3007
3066
|
- `runtime.warmExportKernel() → Promise<boolean>` — pay OCCT's cold boot *before* an
|
|
3008
3067
|
export needs it. Because STEP is pinned to OCCT and OCCT's ~11 MB WASM loads on its
|
|
3009
3068
|
first job, a part whose preview ran on Manifold pays that whole boot inside its first
|
|
@@ -3147,7 +3206,8 @@ addition to) the generated view style button above:
|
|
|
3147
3206
|
revert too.
|
|
3148
3207
|
- `runtime.declaresMaterials` — `true` when any sub-part names a
|
|
3149
3208
|
`display.material`. A part with none still supports realistic mode (every
|
|
3150
|
-
sub-part renders as a PLA print in its CAD colour
|
|
3209
|
+
sub-part renders as a PLA print in its CAD colour, a laser sheet part as its
|
|
3210
|
+
stock — "Sheet parts default to their stock"), so use this to decide
|
|
3151
3211
|
whether to surface your own realistic control at all, not whether it works.
|
|
3152
3212
|
Feature lines are not a preference: they draw whenever `runtime.renderMode`
|
|
3153
3213
|
reads `"cad"` and never while it reads `"realistic"` — there is no switch
|
|
@@ -3635,7 +3695,8 @@ trust handling.
|
|
|
3635
3695
|
|
|
3636
3696
|
**Appearance** (all warnings) — `unknown-material` (a `display.material` the
|
|
3637
3697
|
library does not know; the viewer draws it as if it named none — a PLA print
|
|
3638
|
-
in realistic mode
|
|
3698
|
+
in realistic mode, or for a laser sheet part its stock's look, per "Sheet parts
|
|
3699
|
+
default to their stock"), `unknown-environment`
|
|
3639
3700
|
(`meta.environment` not known; realistic mode uses `studio`),
|
|
3640
3701
|
`material-key-unknown` (a `display` key that is not colour, opacity, material
|
|
3641
3702
|
or one of the six overrides; ignored), `material-override-clamped` (an override
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -852,7 +852,7 @@ maps that protocol onto `setPart` itself.
|
|
|
852
852
|
readiness exactly as it would on a freshly spawned worker.
|
|
853
853
|
|
|
854
854
|
**Epoch guard.** Generates supersede each other; exports (`export-stl`/`export-step`/
|
|
855
|
-
`export-3mf`), `inspect`, and `lint` are **never** epoch-guarded — cancelling a user's
|
|
855
|
+
`export-3mf`/`export-bundle`), `inspect`, and `lint` are **never** epoch-guarded — cancelling a user's
|
|
856
856
|
export because an edit landed would be wrong. A generate that is stale by the time the
|
|
857
857
|
job pump reaches it is skipped and never builds at all. A generate already running
|
|
858
858
|
re-checks staleness at each sub-part boundary and, if it has been superseded, stops
|
package/package.json
CHANGED
package/src/app-materials.js
CHANGED
|
@@ -12,7 +12,9 @@ import { mount } from "./framework/index.js";
|
|
|
12
12
|
// open /materials.html. Not part of the production build — check materials.html
|
|
13
13
|
// by eye after any preset or shader change (see AGENTS.md "Architecture").
|
|
14
14
|
// Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
|
|
15
|
-
// the embedding contract (runtime.captureCurrent) the way an embedder would
|
|
15
|
+
// the embedding contract (runtime.captureCurrent) the way an embedder would, and
|
|
16
|
+
// scripts/capture-contact-sheet.mjs can switch views and environments and call
|
|
17
|
+
// runtime.renderViews — removing it breaks both.
|
|
16
18
|
window.__pfRuntime = mount(materialSwatchesPart, {
|
|
17
19
|
createWorker: (name) =>
|
|
18
20
|
new Worker(new URL("./materials-worker.js", import.meta.url), { type: "module", name }),
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// The cut & print kit (spec D): one ZIP per forge — every checked sheet part drawn flat
|
|
2
|
+
// and laid out on the user's stock, every checked printed part as STL or 3MF, a README
|
|
3
|
+
// and a parts list.
|
|
4
|
+
//
|
|
5
|
+
// jobs.js loads this module LAZILY, on the first export-bundle job (`loadBundle`), never
|
|
6
|
+
// at worker boot (test/worker-layering.test.js). And nothing here may reach ./oracle/*
|
|
7
|
+
// (test/kit-layering.test.js mocks every oracle module to throw): the mesh oracle has
|
|
8
|
+
// OOM-killed iOS Safari, and the kit needs none of it — a sheet is drawn from its 2-D
|
|
9
|
+
// declaration and checked by its own process's facts()/checks().
|
|
10
|
+
import { zipSync, strToU8 } from "fflate";
|
|
11
|
+
import { safeName } from "../safe-name.js";
|
|
12
|
+
import { meshTo3MF } from "../geometry/threemf.js";
|
|
13
|
+
import { h } from "../geometry/solid-hash.js";
|
|
14
|
+
import { printColor } from "../materials/resolve.js";
|
|
15
|
+
import {
|
|
16
|
+
isSheetPart, sheetMeta, fmtMm, SHEET_CHECK_BUDGET_MS, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT,
|
|
17
|
+
} from "../sheet/constants.js";
|
|
18
|
+
import { resolveSheet } from "../sheet/resolve.js";
|
|
19
|
+
import { processById, SHEET_METRICS } from "../process/registry.js";
|
|
20
|
+
import { loadExporter } from "../process/exporters.js";
|
|
21
|
+
import { validateKitOptions, resolveStock, KIT_OPTIONS_ERROR, KIT_LIMITS } from "./formats.js";
|
|
22
|
+
import { canonicalDrawingKey } from "./drawing.js";
|
|
23
|
+
import { renderSvg } from "./svg.js";
|
|
24
|
+
import { renderDxf } from "./dxf.js";
|
|
25
|
+
import { layoutGroup, marginFitsStock } from "./layout.js";
|
|
26
|
+
import { renderReadme, renderPartsCsv, cleanLabel } from "./readme.js";
|
|
27
|
+
|
|
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
|
+
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+)?(-marks)?\.(svg|dxf)|print\/[a-z0-9][a-z0-9._-]*(-x\d+)?\.(stl|3mf))$/;
|
|
31
|
+
|
|
32
|
+
// A path segment from an author string. safeName already lowercases, collapses runs and
|
|
33
|
+
// refuses a leading dot or dash; folding every run of dots on top means no entry name
|
|
34
|
+
// can ever carry "..". safeName keeps a leading underscore, which KIT_ENTRY_RE (copied
|
|
35
|
+
// verbatim by the cloud) refuses, so it comes off here too — a sub-part keyed `_left`
|
|
36
|
+
// is `left`, never a kit that fails at its last step — and a name that was nothing but
|
|
37
|
+
// separators falls back to "part".
|
|
38
|
+
export const kitName = (s) => safeName(s, "part").replace(/\.{2,}/g, ".").replace(/^[._-]+/, "") || "part";
|
|
39
|
+
|
|
40
|
+
// The first of base, base-2, base-3, … whose files are all free, claimed in `taken`.
|
|
41
|
+
// `filesOf(name)` lists every path a name produces — a piece's cut file and its -marks
|
|
42
|
+
// file — so a name is free only when all of them are, and "front" ×2 can never land on
|
|
43
|
+
// the file of a piece literally named "front-x2". Called in definition order, so a
|
|
44
|
+
// forge always gets the same names.
|
|
45
|
+
export function uniqueName(base, taken, filesOf = (name) => [name]) {
|
|
46
|
+
let name = base;
|
|
47
|
+
for (let i = 2; filesOf(name).some((f) => taken.has(f)); i++) name = `${base}-${i}`;
|
|
48
|
+
for (const f of filesOf(name)) taken.add(f);
|
|
49
|
+
return name;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Merge equal items, first-seen order kept. `hashOf` only NOMINATES candidates — the kit
|
|
53
|
+
// hashes with solid-hash.js's 32-bit FNV, which can collide — and `same(a, b)` confirms.
|
|
54
|
+
// Returns [{ item, members }], `item` being the first member.
|
|
55
|
+
export function dedupe(items, hashOf, same) {
|
|
56
|
+
const groups = [];
|
|
57
|
+
const byHash = new Map();
|
|
58
|
+
for (const item of items) {
|
|
59
|
+
const key = hashOf(item);
|
|
60
|
+
const candidates = byHash.get(key) ?? [];
|
|
61
|
+
const hit = candidates.find((g) => same(g.item, item));
|
|
62
|
+
if (hit) { hit.members.push(item); continue; }
|
|
63
|
+
const group = { item, members: [item] };
|
|
64
|
+
candidates.push(group);
|
|
65
|
+
byHash.set(key, candidates);
|
|
66
|
+
groups.push(group);
|
|
67
|
+
}
|
|
68
|
+
return groups;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// verify's wording for the three expression forms a process's checks() emits — ">=N",
|
|
72
|
+
// "<=N" and a bare number (equality) — restated from oracle/assert-dsl.js, because the
|
|
73
|
+
// kit must never load ./oracle/*. test/kit-bundle-helpers.test.js holds the two equal.
|
|
74
|
+
// Any other form throws: it needs a line here, not a silent pass.
|
|
75
|
+
const EPS = 1e-6;
|
|
76
|
+
export function checkMessage(expr, actual) {
|
|
77
|
+
const m = /^(>=|<=)?\s*([-+]?[0-9]*\.?[0-9]+)$/.exec(String(expr).trim());
|
|
78
|
+
if (!m) throw new Error(`cut kit: unsupported sheet check "${expr}"`);
|
|
79
|
+
const value = Number(m[2]);
|
|
80
|
+
if (m[1] === ">=") {
|
|
81
|
+
const pass = actual >= value - EPS;
|
|
82
|
+
return { pass, message: `${actual} ${pass ? ">=" : "not >="} ${value}` };
|
|
83
|
+
}
|
|
84
|
+
if (m[1] === "<=") {
|
|
85
|
+
const pass = actual <= value + EPS;
|
|
86
|
+
return { pass, message: `${actual} ${pass ? "<=" : "not <="} ${value}` };
|
|
87
|
+
}
|
|
88
|
+
const pass = Math.abs(actual - value) <= EPS + EPS * Math.abs(value);
|
|
89
|
+
return { pass, message: `${actual} ${pass ? "==" : "!="} ${value}` };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// SHEET_CHECKS_NOTICE.message ("2-D sheet checks not evaluated (time budget)") is
|
|
93
|
+
// oracle/verify.js's own wording for the same state, imported from sheet/constants.js —
|
|
94
|
+
// the one place it lives, since the kit must never load ./oracle/*. The kit appends its
|
|
95
|
+
// own reason to the shared prefix rather than restating the whole line.
|
|
96
|
+
const NOT_EVALUATED = `${SHEET_CHECKS_NOTICE.message} — the kit ran out of time checking this piece for narrow webs, narrow gaps and stray marks; look it over before cutting`;
|
|
97
|
+
|
|
98
|
+
// The README's CHECKS lines for one piece: every volunteered check its process runs
|
|
99
|
+
// that fails, as `${label}: ${message} — ${hint}` with the metric's own hint. A metric
|
|
100
|
+
// with no reading (budget-gated and not evaluated, or a custom build's volume match,
|
|
101
|
+
// which needs a solid the kit never builds) is skipped; an unevaluated piece gets one
|
|
102
|
+
// line saying so instead. A metric the geometry engine refused to read (readErrors) gets
|
|
103
|
+
// its own line, worded like verify's: `not measured — <reason>`, with SHEET_READ_ERROR_HINT
|
|
104
|
+
// (sheet/constants.js, imported above — the kit must never load ./oracle/* itself, so it
|
|
105
|
+
// reads the one shared text rather than a copy) — never the metric's own hint, which
|
|
106
|
+
// describes a value that was never taken.
|
|
107
|
+
export function sheetWarnings(label, facts) {
|
|
108
|
+
const desc = facts ? processById(facts.process) : null;
|
|
109
|
+
if (!desc) return [];
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const [metric, expr] of Object.entries(desc.checks(facts))) {
|
|
112
|
+
const reg = SHEET_METRICS[metric];
|
|
113
|
+
const actual = reg?.extract({ sheet: facts });
|
|
114
|
+
if (actual === null || actual === undefined) {
|
|
115
|
+
const why = reg?.readError?.({ sheet: facts });
|
|
116
|
+
if (why) out.push(`${label}: not measured — ${why} — ${SHEET_READ_ERROR_HINT}`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const { pass, message } = checkMessage(expr, actual);
|
|
120
|
+
if (!pass) out.push(`${label}: ${message} — ${reg.hint}`);
|
|
121
|
+
}
|
|
122
|
+
if (facts.evaluated === false) out.push(`${label}: ${NOT_EVALUATED}`);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── buildBundle ────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
// cl:src/sandbox/protocol.js clips a progress phase at 120 characters; the kit keeps
|
|
129
|
+
// its own phases under that, so a long label is cut here, once, with an ellipsis.
|
|
130
|
+
const PHASE_MAX = 120;
|
|
131
|
+
const clipPhase = (s) => (s.length > PHASE_MAX ? `${s.slice(0, PHASE_MAX - 1)}…` : s);
|
|
132
|
+
const sizeOf = (b) => [b.max[0] - b.min[0], b.max[1] - b.min[1]];
|
|
133
|
+
const near = (a, b) => Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b));
|
|
134
|
+
// The words of a control's key or label: camelCase, snake_case, digits and spaces all
|
|
135
|
+
// split, lowercased — "fingerClearance" → finger, clearance.
|
|
136
|
+
const wordsOf = (s) => String(s ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([a-zA-Z])([0-9])/g, "$1 $2")
|
|
137
|
+
.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
138
|
+
// Controls the README lists as design fit rather than kerf (spec D.9): a WORD of the key
|
|
139
|
+
// or label is clearance, fit or play — so "Finger clearance", "tab_play" and "pressFit"
|
|
140
|
+
// are listed, and "display", "outfit" and "profit" are not.
|
|
141
|
+
const CLEARANCE_WORDS = new Set(["clearance", "clearances", "fit", "play"]);
|
|
142
|
+
const isClearance = (c) => [...wordsOf(c.key), ...wordsOf(c.label)].some((w) => CLEARANCE_WORDS.has(w));
|
|
143
|
+
const ALL_LAYERS = ["cut", "score", "engrave"];
|
|
144
|
+
|
|
145
|
+
// Every control that owns a parameter key, whatever the section shape: authored
|
|
146
|
+
// `controls` (groups nest), and the legacy `advanced`, `toggles`, `features` and a
|
|
147
|
+
// feature's own `sliders`. Groups and presets own no key.
|
|
148
|
+
function collectControls(parameters) {
|
|
149
|
+
const out = [];
|
|
150
|
+
const visit = (list) => {
|
|
151
|
+
for (const c of Array.isArray(list) ? list : []) {
|
|
152
|
+
if (!c || typeof c !== "object") continue;
|
|
153
|
+
if (typeof c.key === "string" && c.type !== "group" && c.type !== "preset") out.push(c);
|
|
154
|
+
visit(c.controls);
|
|
155
|
+
visit(c.sliders);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
for (const sec of Array.isArray(parameters) ? parameters : []) {
|
|
159
|
+
visit(sec?.controls); visit(sec?.advanced); visit(sec?.toggles); visit(sec?.features);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The control that sets a sheet part's thickness, so the README can name the setting to
|
|
165
|
+
// change when the user's sheet measures differently. Of the controls the thickness
|
|
166
|
+
// function reads, the one whose value IS the thickness it returned (a
|
|
167
|
+
// `p.material === "acrylic" ? p.acrylicT : p.plyT` reads Material first, and Material
|
|
168
|
+
// is not the thickness); failing a single such control, the one read whose key or label
|
|
169
|
+
// says "thick"; else null — the generic wording, never a guess. Null too for a literal
|
|
170
|
+
// thickness or one that reads no control.
|
|
171
|
+
function thicknessControlLabel(controls, sp, p, d) {
|
|
172
|
+
const t = sp.sheet.thickness;
|
|
173
|
+
if (typeof t !== "function") return null;
|
|
174
|
+
const reads = new Set();
|
|
175
|
+
const spy = new Proxy(p, { get: (target, key) => { if (typeof key === "string") reads.add(key); return target[key]; } });
|
|
176
|
+
let value;
|
|
177
|
+
try { value = t(spy, d); } catch { return null; }
|
|
178
|
+
const read = controls.filter((x) => reads.has(x.key));
|
|
179
|
+
const holds = read.filter((x) => typeof p[x.key] === "number" && Math.abs(p[x.key] - value) <= 1e-9);
|
|
180
|
+
const pool = holds.length > 1 ? holds : read;
|
|
181
|
+
const named = pool.filter((x) => [...wordsOf(x.key), ...wordsOf(x.label)].some((w) => w.startsWith("thick")));
|
|
182
|
+
const c = holds.length === 1 ? holds[0] : named.length === 1 ? named[0] : null;
|
|
183
|
+
return c ? String(c.label ?? c.key) : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A sheet part's stock group, or the part's own named error: sheetMeta answers null
|
|
187
|
+
// when material or thickness cannot be evaluated, and resolveSheet is what knows how to
|
|
188
|
+
// say why.
|
|
189
|
+
function sheetMetaOrThrow(kernel, sp, p, d, name) {
|
|
190
|
+
const meta = sheetMeta(sp, p, d);
|
|
191
|
+
if (meta) return meta;
|
|
192
|
+
resolveSheet(kernel, sp, p, d);
|
|
193
|
+
throw new Error(`sheet part "${name}": its material or thickness could not be evaluated`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// One export-bundle job's kit. `names` is the job's selected() — exportable, enabled, in
|
|
197
|
+
// definition order — and `posed`/`label`/`exportName` are jobs.js's own, so a kit's
|
|
198
|
+
// printed parts are built exactly the way the STL and 3MF exports build them.
|
|
199
|
+
export async function buildBundle({ kernel, part, msg, p, d, posed, label, exportName, names, onProgress, fileBase }) {
|
|
200
|
+
// Options first, before anything is built: a bad option is the user's to fix and must
|
|
201
|
+
// not wait behind a build (spec D.3). An absent options object means every default.
|
|
202
|
+
const o = validateKitOptions(msg.options ?? {});
|
|
203
|
+
const service = o.destination === "service";
|
|
204
|
+
const cutExt = service ? "dxf" : "svg";
|
|
205
|
+
const say = (phase) => onProgress(clipPhase(phase));
|
|
206
|
+
const nameOf = (name) => cleanLabel(label(name));
|
|
207
|
+
if (names.length === 0) throw new Error("no exportable parts selected");
|
|
208
|
+
const sheetNames = names.filter((name) => isSheetPart(part.parts[name]));
|
|
209
|
+
const printNames = names.filter((name) => !isSheetPart(part.parts[name]));
|
|
210
|
+
if (sheetNames.length === 0) {
|
|
211
|
+
throw new Error(`${KIT_OPTIONS_ERROR} none of the checked parts is a sheet part — check a sheet part or export STL/3MF instead`);
|
|
212
|
+
}
|
|
213
|
+
// The cut-piece count is sheet sub-parts × sets — exactly what the merged pieces sum
|
|
214
|
+
// to — so it is checked before the builds it would otherwise waste. The remedy named
|
|
215
|
+
// is one that can work: past the cap at a single set, only fewer parts will do.
|
|
216
|
+
const cutCount = sheetNames.length * o.sets;
|
|
217
|
+
if (cutCount > KIT_LIMITS.pieces) {
|
|
218
|
+
const remedy = sheetNames.length > KIT_LIMITS.pieces ? "check fewer sheet parts" : "lower sets";
|
|
219
|
+
throw new Error(`${KIT_OPTIONS_ERROR} the kit has ${cutCount} pieces — at most ${KIT_LIMITS.pieces}; ${remedy}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Stock groups from each part's material and thickness alone, so a stock entry that
|
|
223
|
+
// names no group in the kit fails before any drawing is made.
|
|
224
|
+
const groups = new Map();
|
|
225
|
+
const groupOf = new Map();
|
|
226
|
+
for (const name of sheetNames) {
|
|
227
|
+
const meta = sheetMetaOrThrow(kernel, part.parts[name], p, d, nameOf(name));
|
|
228
|
+
if (!groups.has(meta.group)) {
|
|
229
|
+
const material = cleanLabel(meta.material);
|
|
230
|
+
groups.set(meta.group, { group: meta.group, material, thickness: meta.thickness,
|
|
231
|
+
label: `${material} ${fmtMm(meta.thickness)} mm`, first: name });
|
|
232
|
+
}
|
|
233
|
+
groupOf.set(name, meta.group);
|
|
234
|
+
}
|
|
235
|
+
const stockFor = resolveStock(o.stock, [...groups.values()].map((g) => ({ group: g.group, label: g.label })));
|
|
236
|
+
// A margin that leaves a group's stock with no inside at all is known right here —
|
|
237
|
+
// check it before any piece is drawn (spec D.3), not only once layoutGroup reaches
|
|
238
|
+
// that group after every sheet part has been built.
|
|
239
|
+
for (const g of groups.values()) marginFitsStock(stockFor.get(g.group), o.margin, g.label);
|
|
240
|
+
|
|
241
|
+
// Draw every sheet part flat, from its 2-D declaration, with its 2-D facts under ONE
|
|
242
|
+
// budget for the whole kit.
|
|
243
|
+
const deadline = Date.now() + SHEET_CHECK_BUDGET_MS;
|
|
244
|
+
const drawn = [];
|
|
245
|
+
for (const name of sheetNames) {
|
|
246
|
+
say(`building ${nameOf(name)}`);
|
|
247
|
+
const s = resolveSheet(kernel, part.parts[name], p, d);
|
|
248
|
+
const desc = processById(s.process);
|
|
249
|
+
if (!desc) throw new Error(`sheet part "${nameOf(name)}": unknown process "${s.process}"`);
|
|
250
|
+
const facts = desc.facts(s, { deadline });
|
|
251
|
+
const exporter = await loadExporter(s.process);
|
|
252
|
+
const drawing = exporter.drawing(s, o, kernel, { label: nameOf(name), facts });
|
|
253
|
+
drawn.push({ name, label: nameOf(name), fileKey: exportName(name), group: groupOf.get(name),
|
|
254
|
+
drawing, facts, key: canonicalDrawingKey(drawing) });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Identical pieces become one file ×N (spec D.4): the hash nominates; the canonical
|
|
258
|
+
// drawing and the stock group confirm.
|
|
259
|
+
const pieces = dedupe(drawn, (x) => h(x.key), (a, b) => a.key === b.key && a.group === b.group)
|
|
260
|
+
.map(({ item, members }) => ({
|
|
261
|
+
...item, labels: members.map((m) => m.label), qty: members.length * o.sets,
|
|
262
|
+
hasMarks: item.drawing.layers.some((l) => l.id === "score" || l.id === "engrave"),
|
|
263
|
+
}));
|
|
264
|
+
|
|
265
|
+
// Names, in definition order. A name that was taken moves to name-2; the README's
|
|
266
|
+
// RENAMED FILES says which file it was and who held the name (spec D.10). A piece
|
|
267
|
+
// claims its bare name as well as its files (the `parts/<name>/` key, which no file
|
|
268
|
+
// path can equal): `-x<n>` is a QUANTITY, so a second, different piece with the
|
|
269
|
+
// same name must be name-2 — never name.svg beside name-x2.svg, which reads as one
|
|
270
|
+
// piece at two quantities.
|
|
271
|
+
const taken = new Set();
|
|
272
|
+
const owners = new Map();
|
|
273
|
+
const renamed = [];
|
|
274
|
+
const claim = (base, filesOf, labels) => {
|
|
275
|
+
const name = uniqueName(base, taken, filesOf);
|
|
276
|
+
const file = filesOf(name)[0];
|
|
277
|
+
if (name !== base) {
|
|
278
|
+
const holder = filesOf(base).map((f) => owners.get(f)).find(Boolean);
|
|
279
|
+
renamed.push(`${file}: ${labels.join(", ")}${holder ? ` (renamed: ${holder.file} is ${holder.labels.join(", ")})` : ""}`);
|
|
280
|
+
}
|
|
281
|
+
for (const f of filesOf(name)) owners.set(f, { file, labels });
|
|
282
|
+
return name;
|
|
283
|
+
};
|
|
284
|
+
for (const pc of pieces) {
|
|
285
|
+
const sx = pc.qty > 1 ? `-x${pc.qty}` : "";
|
|
286
|
+
const marks = service && pc.hasMarks;
|
|
287
|
+
const name = claim(kitName(pc.fileKey),
|
|
288
|
+
(n) => [`parts/${n}${sx}.${cutExt}`, ...(marks ? [`parts/${n}${sx}-marks.dxf`] : []), `parts/${n}/`], pc.labels);
|
|
289
|
+
pc.file = `parts/${name}${sx}.${cutExt}`;
|
|
290
|
+
pc.marksFile = marks ? `parts/${name}${sx}-marks.dxf` : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Lay out each stock group. An own laser needs every piece to fit (layoutGroup
|
|
294
|
+
// throws "stock too small"); for a cutting service the sheets are reference only, so
|
|
295
|
+
// an oversized piece is listed in the README instead (spec D.5).
|
|
296
|
+
const sheets = [];
|
|
297
|
+
const oversized = [];
|
|
298
|
+
for (const g of groups.values()) {
|
|
299
|
+
const inGroup = pieces.filter((pc) => pc.group === g.group);
|
|
300
|
+
const count = inGroup.reduce((n, pc) => n + pc.qty, 0);
|
|
301
|
+
say(`laying out ${g.material} ${fmtMm(g.thickness)} mm (${count} pieces)`);
|
|
302
|
+
const stock = stockFor.get(g.group);
|
|
303
|
+
const laid = layoutGroup({
|
|
304
|
+
group: g.group, label: g.label, stock, margin: o.margin, spacing: o.spacing, strict: !service,
|
|
305
|
+
pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.drawing, qty: pc.qty })),
|
|
306
|
+
});
|
|
307
|
+
if (laid.sheets.length > KIT_LIMITS.sheetsPerGroup) {
|
|
308
|
+
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`);
|
|
309
|
+
}
|
|
310
|
+
const dir = claim(kitName(`${g.material}-${fmtMm(g.thickness)}mm`), (n) => [`sheets/${n}/`], [g.label]);
|
|
311
|
+
laid.sheets.forEach((sh, i) => sheets.push({
|
|
312
|
+
file: `sheets/${dir}/sheet-${i + 1}-of-${laid.sheets.length}.${cutExt}`,
|
|
313
|
+
group: g, stock, placements: sh.placements, used: sh.used, index: i + 1, count: laid.sheets.length,
|
|
314
|
+
}));
|
|
315
|
+
for (const key of laid.oversized) {
|
|
316
|
+
const pc = inGroup.find((x) => x.file === key);
|
|
317
|
+
const [w, ht] = sizeOf(pc.drawing.bounds);
|
|
318
|
+
oversized.push(`${pc.labels.join(", ")}: ${w.toFixed(1)} × ${ht.toFixed(1)} mm does not fit a ${fmtMm(stock[0])} × ${fmtMm(stock[1])} mm sheet after its ${fmtMm(o.margin)} mm margin (${g.label}) — cut it from ${pc.file}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Printed parts, posed for export exactly as the STL/3MF exports pose them. Identical
|
|
323
|
+
// prints merge: the solid hash nominates; volume, bounding box, triangle count and
|
|
324
|
+
// print colour confirm (spec D.4).
|
|
325
|
+
const quality = msg.quality ?? "print";
|
|
326
|
+
const builtPrints = [];
|
|
327
|
+
for (const name of printNames) {
|
|
328
|
+
say(`building ${nameOf(name)}`);
|
|
329
|
+
const solid = posed(name, "export", onProgress);
|
|
330
|
+
const box = solid.boundingBox();
|
|
331
|
+
const display = part.parts[name]?.display;
|
|
332
|
+
const pp = {
|
|
333
|
+
name, label: nameOf(name), fileKey: exportName(name), hash: String(solid._hash ?? ""),
|
|
334
|
+
size: [0, 1, 2].map((i) => box.max[i] - box.min[i]), volume: solid.volume(),
|
|
335
|
+
color: printColor(display), material: typeof display?.material === "string" ? display.material : "",
|
|
336
|
+
};
|
|
337
|
+
if (o.printFormat === "stl") {
|
|
338
|
+
pp.bytes = new Uint8Array(await solid.toSTL({ quality }));
|
|
339
|
+
pp.triangles = new DataView(pp.bytes.buffer, pp.bytes.byteOffset, pp.bytes.byteLength).getUint32(80, true);
|
|
340
|
+
} else {
|
|
341
|
+
const { positions, indices } = solid.toIndexedMesh({ quality });
|
|
342
|
+
pp.mesh = { positions, indices };
|
|
343
|
+
pp.triangles = indices.length / 3;
|
|
344
|
+
}
|
|
345
|
+
builtPrints.push(pp);
|
|
346
|
+
}
|
|
347
|
+
const prints = dedupe(builtPrints, (x) => x.hash, (a, b) => a.triangles === b.triangles && a.color === b.color
|
|
348
|
+
&& near(a.volume, b.volume) && a.size.every((x, i) => near(x, b.size[i])))
|
|
349
|
+
.map(({ item, members }) => ({ ...item, labels: members.map((m) => m.label), qty: members.length * o.sets }));
|
|
350
|
+
for (const pp of prints) {
|
|
351
|
+
const sx = pp.qty > 1 ? `-x${pp.qty}` : "";
|
|
352
|
+
const filesOf = (n) => [`print/${n}${sx}.${o.printFormat}`, `print/${n}/`];
|
|
353
|
+
pp.file = filesOf(claim(kitName(pp.fileKey), filesOf, pp.labels))[0];
|
|
354
|
+
if (!pp.bytes) pp.bytes = new Uint8Array(meshTo3MF([{ name: pp.fileKey, ...pp.mesh, color: pp.color }]));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The files (spec D.8): own laser — an SVG per sheet and per piece; service — a
|
|
358
|
+
// cut-only DXF per piece, a -marks.dxf where it has marks, and reference DXF sheets.
|
|
359
|
+
say("writing cut files");
|
|
360
|
+
const title = cleanLabel(part.meta?.title ?? fileBase);
|
|
361
|
+
const files = [];
|
|
362
|
+
const put = (file, text) => files.push({ file, data: strToU8(text) });
|
|
363
|
+
for (const sh of sheets) {
|
|
364
|
+
const doc = { title: `${title} — ${sh.group.label} — sheet ${sh.index} of ${sh.count}`, size: sh.stock, placements: sh.placements };
|
|
365
|
+
put(sh.file, service ? renderDxf(doc, { include: ALL_LAYERS }) : renderSvg(doc));
|
|
366
|
+
}
|
|
367
|
+
for (const pc of pieces) {
|
|
368
|
+
const doc = { title: pc.labels.join(", "), size: sizeOf(pc.drawing.bounds),
|
|
369
|
+
placements: [{ drawing: pc.drawing, at: [0, 0], rotated: false }] };
|
|
370
|
+
if (!service) { put(pc.file, renderSvg(doc)); continue; }
|
|
371
|
+
put(pc.file, renderDxf(doc, { include: ["cut"] }));
|
|
372
|
+
if (pc.marksFile) put(pc.marksFile, renderDxf(doc, { include: ["score", "engrave"] }));
|
|
373
|
+
}
|
|
374
|
+
for (const pp of prints) files.push({ file: pp.file, data: pp.bytes });
|
|
375
|
+
|
|
376
|
+
const controls = collectControls(part.parameters);
|
|
377
|
+
const readme = renderReadme({
|
|
378
|
+
title, destination: o.destination, cutFormat: o.cutFormat, kerf: o.kerf,
|
|
379
|
+
stockNotes: [...groups.values()].map((g) => ({ material: g.material, thickness: g.thickness,
|
|
380
|
+
control: thicknessControlLabel(controls, part.parts[g.first], p, d) })),
|
|
381
|
+
scale: pieces.map((pc) => ({ label: pc.labels.join(", "), nominal: pc.drawing.nominal, drawn: sizeOf(pc.drawing.bounds) })),
|
|
382
|
+
sheets: sheets.map((sh) => ({ file: sh.file, material: sh.group.material, thickness: sh.group.thickness,
|
|
383
|
+
size: sh.stock, pieces: sh.placements.length, used: sh.used })),
|
|
384
|
+
pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, labels: pc.labels, material: groups.get(pc.group).material,
|
|
385
|
+
thickness: groups.get(pc.group).thickness, size: pc.drawing.nominal, quantity: pc.qty })),
|
|
386
|
+
printed: prints.map((pp) => ({ file: pp.file, labels: pp.labels, quantity: pp.qty })),
|
|
387
|
+
clearances: controls.filter(isClearance)
|
|
388
|
+
.map((c) => ({ label: c.label ?? c.key, value: p[c.key], unit: c.unit ?? "" })),
|
|
389
|
+
settings: Object.keys(p).map((key) => ({ key, value: p[key] })),
|
|
390
|
+
checks: pieces.flatMap((pc) => sheetWarnings(pc.labels.join(", "), pc.facts)),
|
|
391
|
+
oversized, renamed,
|
|
392
|
+
});
|
|
393
|
+
const csv = renderPartsCsv([
|
|
394
|
+
...pieces.map((pc) => ({ file: pc.file, kind: "sheet", labels: pc.labels, material: groups.get(pc.group).material,
|
|
395
|
+
thickness: groups.get(pc.group).thickness, width: pc.drawing.nominal[0], height: pc.drawing.nominal[1], quantity: pc.qty })),
|
|
396
|
+
...prints.map((pp) => ({ file: pp.file, kind: "print", labels: pp.labels, material: pp.material, thickness: null,
|
|
397
|
+
width: pp.size[0], height: pp.size[1], quantity: pp.qty })),
|
|
398
|
+
]);
|
|
399
|
+
|
|
400
|
+
// Every planned file must land under its own name: a path two files shared would
|
|
401
|
+
// silently overwrite one of them, so the count is asserted, and every name is held to
|
|
402
|
+
// the entry pattern once more on the way in.
|
|
403
|
+
const planned = [{ file: "README.txt", data: strToU8(readme) }, { file: "parts.csv", data: strToU8(csv) }, ...files];
|
|
404
|
+
const entries = {};
|
|
405
|
+
for (const { file, data } of planned) {
|
|
406
|
+
if (!KIT_ENTRY_RE.test(file) || file.includes("..")) throw new Error(`cut kit: bad entry name ${JSON.stringify(file)}`);
|
|
407
|
+
entries[file] = data;
|
|
408
|
+
}
|
|
409
|
+
const built = Object.keys(entries).length;
|
|
410
|
+
if (built !== planned.length) throw new Error(`cut kit: planned ${planned.length} files but built ${built}`);
|
|
411
|
+
say("zipping kit");
|
|
412
|
+
return { data: zipSync(entries, { level: 6 }), filename: `${fileBase}-kit.zip` };
|
|
413
|
+
}
|