partforge 0.92.0 → 0.93.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/bin/cli.js +6 -3
- package/docs/AUTHORING-PARTS.md +184 -1
- package/docs/ERROR-PATTERNS.md +97 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/docs/VECTOR-FORMAT.md +737 -0
- package/package.json +9 -1
- package/src/app-emblem.js +15 -0
- package/src/emblem-worker.js +3 -0
- package/src/framework/asset-resolve.js +5 -4
- package/src/framework/geometry/arc-fit.js +146 -0
- package/src/framework/geometry/contour-offset.js +5 -0
- package/src/framework/geometry/curve-fill.js +57 -7
- package/src/framework/geometry/kernel-front.js +46 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/probe.js +1 -1
- package/src/framework/geometry/stroke-outline.js +119 -0
- package/src/framework/geometry/vector-format.js +334 -0
- package/src/framework/geometry/vector2d.js +96 -0
- package/src/framework/ingest/svg-ingest.js +212 -0
- package/src/framework/jobs.js +11 -0
- package/src/framework/lint/index.js +28 -3
- package/src/framework/lint/rules-vector.js +112 -0
- package/src/framework/vectors.js +170 -0
- package/src/framework/worker.js +46 -1
- package/src/ingest.js +8 -0
- package/src/parts/assets/emblem.svg +10 -0
- package/src/parts/assets/emblem.vector.json +110 -0
- package/src/parts/assets/plate.vector.json +27 -0
- package/src/parts/emblem.js +102 -0
- package/src/testing/manifold.js +3 -1
- package/src/testing/occt.js +3 -1
- package/types/ingest.d.ts +118 -0
- package/types/kernel.d.ts +28 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The k.vector2d reference part — for BOTH paths the format supports: ingested
|
|
2
|
+
// artwork (`emblem`, `units: "artwork"`, sized per call site) and an authored
|
|
3
|
+
// millimetre drawing (`plate`, `units: "mm"`, placed exactly as drawn). The two
|
|
4
|
+
// vectors share one build, composed together with an ordinary boolean.
|
|
5
|
+
//
|
|
6
|
+
// `vectors` is declared with `new URL(..., import.meta.url)`, the same form
|
|
7
|
+
// import-demo.js uses for its STL: Vite turns it into a bundled asset URL, and
|
|
8
|
+
// in Node it is a file: URL that src/testing/assets.js reads off disk. A bare
|
|
9
|
+
// `() => import("./assets/emblem.vector.json")` would work in Vite and fail in the CLI.
|
|
10
|
+
//
|
|
11
|
+
// The source artwork lives beside it as emblem.svg, and the .json is regenerated
|
|
12
|
+
// with `node scripts/ingest-svg.mjs src/parts/assets/emblem.svg`. plate.vector.json
|
|
13
|
+
// is hand-authored — no ingest step, no source SVG — and is kept legible enough
|
|
14
|
+
// to serve as documentation's worked example of a multi-shape, role-composed file.
|
|
15
|
+
export default {
|
|
16
|
+
meta: { title: "Emblem", units: "mm", background: 0x15181d },
|
|
17
|
+
vectors: {
|
|
18
|
+
emblem: new URL("./assets/emblem.vector.json", import.meta.url),
|
|
19
|
+
plate: new URL("./assets/plate.vector.json", import.meta.url),
|
|
20
|
+
},
|
|
21
|
+
parameters: [
|
|
22
|
+
{
|
|
23
|
+
id: "plate",
|
|
24
|
+
title: "Plate",
|
|
25
|
+
description: "The backing plate the artwork is embossed on. Its outline — including the bolt "
|
|
26
|
+
+ "holes and keyway — is drawn in plate.vector.json, not parameterized.",
|
|
27
|
+
advanced: [
|
|
28
|
+
{ key: "plate_t", label: "Thickness", unit: "mm", min: 1, max: 10, step: 0.5, description: "Plate thickness." },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "art",
|
|
33
|
+
title: "Artwork",
|
|
34
|
+
description: "The embossed vector art. `emblem.svg` carries a filled circle and a stroked bar, so both of ingest's geometry paths are exercised.",
|
|
35
|
+
advanced: [
|
|
36
|
+
{ key: "emblem_w", label: "Emblem width", unit: "mm", min: 8, max: 70, step: 1,
|
|
37
|
+
description: "Width of the artwork's **tight bounding box** in mm — not its `viewBox`. Stroke thickness scales with it." },
|
|
38
|
+
{ key: "emboss", label: "Emboss height", unit: "mm", min: 0.4, max: 4, step: 0.2,
|
|
39
|
+
description: "How far the artwork stands proud of the plate." },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
defaults: { plate_t: 3, emblem_w: 30, emboss: 1 },
|
|
44
|
+
parts: {
|
|
45
|
+
plate: {
|
|
46
|
+
label: "Plate",
|
|
47
|
+
views: ["plate"],
|
|
48
|
+
export: { name: "emblem-plate" },
|
|
49
|
+
// No shape named and no size: the file's own roles compose it (body minus
|
|
50
|
+
// holes minus keyway), and units "mm" places it exactly as drawn. A size
|
|
51
|
+
// HERE would be safe — the composed call measures the whole document at
|
|
52
|
+
// once — but it would also be pointless. What is not safe is fetching
|
|
53
|
+
// body/holes/keyway separately and sizing each call: each would scale
|
|
54
|
+
// against ITS OWN bounds, and the drawing's shared frame would be gone
|
|
55
|
+
// (ERROR-PATTERNS.md#vector-mm-shapes-misscaled). Millimetres place as
|
|
56
|
+
// authored; that is the whole point of the units mode.
|
|
57
|
+
// The keyway sits clear of the artwork at the default `emblem_w` (30) —
|
|
58
|
+
// confirmed by measurement, not eyeballed: their 2-D footprints have zero
|
|
59
|
+
// intersection. That clearance is deliberate, not incidental: a much
|
|
60
|
+
// larger `emblem_w` would grow the emboss until it overlaps the keyway's
|
|
61
|
+
// footprint again, and the union below would then cap it from above —
|
|
62
|
+
// a through-slot the drawing marks `role: "subtract"` quietly becoming a
|
|
63
|
+
// blind pocket. This is exactly the failure mode the `holes` gate below
|
|
64
|
+
// exists to catch, which is why that gate is only asserted at defaults.
|
|
65
|
+
build: (k, p) => k
|
|
66
|
+
.vector2d("plate")
|
|
67
|
+
.extrude({ h: p.plate_t })
|
|
68
|
+
.union(k.vector2d("emblem", { width: p.emblem_w }).extrude({ h: p.emboss }).translate([0, 0, p.plate_t])),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
views: { plate: { label: "Plate" } },
|
|
72
|
+
verify: {
|
|
73
|
+
expect: {
|
|
74
|
+
plate: {
|
|
75
|
+
// Tight in all three axes, by the same +1 mm convention as
|
|
76
|
+
// import-demo.js's gates: verify evaluates the `defaults` case only
|
|
77
|
+
// (this part declares no presets), so bbox is checked against one
|
|
78
|
+
// deterministic value (40 x 24 x 4) forever, not a swept range —
|
|
79
|
+
// z = plate_t + emboss = 3 + 1 = 4 at these defaults specifically,
|
|
80
|
+
// not the schema's wider plate_t/emboss envelope. Revisit this bound
|
|
81
|
+
// if a preset is ever added that sweeps plate_t or emboss.
|
|
82
|
+
bbox: "<=[41,25,5]",
|
|
83
|
+
// Measured at defaults: 3013 mm^3 (`npx partforge measure`). The bare
|
|
84
|
+
// plate (no emboss union) is 2748 mm^3 — comfortably under this bound —
|
|
85
|
+
// so a silently-vanished emboss union fails here. Complemented by the
|
|
86
|
+
// `holes` gate below for the opposite failure (a cut that stops working
|
|
87
|
+
// raises volume, not lowers it, so this bound alone can't catch that).
|
|
88
|
+
volume: ">=2900",
|
|
89
|
+
watertight: true,
|
|
90
|
+
// Three through-holes: the two bolt circles, plus the keyway triangle —
|
|
91
|
+
// all cut clean through the extruded plate and, at this part's default
|
|
92
|
+
// `emblem_w`, none of them sit under the artwork's emboss (see the
|
|
93
|
+
// build comment above for why that placement matters). Confirmed with
|
|
94
|
+
// `npx partforge measure`, and falsified by temporarily flipping
|
|
95
|
+
// "holes"/"keyway" to role "add" in plate.vector.json (which drops
|
|
96
|
+
// this to 0, proving the gate can fail).
|
|
97
|
+
holes: 3,
|
|
98
|
+
},
|
|
99
|
+
_view: { overlaps: 0 },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
package/src/testing/manifold.js
CHANGED
|
@@ -6,10 +6,11 @@ import { createManifoldKernel } from "../framework/geometry/manifold-backend.js"
|
|
|
6
6
|
import { resolveFonts } from "../framework/fonts.js";
|
|
7
7
|
import { normalizeOpentype, parseFont } from "../framework/geometry/opentype-interop.js";
|
|
8
8
|
import { ensureImports } from "../framework/imports.js";
|
|
9
|
+
import { ensureVectors } from "../framework/vectors.js";
|
|
9
10
|
import { nodeAssetSources } from "./assets.js";
|
|
10
11
|
import { tessellateStepAssets } from "./step-mesh.js";
|
|
11
12
|
|
|
12
|
-
export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes } = {}) {
|
|
13
|
+
export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes, vectors } = {}) {
|
|
13
14
|
const wasm = await Module();
|
|
14
15
|
wasm.setup();
|
|
15
16
|
const kernel = createManifoldKernel(wasm, { quality });
|
|
@@ -24,5 +25,6 @@ export async function bootManifoldKernel({ quality = "preview", fonts, imports,
|
|
|
24
25
|
const meshes = importMeshes ?? (stepEntries.length ? await tessellateStepAssets(stepEntries) : null);
|
|
25
26
|
await ensureImports(kernel, decl, meshes);
|
|
26
27
|
}
|
|
28
|
+
if (vectors) await ensureVectors(kernel, nodeAssetSources(vectors));
|
|
27
29
|
return kernel;
|
|
28
30
|
}
|
package/src/testing/occt.js
CHANGED
|
@@ -8,9 +8,10 @@ import { createOcctKernel } from "../framework/geometry/occt-backend.js";
|
|
|
8
8
|
import { resolveFonts } from "../framework/fonts.js";
|
|
9
9
|
import { normalizeOpentype, parseFont } from "../framework/geometry/opentype-interop.js";
|
|
10
10
|
import { ensureImports } from "../framework/imports.js";
|
|
11
|
+
import { ensureVectors } from "../framework/vectors.js";
|
|
11
12
|
import { nodeAssetSources } from "./assets.js";
|
|
12
13
|
|
|
13
|
-
export async function bootOcctKernel({ fonts, imports, importMeshes } = {}) {
|
|
14
|
+
export async function bootOcctKernel({ fonts, imports, importMeshes, vectors } = {}) {
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
globalThis.require = globalThis.require ?? require;
|
|
16
17
|
globalThis.__dirname = globalThis.__dirname ?? path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -22,5 +23,6 @@ export async function bootOcctKernel({ fonts, imports, importMeshes } = {}) {
|
|
|
22
23
|
if (fonts) { const opentype = normalizeOpentype(await import("opentype.js"));
|
|
23
24
|
for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, parseFont(opentype, buf, name)); }
|
|
24
25
|
if (imports) await ensureImports(kernel, nodeAssetSources(imports), importMeshes ?? null);
|
|
26
|
+
if (vectors) await ensureVectors(kernel, nodeAssetSources(vectors));
|
|
25
27
|
return kernel;
|
|
26
28
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// partforge/ingest — SVG -> the partforge-vector JSON format (docs/VECTOR-FORMAT.md).
|
|
2
|
+
//
|
|
3
|
+
// DOM-dependent and main-thread-only: this entry is deliberately NOT reachable
|
|
4
|
+
// from the geometry worker, and is never re-exported from the main entry or
|
|
5
|
+
// from `partforge/geometry`. A host runs it once per artwork, in a browser,
|
|
6
|
+
// and stores the result — the same division of labour as `fontCatalog`.
|
|
7
|
+
// partforge does not write files.
|
|
8
|
+
//
|
|
9
|
+
// These declarations describe the FORMAT, not just what `ingestSvg` happens to
|
|
10
|
+
// emit — the same documents are hand-authored, so `bbox` and `source` are
|
|
11
|
+
// optional here even though ingest always writes both.
|
|
12
|
+
|
|
13
|
+
/** Coordinate meaning. `"mm"` places as authored; `"artwork"` requires a size at every call site. */
|
|
14
|
+
export type VectorUnits = "mm" | "artwork";
|
|
15
|
+
|
|
16
|
+
/** Whether a shape adds material to the composed result or is cut from it. `"add"` is the default. */
|
|
17
|
+
export type VectorRole = "add" | "subtract";
|
|
18
|
+
|
|
19
|
+
/** The document's tight bounding box. A cache, not an authority — placement recomputes it. */
|
|
20
|
+
export interface VectorBbox {
|
|
21
|
+
minX: number;
|
|
22
|
+
minY: number;
|
|
23
|
+
maxX: number;
|
|
24
|
+
maxY: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A parsed `partforge-vector` JSON document — see docs/VECTOR-FORMAT.md. */
|
|
28
|
+
export interface VectorDocument {
|
|
29
|
+
format: "partforge-vector";
|
|
30
|
+
version: number;
|
|
31
|
+
units: VectorUnits;
|
|
32
|
+
/** Free text; ignored on load. Ingest writes the format's own one-paragraph summary. */
|
|
33
|
+
note?: string;
|
|
34
|
+
/** Provenance only — typically the original filename. Not validated or used at load/build time. */
|
|
35
|
+
source?: string | null;
|
|
36
|
+
/** Optional: an author need not compute analytic curve extrema, but a stale value is a named error. */
|
|
37
|
+
bbox?: VectorBbox;
|
|
38
|
+
/** Name -> shape. At least one shape, and at least one of them must have role `"add"`. */
|
|
39
|
+
shapes: Record<string, VectorShape>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A named shape: either a bare region list (role `"add"`) or `{ role, regions }`.
|
|
44
|
+
* Both forms exist because `"add"` is an honest default — a painted region adds
|
|
45
|
+
* material, which is what every file written before roles existed already meant.
|
|
46
|
+
*/
|
|
47
|
+
export type VectorShape = VectorRegion[] | { role?: VectorRole; regions: VectorRegion[] };
|
|
48
|
+
|
|
49
|
+
/** One filled region: an `outer` boundary with `holes` subtracted from it. */
|
|
50
|
+
export interface VectorRegion {
|
|
51
|
+
outer: VectorContour;
|
|
52
|
+
holes?: VectorContour[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One closed contour: the explicit `"path"` form, or one of the three primitives. */
|
|
56
|
+
export type VectorContour = VectorPath | VectorCircle | VectorRect | VectorPolygon;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Segments run head-to-tail from `start`, and the contour closes IMPLICITLY from
|
|
60
|
+
* the last segment's `to` back to `start`. At least one segment, and at least two
|
|
61
|
+
* if they are all `"line"` — a single straight edge and its closure are the same
|
|
62
|
+
* line, so they bound nothing, while a single `"arc"` or `"cubic"` bounds area
|
|
63
|
+
* against the closing chord.
|
|
64
|
+
*/
|
|
65
|
+
export interface VectorPath {
|
|
66
|
+
kind: "path";
|
|
67
|
+
start: [number, number];
|
|
68
|
+
segments: VectorSegment[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Expands to two 180-degree arcs, counter-clockwise. */
|
|
72
|
+
export interface VectorCircle {
|
|
73
|
+
kind: "circle";
|
|
74
|
+
center: [number, number];
|
|
75
|
+
r: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Axis-aligned, counter-clockwise. `radius` rounds all four corners; at most half the shorter side. */
|
|
79
|
+
export interface VectorRect {
|
|
80
|
+
kind: "rect";
|
|
81
|
+
center: [number, number];
|
|
82
|
+
width: number;
|
|
83
|
+
height: number;
|
|
84
|
+
radius?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** At least 3 points, joined by straight edges in the author's own order. */
|
|
88
|
+
export interface VectorPolygon {
|
|
89
|
+
kind: "polygon";
|
|
90
|
+
points: Array<[number, number]>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type VectorSegment =
|
|
94
|
+
| { kind: "line"; to: [number, number] }
|
|
95
|
+
| { kind: "arc"; to: [number, number]; through: [number, number] }
|
|
96
|
+
| { kind: "cubic"; to: [number, number]; c1: [number, number]; c2: [number, number] };
|
|
97
|
+
|
|
98
|
+
export interface IngestSvgOptions {
|
|
99
|
+
/**
|
|
100
|
+
* `"outline"` (default) turns strokes into filled geometry; `"ignore"` drops
|
|
101
|
+
* stroke geometry entirely and keeps only fills. There is no equivalent
|
|
102
|
+
* option on `k.vector2d` — once ingested, there is no stroke left to ignore.
|
|
103
|
+
*/
|
|
104
|
+
strokes?: "outline" | "ignore";
|
|
105
|
+
/** Provenance only — typically the original filename. Stored verbatim as `source`; not validated or used at load/build time. */
|
|
106
|
+
source?: string | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Convert an SVG document (as text) into the `partforge-vector` JSON format a
|
|
111
|
+
* part's `k.vector2d()` calls can place. DOM-required — call this in a browser,
|
|
112
|
+
* store the returned document (e.g. as `<name>.vector.json` beside the part),
|
|
113
|
+
* and reference it from the part's `vectors` field. The result is always one
|
|
114
|
+
* shape named `"artwork"` in `"artwork"` units, with `bbox` and `source` written.
|
|
115
|
+
* Throws if the SVG can't be parsed, or if it contains no painted geometry (every
|
|
116
|
+
* element is `fill="none"` with no stroke, hidden, or empty).
|
|
117
|
+
*/
|
|
118
|
+
export function ingestSvg(svgText: string, opts?: IngestSvgOptions): VectorDocument;
|
package/types/kernel.d.ts
CHANGED
|
@@ -455,6 +455,29 @@ export interface Text2dOptions {
|
|
|
455
455
|
kerning?: boolean;
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
/** Horizontal alignment of a `vector2d` placement. */
|
|
459
|
+
export type Vector2dAlign = "center" | "left" | "right";
|
|
460
|
+
/** Vertical alignment of a `vector2d` placement. */
|
|
461
|
+
export type Vector2dValign = "middle" | "top" | "bottom";
|
|
462
|
+
|
|
463
|
+
export interface Vector2dOptions {
|
|
464
|
+
/**
|
|
465
|
+
* Name of one shape in the file, returned whatever its `role`. Omit for the
|
|
466
|
+
* composed result: every `"add"` shape unioned, minus every `"subtract"` one.
|
|
467
|
+
*/
|
|
468
|
+
shape?: string;
|
|
469
|
+
/** Target width in mm. At most one of `width`/`height`/`fit`; required for `units: "artwork"`. */
|
|
470
|
+
width?: number;
|
|
471
|
+
/** Target height in mm. At most one of `width`/`height`/`fit`; required for `units: "artwork"`. */
|
|
472
|
+
height?: number;
|
|
473
|
+
/** Target size in mm for the larger extent. At most one of `width`/`height`/`fit`; required for `units: "artwork"`. */
|
|
474
|
+
fit?: number;
|
|
475
|
+
/** Defaults to `"center"` for `units: "artwork"`, and to no horizontal translate for `units: "mm"`. */
|
|
476
|
+
align?: Vector2dAlign;
|
|
477
|
+
/** Defaults to `"middle"` for `units: "artwork"`, and to no vertical translate for `units: "mm"`. */
|
|
478
|
+
valign?: Vector2dValign;
|
|
479
|
+
}
|
|
480
|
+
|
|
458
481
|
/** Anything `k.hull`/`k.hullChain` accepts as one input. */
|
|
459
482
|
export type HullInput = Shape2D | Contour;
|
|
460
483
|
|
|
@@ -501,6 +524,11 @@ export interface GeometryKernel {
|
|
|
501
524
|
shape2d(profile: ProfileInput): Shape2D;
|
|
502
525
|
/** Render outline-font text as a `Shape2D`. */
|
|
503
526
|
text2d(string: string, opts?: Text2dOptions): Shape2D;
|
|
527
|
+
/**
|
|
528
|
+
* Place a declared vector file as a `Shape2D`. `name` is a key in the part's
|
|
529
|
+
* `vectors` field (`partforge-vector` JSON, not raw `.svg`).
|
|
530
|
+
*/
|
|
531
|
+
vector2d(name: string, opts?: Vector2dOptions): Shape2D;
|
|
504
532
|
/** Convex hull of all inputs → a convex (faceted) `Shape2D`. */
|
|
505
533
|
hull(inputs: HullInput[]): Shape2D;
|
|
506
534
|
/** Swept hull over an ordered sequence (>= 2 inputs). */
|