partforge 0.93.0 → 0.94.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.
@@ -139,6 +139,9 @@ export default {
139
139
  grammar and preload timing as `fonts` above — but the source resolves to **JSON** in the
140
140
  `partforge-vector` format, never to raw `.svg`. That JSON is either **authored** by hand
141
141
  (millimetre coordinates, placed as drawn) or the **ingested** output of `partforge/ingest`.
142
+ A vector source may additionally be that JSON **already parsed** — the object itself,
143
+ rather than bytes or a URL pointing at it — which is the form to use when the artwork
144
+ lives beside the part and is meant to stay hand-editable.
142
145
  See "Vector geometry" below for the full contract.
143
146
 
144
147
  ---
@@ -1553,6 +1556,28 @@ import outside a Vite build, so `partforge lint`/`measure`/`render` can't resolv
1553
1556
  source must resolve to the `.vector.json`, never to a raw `.svg` — `k.vector2d` does no
1554
1557
  SVG parsing at all.
1555
1558
 
1559
+ **A source may also be the parsed file itself.** Alongside bytes, a URL and a thunk, a
1560
+ `vectors` entry accepts the **contents** of a `.vector.json` — the object a JSON import
1561
+ yields, or anything else that already holds it:
1562
+
1563
+ ```js
1564
+ import plate from "./assets/plate.vector.json" with { type: "json" };
1565
+ export default { vectors: { plate }, /* … */ };
1566
+ ```
1567
+
1568
+ The `with { type: "json" }` attribute is required — Node refuses a JSON import without it.
1569
+ Reach for this form when the artwork is **hand-authored and meant to stay editable**: the
1570
+ numbers sit in a file a reader can open and change, next to the part that uses them, with
1571
+ nothing to fetch in order to see them. Reach for `new URL(…)` instead when the file is
1572
+ **ingested output** — generated, large, and not read by hand. `src/parts/emblem.js`
1573
+ declares one of each, side by side, for exactly this contrast.
1574
+
1575
+ Two consequences worth knowing. `partforge/lint`'s document-aware rules can read a parsed
1576
+ source on the very first lint, before any build has run, because there is nothing to
1577
+ resolve — with a URL they stay silent until the bytes arrive. And the object is validated
1578
+ on every resolve, so a malformed one fails with the same message its fetched twin would;
1579
+ it is read and never written, so `build` stays pure.
1580
+
1556
1581
  **Sizing is against the tight geometric bounding box, not a `viewBox`.** Icon sets pad
1557
1582
  their `viewBox` inconsistently, so sizing relative to `viewBox` makes two icons declared at
1558
1583
  the same nominal size look different on the plate. `width`/`height`/`fit` instead measure
@@ -1583,7 +1608,9 @@ a `"role": "subtract"` shape in the JSON, or `.cut()` it in `build`), and
1583
1608
 
1584
1609
  **What this is not.** `k.shape2d` does **not** accept the JSON dialect — it takes the
1585
1610
  internal contour form the polygon helpers and `pathProfile` produce — and there is no
1586
- inline document form in `build`. The two vocabularies stay separated by the file boundary,
1611
+ inline document form in `build`. A parsed source (above) does not change that: it is a
1612
+ `vectors` **declaration**, resolved and validated before `build` runs, not a document
1613
+ `build` may assemble or hand to the kernel. The two vocabularies stay separated by the file boundary,
1587
1614
  which is what lets `docs/VECTOR-FORMAT.md` be the only place they meet. Inline authoring
1588
1615
  stays `pathProfile` (see § "Geometry: the kernel / `Solid` API" above, where `pathProfile` is
1589
1616
  introduced, for which to reach for).
@@ -567,9 +567,18 @@ one:
567
567
  - **Existing artwork** — an `.svg` someone else made — goes through
568
568
  `partforge/ingest` once and is then referenced like any other document.
569
569
 
