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.
@@ -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
+ };
@@ -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
  }
@@ -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). */