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.
- package/docs/AUTHORING-PARTS.md +28 -1
- package/docs/VECTOR-FORMAT.md +11 -2
- package/package.json +1 -1
- package/src/framework/asset-resolve.js +24 -3
- package/src/framework/vectors.js +44 -9
- package/src/parts/emblem.js +17 -5
- package/types/part.d.ts +65 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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`.
|
|
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).
|
package/docs/VECTOR-FORMAT.md
CHANGED
|
@@ -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
|
|
572
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
})();
|
package/src/framework/vectors.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
77
|
-
let doc = parsed.get(
|
|
78
|
-
if (!doc) { doc = parseDocument(
|
|
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
|
|
103
|
-
|
|
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;
|
package/src/parts/emblem.js
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
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
|
}
|