570
+ Wherever it comes from, the file reaches a part through its `vectors` map — as a
571
+ URL, as bytes, or as the parsed contents themselves (`import doc from
572
+ "./x.vector.json" with { type: "json" }`). The last form is the one to prefer for
573
+ authored artwork, because it keeps the numbers in a file a reader can open and
574
+ edit rather than behind a fetch. See "Vector geometry" in
575
+ `docs/AUTHORING-PARTS.md` for the declaration rules.
576
+
570
577
  `k.shape2d` does **not** accept this JSON dialect, and there is no inline
571
- document form in `build`. The two vocabularies stay separated by the file
572
- boundary; that separation is what lets this document be the only place they meet.
578
+ document form in `build` a parsed source is a declaration resolved before the
579
+ build, not something `build` assembles. The two vocabularies stay separated by
580
+ the file boundary; that separation is what lets this document be the only place
581
+ they meet.
573
582
 
574
583
  ## 6. Converting an SVG to this format by hand
575
584
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.93.0",
3
+ "version": "0.94.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,6 +11,15 @@
11
11
  // thunk, is content-stable for a session — resolve it once). DOM-free and
12
12
  // node:-free so it stays safe in the geometry worker's import closure.
13
13
 
14
+ // The `{ default: … }` module namespace a dynamic `import()` yields, unwrapped to
15
+ // the value itself; every other value passes through untouched. Exported because
16
+ // vectors.js applies the same rule OUTSIDE this resolver — its synchronous lint
17
+ // path reads a declared source directly, without resolving it — and the rule for
18
+ // what a module wrapper looks like must have exactly one definition.
19
+ export function unwrapModule(v) {
20
+ return v && typeof v === "object" && "default" in v && !toBuffer(v) && !(v instanceof URL) ? v.default : v;
21
+ }
22
+
14
23
  export function toBuffer(v) {
15
24
  if (v instanceof ArrayBuffer) return v;
16
25
  // A view may not span its whole backing buffer — slice to its exact range (Node
@@ -33,13 +42,21 @@ function describeSource(v) {
33
42
  // async); `errorMessage` is thrown when `source` doesn't match the grammar.
34
43
  // Results are cached on the caller-supplied `cache` Map, keyed by source
35
44
  // identity, so a repeated declaration resolves (and fetches) only once.
36
- export function makeAssetResolver(cache, finish, errorMessage) {
45
+ //
46
+ // `adopt(value, source)` is an optional last chance to claim a source that is
47
+ // neither bytes nor fetchable. vectors.js uses it for a source that is ALREADY
48
+ // the parsed contents of its file, where the source IS the asset rather than a
49
+ // way to reach its bytes. It is consulted only for values the grammar is about
50
+ // to refuse, so it can never shadow the bytes/URL/thunk forms; returning
51
+ // `undefined` declines and restores the refusal. Whatever it returns becomes
52
+ // resolveOne's result directly — `finish` takes bytes, and is not called.
53
+ export function makeAssetResolver(cache, finish, errorMessage, adopt = null) {
37
54
  return function resolveOne(source) {
38
55
  if (cache.has(source)) return cache.get(source);
39
56
  const p = (async () => {
40
57
  let v = source;
41
58
  if (typeof v === "function") v = await v();
42
- if (v && typeof v === "object" && "default" in v && !toBuffer(v) && !(v instanceof URL)) v = v.default; // dynamic-import module
59
+ v = unwrapModule(v); // dynamic-import module
43
60
  let bytes = toBuffer(v);
44
61
  if (!bytes) {
45
62
  if (v instanceof URL || typeof v === "string") {
@@ -53,7 +70,11 @@ export function makeAssetResolver(cache, finish, errorMessage) {
53
70
  }
54
71
  bytes = await res.arrayBuffer();
55
72
  }
56
- else throw new Error(errorMessage);
73
+ else {
74
+ const claimed = adopt ? adopt(v, source) : undefined;
75
+ if (claimed !== undefined) return claimed;
76
+ throw new Error(errorMessage);
77
+ }
57
78
  }
58
79
  return finish(bytes, v, source);
59
80
  })();
@@ -12,7 +12,7 @@
12
12
  // argument kernel-front.js:117-121 records for text2d.
13
13
  //
14
14
  // DOM-free and node:-free.
15
- import { makeAssetResolver, resolveDecl } from "./asset-resolve.js";
15
+ import { makeAssetResolver, resolveDecl, unwrapModule } from "./asset-resolve.js";
16
16
  import { toInternalDocument } from "./geometry/vector-format.js";
17
17
 
18
18
  const cache = new Map(); // source → Promise<Uint8Array> (raw bytes)
@@ -26,9 +26,32 @@ const cache = new Map(); // source → Promise<Uint8Array> (raw bytes)
26
26
  // under the next name that declares it, with that name in the message.
27
27
  const parsed = new Map();
28
28
 
29
- function parseDocument(bytes, label) {
29
+ // A source that IS the parsed contents of a partforge-vector file, rather than a
30
+ // way to reach its bytes — the in-tree form, `import doc from "./x.vector.json"`.
31
+ // Returns that object, or null for every other source form.
32
+ //
33
+ // `unwrapModule` first, so a dynamic `import("./x.vector.json")` namespace reads
34
+ // the same as the static default import, matching the rule the resolver applies
35
+ // to bytes and URLs.
36
+ //
37
+ // Deliberately STRUCTURAL, not a format check: anything object-shaped is claimed
38
+ // here and judged afterwards by toInternalDocument, so an object that is not
39
+ // artwork draws the validator's specific complaint (`has format "svg"`) rather
40
+ // than the source grammar's generic one. Arrays are not claimed — an array is
41
+ // never a file, and for it the grammar error names the real mistake.
42
+ function asParsedFile(source) {
43
+ const v = unwrapModule(source);
44
+ if (!v || typeof v !== "object" || Array.isArray(v)) return null;
45
+ if (v instanceof ArrayBuffer || ArrayBuffer.isView(v) || v instanceof URL) return null;
46
+ return v;
47
+ }
48
+
49
+ // `payload` is what resolveOne produced: the resolved bytes, or — for a source
50
+ // `asParsedFile` claimed — the file's contents themselves, already parsed.
51
+ function parseDocument(payload, label) {
52
+ if (!(payload instanceof ArrayBuffer)) return toInternalDocument(payload, label);
30
53
  let text;
31
- try { text = new TextDecoder().decode(bytes); }
54
+ try { text = new TextDecoder().decode(payload); }
32
55
  catch { throw new Error(`vector2d: "${label}" could not be decoded as UTF-8 text`); }
33
56
  let doc;
34
57
  try { doc = JSON.parse(text); }
@@ -55,7 +78,9 @@ const rawBySource = new Map();
55
78
  const resolveOne = makeAssetResolver(
56
79
  cache,
57
80
  (bytes, _value, source) => { bytesBySource.set(source, bytes); return bytes; },
58
- "resolveVectors: a vector source must be bytes, a URL, or a thunk returning one",
81
+ "resolveVectors: a vector source must be bytes, a URL, a thunk returning one, "
82
+ + "or the already-parsed contents of a partforge-vector file",
83
+ (value) => asParsedFile(value) ?? undefined,
59
84
  );
60
85
 
61
86
  export async function resolveVectors(vectorsDecl) {
@@ -73,9 +98,9 @@ export async function resolveVectors(vectorsDecl) {
73
98
  }
74
99
  const raw = await resolveDecl(vectorsDecl, resolveOne);
75
100
  const out = new Map();
76
- for (const [name, bytes] of raw) {
77
- let doc = parsed.get(bytes);
78
- if (!doc) { doc = parseDocument(bytes, name); parsed.set(bytes, doc); }
101
+ for (const [name, payload] of raw) {
102
+ let doc = parsed.get(payload);
103
+ if (!doc) { doc = parseDocument(payload, name); parsed.set(payload, doc); }
79
104
  out.set(name, doc);
80
105
  }
81
106
  return out;
@@ -99,8 +124,13 @@ export async function resolveVectorDocs(vectorsDecl) {
99
124
  const out = new Map();
100
125
  await Promise.all(Object.entries(decl).map(async ([name, source]) => {
101
126
  try {
102
- const bytes = await resolveOne(source);
103
- const doc = JSON.parse(new TextDecoder().decode(bytes));
127
+ const payload = await resolveOne(source);
128
+ // An adopted source resolves to the raw JSON itself — there is nothing to
129
+ // decode, and running it through TextDecoder would map a perfectly good
130
+ // file to null.
131
+ const doc = payload instanceof ArrayBuffer
132
+ ? JSON.parse(new TextDecoder().decode(payload))
133
+ : payload;
104
134
  out.set(name, doc && typeof doc === "object" ? doc : null);
105
135
  } catch {
106
136
  out.set(name, null);
@@ -139,6 +169,11 @@ export function cachedVectorDocs(vectorsDecl) {
139
169
  for (const entry of entries) {
140
170
  try {
141
171
  const [name, source] = entry;
172
+ // An already-parsed source has nothing to resolve, so unlike bytes and URLs
173
+ // it is readable on the very first lint — before any build has run. That is
174
+ // the state a hosted editor spends most of its time in.
175
+ const inline = asParsedFile(source);
176
+ if (inline) { out.set(name, inline); continue; }
142
177
  if (!bytesBySource.has(source)) continue; // not resolved yet — stay silent
143
178
  if (!rawBySource.has(source)) {
144
179
  let doc = null;
@@ -3,20 +3,32 @@
3
3
  // millimetre drawing (`plate`, `units: "mm"`, placed exactly as drawn). The two
4
4
  // vectors share one build, composed together with an ordinary boolean.
5
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.
6
+ // The two entries also demonstrate the two SOURCE forms, deliberately:
7
+ //
8
+ // `emblem` is `new URL(..., import.meta.url)` the form import-demo.js uses
9
+ // for its STL. Vite turns it into a bundled asset URL; in Node it is a file:
10
+ // URL src/testing/assets.js reads off disk. Right for ingested output, which is
11
+ // generated, large, and not meant to be read by hand.
12
+ //
13
+ // `plate` is the file's parsed CONTENTS, imported directly. Right for artwork
14
+ // that is hand-authored and meant to STAY hand-editable: the numbers live in a
15
+ // .json a reader can open, and nothing has to fetch anything to see them —
16
+ // which is also what lets lint read the file before the first build has run.
17
+ // The `with { type: "json" }` attribute is required: Node refuses a JSON import
18
+ // without it, and a bare `() => import("./assets/plate.vector.json")` would
19
+ // work under Vite and fail in the CLI.
10
20
  //
11
21
  // The source artwork lives beside it as emblem.svg, and the .json is regenerated
12
22
  // with `node scripts/ingest-svg.mjs src/parts/assets/emblem.svg`. plate.vector.json
13
23
  // is hand-authored — no ingest step, no source SVG — and is kept legible enough
14
24
  // to serve as documentation's worked example of a multi-shape, role-composed file.
25
+ import plate from "./assets/plate.vector.json" with { type: "json" };
26
+
15
27
  export default {
16
28
  meta: { title: "Emblem", units: "mm", background: 0x15181d },
17
29
  vectors: {
18
30
  emblem: new URL("./assets/emblem.vector.json", import.meta.url),
19
- plate: new URL("./assets/plate.vector.json", import.meta.url),
31
+ plate,
20
32
  },
21
33
  parameters: [
22
34
  {
package/types/part.d.ts CHANGED
@@ -257,6 +257,57 @@ export type FontSource =
257
257
 
258
258
  type FontSourceValue = string | ArrayBuffer | ArrayBufferView | { default: string };
259
259
 
260
+ // --- imports and vectors ------------------------------------------------------
261
+
262
+ /**
263
+ * One entry of a part's `imports` map: the STEP/STL/3MF file a `k.import()` call
264
+ * names. Same source grammar and preload timing as {@link FontSource}.
265
+ */
266
+ export type ImportSource = FontSource;
267
+
268
+ /**
269
+ * One entry of a part's `vectors` map: a `partforge-vector` file for `k.vector2d()`
270
+ * to place — never a raw `.svg`, which nothing in the geometry worker can read.
271
+ *
272
+ * Beyond the bytes/URL/thunk forms every asset source accepts, a vector source may
273
+ * be the file's ALREADY-PARSED contents: the object a `.vector.json` yields when
274
+ * something has imported or fetched it. That is the form to reach for when the
275
+ * artwork lives in the part's own tree and is meant to stay readable and editable,
276
+ * rather than sitting behind an opaque asset token.
277
+ *
278
+ * The object is read, never written, and is validated on every resolve — so a
279
+ * malformed one fails with the same message its on-disk twin would produce.
280
+ */
281
+ export type VectorSource =
282
+ | string
283
+ | ArrayBuffer
284
+ | ArrayBufferView
285
+ | VectorDocument
286
+ | (() => VectorSourceValue | Promise<VectorSourceValue>);
287
+
288
+ type VectorSourceValue =
289
+ | string
290
+ | ArrayBuffer
291
+ | ArrayBufferView
292
+ | VectorDocument
293
+ | { default: string | VectorDocument };
294
+
295
+ /**
296
+ * The parsed contents of a `partforge-vector` file. `docs/VECTOR-FORMAT.md` is the
297
+ * normative spec; this type is deliberately shallow — it pins the envelope every
298
+ * reader depends on and leaves contour shapes to the runtime validator, which
299
+ * reports far better errors than a structural type mismatch can.
300
+ */
301
+ export interface VectorDocument {
302
+ format: "partforge-vector";
303
+ version: number;
304
+ units: "mm" | "artwork";
305
+ shapes: Record<string, unknown>;
306
+ source?: unknown;
307
+ bbox?: unknown;
308
+ note?: string;
309
+ }
310
+
260
311
  // --- derive -----------------------------------------------------------------
261
312
 
262
313
  /**
@@ -316,6 +367,11 @@ export interface SubPartDefinition<P = ResolvedParams, D = Derived> {
316
367
 
317
368
  export interface ViewDefinition {
318
369
  label: string;
370
+ /**
371
+ * Open this view first. With none flagged, the first key wins — see
372
+ * `default-view.js`, which also falls back when the flagged view is empty.
373
+ */
374
+ default?: boolean;
319
375
  /**
320
376
  * Named animations belonging to this view — keyframe data driving this view's
321
377
  * params and sub-part opacity over time. See `AnimationSpec` below; the
@@ -548,6 +604,10 @@ export interface PartDefinition<P = ResolvedParams, D = Derived> {
548
604
  defaults: Defaults;
549
605
  /** Outline fonts a part's `k.text2d()` calls need, as `{ name: source }`. */
550
606
  fonts?: Record<string, FontSource>;
607
+ /** STEP/STL/3MF files a part's `k.import()` calls need, as `{ name: source }`. */
608
+ imports?: Record<string, ImportSource>;
609
+ /** Vector artwork a part's `k.vector2d()` calls place, as `{ name: source }`. */
610
+ vectors?: Record<string, VectorSource>;
551
611
  /** Dependent values computed once per build. */
552
612
  derive?: DeriveSpec<P, D>;
553
613
  /** Named sub-parts; each builds exactly one solid. */
@@ -556,4 +616,9 @@ export interface PartDefinition<P = ResolvedParams, D = Derived> {
556
616
  views: Record<string, ViewDefinition>;
557
617
  /** Self-verification, co-located with the schema. */
558
618
  verify?: VerifyBlock<P, D>;
619
+ /**
620
+ * Named measurements reported by `measure`/`inspect` — never rendered, never
621
+ * exported. Each entry returns either a solid to measure or plain JSON.
622
+ */
623
+ probes?: Record<string, (k: GeometryKernel, p: P, d: D) => unknown>;
559
624
  }