partforge 0.96.0 → 0.98.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 +128 -9
- package/docs/AUTHORING-PARTS.md +371 -5
- package/docs/ERROR-PATTERNS.md +25 -1
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/docs/VECTOR-FORMAT.md +23 -17
- package/package.json +9 -1
- package/src/app-relief.js +16 -0
- package/src/framework/app.css +30 -0
- package/src/framework/backend-select.js +7 -2
- package/src/framework/font-source.js +18 -1
- package/src/framework/geometry/heightfield.js +129 -0
- package/src/framework/geometry/kernel.js +3 -0
- package/src/framework/geometry/manifold-backend.js +61 -0
- package/src/framework/geometry/occt-backend.js +148 -1
- package/src/framework/geometry/op-options.js +10 -0
- package/src/framework/geometry/png-decode.js +107 -0
- package/src/framework/geometry/solid-hash.js +96 -0
- package/src/framework/image-source.js +76 -0
- package/src/framework/images.js +66 -0
- package/src/framework/ingest/image-ingest.js +41 -0
- package/src/framework/ingest/node-dom.js +69 -0
- package/src/framework/ingest/registry.js +57 -0
- package/src/framework/ingest/sniff.js +96 -0
- package/src/framework/jobs.js +107 -6
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-images.js +109 -0
- package/src/framework/lint/rules-vector.js +68 -3
- package/src/framework/measure/measure-mode.js +2 -1
- package/src/framework/mount.js +14 -1
- package/src/framework/oracle/verify.js +6 -1
- package/src/framework/panel/image-picker.js +152 -0
- package/src/framework/panel/render.js +2 -0
- package/src/framework/panel/widget-specs.js +4 -0
- package/src/framework/panel/widgets/file-drop.js +321 -0
- package/src/framework/panel/widgets/font.js +51 -12
- package/src/framework/panel/widgets/image.js +178 -0
- package/src/framework/panel/widgets/index.js +11 -5
- package/src/framework/panel/widgets/vector.js +77 -0
- package/src/framework/param-deps.js +7 -2
- package/src/framework/vector-source.js +127 -0
- package/src/framework/vectors.js +9 -0
- package/src/ingest.js +1 -0
- package/src/parts/assets/relief-demo.png +0 -0
- package/src/parts/emblem.js +2 -1
- package/src/parts/relief.js +84 -0
- package/src/relief-worker.js +3 -0
- package/src/testing/manifold.js +7 -1
- package/src/testing/occt.js +4 -1
- package/types/index.d.ts +67 -0
- package/types/ingest.d.ts +13 -0
- package/types/kernel.d.ts +32 -0
- package/types/part.d.ts +21 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// The `type: "image"` control. Its VALUE is an image source — either a URL
|
|
2
|
+
// string (the same grammar `PartDefinition.images` already accepts) or raw PNG
|
|
3
|
+
// bytes (an ArrayBuffer/typed array): the partforge-cloud sandbox cannot fetch
|
|
4
|
+
// URLs, so it puts the bytes straight in the param. Everything downstream
|
|
5
|
+
// (presets, undo, the params hash, `when`) works with either shape, no special
|
|
6
|
+
// case — this widget is the only place on the main thread that has to look at
|
|
7
|
+
// the difference.
|
|
8
|
+
//
|
|
9
|
+
// Two renderings, mirroring widgets/font.js exactly. With a host-supplied
|
|
10
|
+
// `imageCatalog` it is a button — a thumbnail + label — opening the picker.
|
|
11
|
+
// Without one it degrades to a URL text field, so a standalone partforge app
|
|
12
|
+
// (which ships no catalog) still exposes the parameter.
|
|
13
|
+
//
|
|
14
|
+
// Main-thread only: the preview is a plain `<img>` bound to the source URL —
|
|
15
|
+
// the browser decodes the PNG natively. Do NOT import `png-decode.js` (or
|
|
16
|
+
// anything from images.js) here; that decoder belongs to the worker's build
|
|
17
|
+
// path, not the panel.
|
|
18
|
+
import { attachInfo } from "../info.js";
|
|
19
|
+
import { IMAGE_ALLOW_DEFAULT, imageSourceAllowed } from "../../image-source.js";
|
|
20
|
+
import { mountDrop } from "./file-drop.js";
|
|
21
|
+
|
|
22
|
+
function el(tag, className, text) {
|
|
23
|
+
const node = document.createElement(tag);
|
|
24
|
+
if (className) node.className = className;
|
|
25
|
+
if (text != null) node.textContent = text;
|
|
26
|
+
return node;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
|
|
30
|
+
|
|
31
|
+
// A URL source → its filename, for a label with no catalog to ask. A byte
|
|
32
|
+
// value has no filename — callers check `isBytes` first and never reach this
|
|
33
|
+
// for one. Not a source to fetch, never a source to warn about: `isNoImageSource`
|
|
34
|
+
// values (unset/"") read as "No image" rather than a broken link.
|
|
35
|
+
export function imageLabel(source) {
|
|
36
|
+
if (typeof source !== "string" || !source) return "No image";
|
|
37
|
+
let path = source;
|
|
38
|
+
try { path = new URL(source).pathname; } catch { /* not a URL — use the raw string */ }
|
|
39
|
+
const file = path.split("/").filter(Boolean).pop();
|
|
40
|
+
return file || source;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Point (or unpoint) the live preview. A byte-valued param has no URL to hand
|
|
44
|
+
// the browser, so the image stays hidden rather than trying to render it or
|
|
45
|
+
// showing a broken-image glyph — same rule an empty/unset value gets. `onerror`
|
|
46
|
+
// covers the other broken-image case: a URL that 404s or that CORS refuses.
|
|
47
|
+
function paintPreview(img, source) {
|
|
48
|
+
if (typeof source === "string" && source) {
|
|
49
|
+
img.hidden = false;
|
|
50
|
+
img.src = source;
|
|
51
|
+
} else {
|
|
52
|
+
img.hidden = true;
|
|
53
|
+
img.removeAttribute("src");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function makeImage(node, params, { onChange, onCommit, info, imageCatalog, onAssetUpload } = {}) {
|
|
58
|
+
const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : IMAGE_ALLOW_DEFAULT;
|
|
59
|
+
const wrap = el("div", "slider");
|
|
60
|
+
const row = el("div", "row");
|
|
61
|
+
const label = el("label", "", node.label ?? node.key);
|
|
62
|
+
attachInfo(label, node.description, info);
|
|
63
|
+
row.append(label);
|
|
64
|
+
wrap.append(row);
|
|
65
|
+
|
|
66
|
+
const preview = document.createElement("img");
|
|
67
|
+
preview.className = "image-preview";
|
|
68
|
+
preview.alt = "";
|
|
69
|
+
preview.hidden = true;
|
|
70
|
+
// A URL that fails to load (404, CORS, revoked link) must degrade to hidden,
|
|
71
|
+
// not the browser's broken-image glyph.
|
|
72
|
+
preview.addEventListener("error", () => { preview.hidden = true; });
|
|
73
|
+
wrap.append(preview);
|
|
74
|
+
|
|
75
|
+
if (!imageCatalog) {
|
|
76
|
+
// Degraded path: a URL field. Unlike `text`, it does NOT write on every
|
|
77
|
+
// keystroke — a half-typed URL is a guaranteed failed fetch, and the
|
|
78
|
+
// rebuild loop would chase every one of them.
|
|
79
|
+
const field = document.createElement("input");
|
|
80
|
+
field.type = "text";
|
|
81
|
+
field.className = "text-input";
|
|
82
|
+
const paintField = () => {
|
|
83
|
+
const v = params[node.key];
|
|
84
|
+
// Bytes cannot round-trip through a text field — `String(arrayBuffer)`
|
|
85
|
+
// is "[object ArrayBuffer]", not a value anyone typed. Show an honest
|
|
86
|
+
// placeholder instead of that, and leave the field free to type a
|
|
87
|
+
// replacement URL over it.
|
|
88
|
+
field.value = isBytes(v) ? "" : String(v ?? "");
|
|
89
|
+
field.placeholder = isBytes(v) ? "Uploaded image" : "";
|
|
90
|
+
field.classList.remove("warn");
|
|
91
|
+
paintPreview(preview, v);
|
|
92
|
+
};
|
|
93
|
+
field.addEventListener("change", () => {
|
|
94
|
+
if (!imageSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
|
|
95
|
+
field.classList.remove("warn");
|
|
96
|
+
params[node.key] = field.value;
|
|
97
|
+
onChange?.();
|
|
98
|
+
onCommit?.();
|
|
99
|
+
});
|
|
100
|
+
paintField();
|
|
101
|
+
wrap.append(field);
|
|
102
|
+
|
|
103
|
+
const drop = mountDrop("image", {
|
|
104
|
+
params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
|
|
105
|
+
});
|
|
106
|
+
wrap.append(drop.el, drop.errorEl);
|
|
107
|
+
|
|
108
|
+
return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const btn = el("button", "image-btn");
|
|
112
|
+
btn.type = "button";
|
|
113
|
+
const thumb = document.createElement("img");
|
|
114
|
+
thumb.className = "image-btn-thumb";
|
|
115
|
+
thumb.alt = "";
|
|
116
|
+
thumb.hidden = true;
|
|
117
|
+
thumb.addEventListener("error", () => { thumb.hidden = true; });
|
|
118
|
+
const iname = el("span", "iname");
|
|
119
|
+
btn.append(thumb, iname);
|
|
120
|
+
btn.insertAdjacentHTML("beforeend",
|
|
121
|
+
'<svg class="caret" width="8" height="7" viewBox="0 0 8 7" aria-hidden="true"><polygon points="0,0 8,0 4,7" fill="currentColor"/></svg>');
|
|
122
|
+
wrap.append(btn);
|
|
123
|
+
|
|
124
|
+
// The value alone cannot describe a byte-valued source, and even a URL's
|
|
125
|
+
// filename is a guess a catalog can improve on. `describe` is optional and
|
|
126
|
+
// may be async, so the label is painted twice: an immediate honest guess,
|
|
127
|
+
// then the catalog's answer when it lands (same two-pass shape as font.js's
|
|
128
|
+
// `paint`, `paintSeq` included so a stale describe() can't win a race
|
|
129
|
+
// against a newer one).
|
|
130
|
+
let paintSeq = 0;
|
|
131
|
+
const paint = () => {
|
|
132
|
+
const src = params[node.key];
|
|
133
|
+
const seq = ++paintSeq;
|
|
134
|
+
paintPreview(preview, src);
|
|
135
|
+
paintPreview(thumb, src);
|
|
136
|
+
const show = ({ label: text, width, height }) => {
|
|
137
|
+
if (seq !== paintSeq) return; // a newer paint already won
|
|
138
|
+
iname.textContent = width && height ? `${text} (${width}×${height})` : text;
|
|
139
|
+
};
|
|
140
|
+
show(isBytes(src) ? { label: "Uploaded image" } : { label: imageLabel(src) });
|
|
141
|
+
if (typeof imageCatalog.describe !== "function") return;
|
|
142
|
+
Promise.resolve()
|
|
143
|
+
.then(() => imageCatalog.describe(src))
|
|
144
|
+
.then((d) => {
|
|
145
|
+
if (!d) return;
|
|
146
|
+
show({ label: d.label ?? (isBytes(src) ? "Uploaded image" : imageLabel(src)), width: d.width, height: d.height });
|
|
147
|
+
})
|
|
148
|
+
.catch(() => { /* a failed lookup keeps the immediate label */ });
|
|
149
|
+
};
|
|
150
|
+
paint();
|
|
151
|
+
|
|
152
|
+
// The picker registers itself through setImagePicker (see below); with no
|
|
153
|
+
// picker in the bundle the button is inert rather than broken.
|
|
154
|
+
//
|
|
155
|
+
// The handle is kept because the picker is a TAKEOVER: it appends itself to
|
|
156
|
+
// the rail, outside the panel root, so tearing the panel down does not take it
|
|
157
|
+
// with it. Without dispose() the element — and the `document` keydown listener
|
|
158
|
+
// that only close() unhooks — would outlive the panel holding a stale `params`.
|
|
159
|
+
let picker = null;
|
|
160
|
+
btn.addEventListener("click", () => {
|
|
161
|
+
picker = openImagePicker?.({ node, params, allow, imageCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const drop = mountDrop("image", { params, node, onAssetUpload, onChange, onCommit, onRender: paint });
|
|
165
|
+
wrap.append(drop.el, drop.errorEl);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
el: wrap,
|
|
169
|
+
sync: paint,
|
|
170
|
+
dispose: () => { picker?.close(); picker = null; drop.dispose(); },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Assigned by image-picker.js, which widgets/index.js imports for the side
|
|
175
|
+
// effect. Kept as a mutable binding rather than a static import so this file
|
|
176
|
+
// stays usable — and testable — without dragging the whole picker in.
|
|
177
|
+
export let openImagePicker = null;
|
|
178
|
+
export const setImagePicker = (fn) => { openImagePicker = fn; };
|
|
@@ -5,12 +5,16 @@ import { makeText } from "./text.js";
|
|
|
5
5
|
import { makeCheckbox } from "./checkbox.js";
|
|
6
6
|
import { makeSelect, makeRadio } from "./select.js";
|
|
7
7
|
import { makeFont } from "./font.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// font.js
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
import { makeImage } from "./image.js";
|
|
9
|
+
import { makeVector } from "./vector.js";
|
|
10
|
+
// Side-effect imports: font-picker.js / image-picker.js call setFontPicker() /
|
|
11
|
+
// setImagePicker() at module scope, so each widget's button finds a picker to
|
|
12
|
+
// open. They live HERE and not in font.js/image.js because the dependency has
|
|
13
|
+
// to run picker → widget and never back — those files must stay importable
|
|
14
|
+
// (and testable) without dragging the whole DOM-heavy picker in. See the note
|
|
15
|
+
// at the bottom of font.js.
|
|
13
16
|
import "../font-picker.js";
|
|
17
|
+
import "../image-picker.js";
|
|
14
18
|
|
|
15
19
|
export const WIDGET_FACTORIES = {
|
|
16
20
|
slider: makeNumeric,
|
|
@@ -21,4 +25,6 @@ export const WIDGET_FACTORIES = {
|
|
|
21
25
|
select: makeSelect,
|
|
22
26
|
radio: makeRadio,
|
|
23
27
|
font: makeFont,
|
|
28
|
+
image: makeImage,
|
|
29
|
+
vector: makeVector,
|
|
24
30
|
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The `type: "vector"` control. Its VALUE is vector artwork — either a URL
|
|
2
|
+
// string (the same grammar `PartDefinition.vectors` already accepts) or the
|
|
3
|
+
// PARSED partforge-vector document object a drop/paste conversion produces
|
|
4
|
+
// (see file-drop.js's vector-kind handling and vectors.js's `asParsedFile`,
|
|
5
|
+
// "the in-tree form" — a source that IS the parsed contents of its file
|
|
6
|
+
// rather than a way to reach its bytes). `vectorsFor` (vectors.js) is what
|
|
7
|
+
// lets a `type: "vector"` control drive the artwork: a part declares
|
|
8
|
+
// `vectors: (p) => ({ name: p.art })` and this control writes `p.art`.
|
|
9
|
+
//
|
|
10
|
+
// Unlike font.js/image.js there is NO catalog provider for artwork — no
|
|
11
|
+
// `vectorCatalog` exists (see the design doc's "no vector catalog provider"
|
|
12
|
+
// note) — so this control has exactly ONE rendering: a URL field plus a
|
|
13
|
+
// drop target. There is no picker button and nothing here degrades from a
|
|
14
|
+
// richer form; this IS the whole control.
|
|
15
|
+
//
|
|
16
|
+
// Main-thread only: the real SVG -> partforge-vector conversion (paper.js)
|
|
17
|
+
// runs inside `makeFileDrop` -> the registry's "vector" convert thunk,
|
|
18
|
+
// never here.
|
|
19
|
+
import { attachInfo } from "../info.js";
|
|
20
|
+
import { VECTOR_ALLOW_DEFAULT, vectorSourceAllowed } from "../../vector-source.js";
|
|
21
|
+
import { mountDrop } from "./file-drop.js";
|
|
22
|
+
|
|
23
|
+
function el(tag, className, text) {
|
|
24
|
+
const node = document.createElement(tag);
|
|
25
|
+
if (className) node.className = className;
|
|
26
|
+
if (text != null) node.textContent = text;
|
|
27
|
+
return node;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
|
|
31
|
+
// A dropped/pasted SVG with no `onAssetUpload` host hook lands as the PARSED
|
|
32
|
+
// document object (task-9 addendum, Ruling D) — an opaque value with nothing
|
|
33
|
+
// a text field can show, the same rule image.js/font.js apply to a
|
|
34
|
+
// byte-valued param.
|
|
35
|
+
const isOpaque = (v) => isBytes(v) || (v != null && typeof v === "object");
|
|
36
|
+
|
|
37
|
+
export function makeVector(node, params, { onChange, onCommit, info, onAssetUpload } = {}) {
|
|
38
|
+
const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : VECTOR_ALLOW_DEFAULT;
|
|
39
|
+
const wrap = el("div", "slider");
|
|
40
|
+
const row = el("div", "row");
|
|
41
|
+
const label = el("label", "", node.label ?? node.key);
|
|
42
|
+
attachInfo(label, node.description, info);
|
|
43
|
+
row.append(label);
|
|
44
|
+
wrap.append(row);
|
|
45
|
+
|
|
46
|
+
// The URL field. Unlike `text`, it does NOT write on every keystroke — a
|
|
47
|
+
// half-typed URL is a guaranteed failed fetch, and the rebuild loop would
|
|
48
|
+
// chase every one of them. Mirrors widgets/image.js's/font.js's own field.
|
|
49
|
+
const field = document.createElement("input");
|
|
50
|
+
field.type = "text";
|
|
51
|
+
field.className = "text-input";
|
|
52
|
+
const paintField = () => {
|
|
53
|
+
const v = params[node.key];
|
|
54
|
+
// A parsed document (or bytes) cannot round-trip through a text field —
|
|
55
|
+
// show an honest placeholder instead, and leave the field free to type a
|
|
56
|
+
// replacement URL over it.
|
|
57
|
+
field.value = isOpaque(v) ? "" : String(v ?? "");
|
|
58
|
+
field.placeholder = isOpaque(v) ? "Uploaded artwork" : "";
|
|
59
|
+
field.classList.remove("warn");
|
|
60
|
+
};
|
|
61
|
+
field.addEventListener("change", () => {
|
|
62
|
+
if (!vectorSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
|
|
63
|
+
field.classList.remove("warn");
|
|
64
|
+
params[node.key] = field.value;
|
|
65
|
+
onChange?.();
|
|
66
|
+
onCommit?.();
|
|
67
|
+
});
|
|
68
|
+
paintField();
|
|
69
|
+
wrap.append(field);
|
|
70
|
+
|
|
71
|
+
const drop = mountDrop("vector", {
|
|
72
|
+
params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
|
|
73
|
+
});
|
|
74
|
+
wrap.append(drop.el, drop.errorEl);
|
|
75
|
+
|
|
76
|
+
return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
|
|
77
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// affect what's visible. Pure — no DOM, no real geometry (reuses the geometry-free
|
|
4
4
|
// probe kernel). Errs toward RELEVANT_ALL whenever it can't analyze a build.
|
|
5
5
|
import { createProbeKernel } from "./geometry/probe.js";
|
|
6
|
+
import { byteAwareReplacer } from "./geometry/solid-hash.js";
|
|
6
7
|
import { viewSubParts } from "./part-model.js";
|
|
7
8
|
import { resolveDerived } from "./derive.js";
|
|
8
9
|
|
|
@@ -125,7 +126,11 @@ export function subPartReadKeys(part, view, params) {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
// Stable string of the given param keys' current values — the cache-validity key
|
|
128
|
-
// for one sub-part. Sorted so key order never affects the result.
|
|
129
|
+
// for one sub-part. Sorted so key order never affects the result. `byteAwareReplacer`
|
|
130
|
+
// substitutes a content fingerprint for a byte-valued param (an ArrayBuffer/typed-array
|
|
131
|
+
// image source — see its own header) so JSON.stringify's default handling doesn't
|
|
132
|
+
// collapse every image to the same "{}" (cache never invalidates) or expand a typed
|
|
133
|
+
// array to one JSON number per byte (see solid-hash.js).
|
|
129
134
|
export function relevanceHash(keys, params) {
|
|
130
|
-
return JSON.stringify(keys.slice().sort().map((k) => [k, params[k]]));
|
|
135
|
+
return JSON.stringify(keys.slice().sort().map((k) => [k, params[k]]), byteAwareReplacer);
|
|
131
136
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// What a PARAM-supplied vector source may be. Author-declared `vectors`
|
|
2
|
+
// sources are code and get no restriction; this file exists only for the
|
|
3
|
+
// other case — a value that arrived in `params`, which on a shared link is
|
|
4
|
+
// attacker-controlled input that `vectors: (p) => …` would turn into a fetch
|
|
5
|
+
// URL.
|
|
6
|
+
//
|
|
7
|
+
// This file's allow rule differs from its two siblings
|
|
8
|
+
// (font-source.js/image-source.js) in ONE place, and getting that difference
|
|
9
|
+
// right matters: those two exempt every non-string (bytes) source from the
|
|
10
|
+
// allow check on the reasoning that an ArrayBuffer cannot survive a share
|
|
11
|
+
// link — a URL can't carry megabytes, so bytes in params can only have been
|
|
12
|
+
// placed there by the host's own trusted panel. That reasoning does NOT
|
|
13
|
+
// transfer to vector artwork: `type: "vector"`'s drop target writes a PARSED
|
|
14
|
+
// partforge-vector document — plain JSON — into params when there is no upload
|
|
15
|
+
// hook, and unlike raw bytes, plain JSON round-trips a share link perfectly
|
|
16
|
+
// (it's just more of the same params payload). Do not copy the
|
|
17
|
+
// bytes-can't-survive-a-link justification here; it would be wrong.
|
|
18
|
+
//
|
|
19
|
+
// So the exemption this file grants is NOT "any object". The gate has to be
|
|
20
|
+
// read against what asset-resolve.js actually does, in its order:
|
|
21
|
+
// `unwrapModule(v)` runs FIRST, and only THEN does the resolver dispatch on
|
|
22
|
+
// shape. A `{ default: "http://169.254.169.254/…" }` wrapper is therefore
|
|
23
|
+
// unwrapped to a plain string and handed to `fetch` — arbitrary scheme,
|
|
24
|
+
// arbitrary host, in ~40 bytes of JSON a share link carries effortlessly. An
|
|
25
|
+
// earlier version of this file exempted every object on the claim that "an
|
|
26
|
+
// object never reaches the fetch branch"; that claim was FALSE for exactly
|
|
27
|
+
// this shape, and it is deleted rather than qualified.
|
|
28
|
+
//
|
|
29
|
+
// The rule that does hold, and what this file gates on, is: unwrap first, then
|
|
30
|
+
// judge the value the RESOLVER will see.
|
|
31
|
+
//
|
|
32
|
+
// - a string or `URL` — fetchable, so it gets the full `allow` treatment;
|
|
33
|
+
// - bytes (ArrayBuffer/view) — never handed to `fetch`, and (unlike JSON)
|
|
34
|
+
// genuinely cannot ride a link, so exempt;
|
|
35
|
+
// - a function/thunk — refused outright. Its return value is a fetch source
|
|
36
|
+
// that cannot be known at check time, so there is nothing to gate on;
|
|
37
|
+
// - a plain object that does NOT unwrap to any of the above — the genuinely
|
|
38
|
+
// inert case, the already-parsed partforge-vector document vectors.js's
|
|
39
|
+
// `asParsedFile` claims. It is validated downstream by `toInternalDocument`,
|
|
40
|
+
// a pure shape check that never touches the network, so it is exempt;
|
|
41
|
+
// - anything else (arrays, numbers, booleans) — refused. None of them is a
|
|
42
|
+
// valid source, and a refusal simply restores the part's default.
|
|
43
|
+
//
|
|
44
|
+
// `unwrapModule` is imported, never re-implemented: this gate is only sound
|
|
45
|
+
// while it unwraps by exactly the same rule the resolver does.
|
|
46
|
+
//
|
|
47
|
+
// DOM-free and node:-free: jobs.js (worker graph) and the panel both import it.
|
|
48
|
+
// asset-resolve.js is itself dependency-free, so importing it keeps
|
|
49
|
+
// partforge/lint's zero-dependency closure intact.
|
|
50
|
+
import { unwrapModule } from "./asset-resolve.js";
|
|
51
|
+
|
|
52
|
+
export const VECTOR_ALLOW_DEFAULT = ["https"];
|
|
53
|
+
|
|
54
|
+
const ASSET_SCHEME = "pfc-asset:";
|
|
55
|
+
|
|
56
|
+
// The "unset" vector source. An empty value declares NO artwork for that
|
|
57
|
+
// name — mirrors isNoFontSource/isNoImageSource exactly. Never a source to
|
|
58
|
+
// fetch, and never a source to refuse.
|
|
59
|
+
export const isNoVectorSource = (v) => v === undefined || v === null || v === "";
|
|
60
|
+
|
|
61
|
+
const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
|
|
62
|
+
|
|
63
|
+
// An already-parsed partforge-vector document: object-shaped, and not any of
|
|
64
|
+
// the shapes the resolver reads as a way to REACH bytes. Deliberately the same
|
|
65
|
+
// structural test vectors.js's `asParsedFile` applies (arrays excluded — an
|
|
66
|
+
// array is never a file), so "what this gate exempts" and "what the resolver
|
|
67
|
+
// adopts without fetching" stay the same set.
|
|
68
|
+
const isParsedDocument = (v) =>
|
|
69
|
+
v != null && typeof v === "object" && !Array.isArray(v)
|
|
70
|
+
&& !isBytes(v) && !(v instanceof URL);
|
|
71
|
+
|
|
72
|
+
// Parse once; an unparseable string is refused rather than guessed at.
|
|
73
|
+
function parse(source) {
|
|
74
|
+
try { return new URL(source); } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function vectorSourceAllowed(source, allow = VECTOR_ALLOW_DEFAULT) {
|
|
78
|
+
// Unwrap FIRST — asset-resolve.js does, before it dispatches on shape, so a
|
|
79
|
+
// `{ default: … }` wrapper must be judged by what it unwraps to. See header.
|
|
80
|
+
const v = unwrapModule(source);
|
|
81
|
+
// A thunk (before or after unwrapping) resolves to a source this check cannot
|
|
82
|
+
// see, so there is nothing to gate; refuse rather than trust it.
|
|
83
|
+
if (typeof source === "function" || typeof v === "function") return false;
|
|
84
|
+
if (isBytes(v)) return true; // never handed to `fetch`
|
|
85
|
+
if (isParsedDocument(v)) return true; // inert; validated, never fetched
|
|
86
|
+
// Everything the resolver would fetch — a string or a `URL` — gets the full
|
|
87
|
+
// allow treatment. Everything else (arrays, numbers, booleans) is refused.
|
|
88
|
+
if (!(typeof v === "string" || v instanceof URL)) return false;
|
|
89
|
+
const u = v instanceof URL ? v : parse(v);
|
|
90
|
+
if (!u) return false;
|
|
91
|
+
for (const kind of allow) {
|
|
92
|
+
// hostname/protocol, never a substring of the raw string — same rule
|
|
93
|
+
// font-source.js's header explains: a URL merely CONTAINING
|
|
94
|
+
// "pfc-asset://" must not pass, and neither must a lookalike host.
|
|
95
|
+
if (kind === "https" && u.protocol === "https:") return true;
|
|
96
|
+
if (kind === "asset" && u.protocol === ASSET_SCHEME) return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// paramKey → allow list, for every `type: "vector"` control in the authored
|
|
102
|
+
// tree — new-shape (`controls`, including nested groups) AND legacy-shape
|
|
103
|
+
// (`advanced`/`toggles`/`features`, where panel/legacy.js desugars a
|
|
104
|
+
// descriptor's `control:` field to `type:`), mirroring
|
|
105
|
+
// imageControlAllows/fontControlAllows exactly. Missing the legacy arrays
|
|
106
|
+
// here would leave a `{key, control:"vector"}` descriptor with no entry in
|
|
107
|
+
// the returned map, and jobs.js's check only looks at keys present in the
|
|
108
|
+
// map — so a legacy-declared vector control would get silently
|
|
109
|
+
// unrestricted. Tolerant of any array being absent or malformed; it must
|
|
110
|
+
// never throw on an existing part.
|
|
111
|
+
export function vectorControlAllows(part) {
|
|
112
|
+
const out = new Map();
|
|
113
|
+
const visit = (nodes) => {
|
|
114
|
+
for (const n of nodes ?? []) {
|
|
115
|
+
if (!n || typeof n !== "object") continue;
|
|
116
|
+
if (Array.isArray(n.controls)) visit(n.controls);
|
|
117
|
+
if (Array.isArray(n.advanced)) visit(n.advanced);
|
|
118
|
+
if (Array.isArray(n.toggles)) visit(n.toggles);
|
|
119
|
+
if (Array.isArray(n.features)) visit(n.features);
|
|
120
|
+
if ((n.type === "vector" || n.control === "vector") && typeof n.key === "string") {
|
|
121
|
+
out.set(n.key, Array.isArray(n.allow) && n.allow.length ? n.allow : VECTOR_ALLOW_DEFAULT);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
visit(part?.parameters);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
package/src/framework/vectors.js
CHANGED
|
@@ -83,6 +83,15 @@ const resolveOne = makeAssetResolver(
|
|
|
83
83
|
(value) => asParsedFile(value) ?? undefined,
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
+
// `vectors` may be a plain { name: source } map, or a function of the resolved
|
|
87
|
+
// params — the second form is what lets a `type: "vector"` control drive the
|
|
88
|
+
// artwork. Resolving it needs `p`, which is why this is a separate step from
|
|
89
|
+
// resolveVectors rather than folded into it. Mirrors fontsFor/imagesFor.
|
|
90
|
+
export function vectorsFor(part, p) {
|
|
91
|
+
const decl = part?.vectors;
|
|
92
|
+
return typeof decl === "function" ? decl(p) : decl;
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
export async function resolveVectors(vectorsDecl) {
|
|
87
96
|
// A function reaching here means a caller passed `part.vectors` raw, the way
|
|
88
97
|
// fonts.js's resolveFonts guards against the same mistake for `part.fonts`.
|
package/src/ingest.js
CHANGED
|
@@ -6,3 +6,4 @@
|
|
|
6
6
|
// Deliberately NOT re-exported from `partforge` (the main entry) or from
|
|
7
7
|
// `partforge/geometry`: this must stay unreachable from the geometry worker.
|
|
8
8
|
export { ingestSvg } from "./framework/ingest/svg-ingest.js";
|
|
9
|
+
export { imageToPng } from "./framework/ingest/image-ingest.js";
|
|
Binary file
|
package/src/parts/emblem.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
// work under Vite and fail in the CLI.
|
|
20
20
|
//
|
|
21
21
|
// The source artwork lives beside it as emblem.svg, and the .json is regenerated
|
|
22
|
-
// with `
|
|
22
|
+
// with `npx partforge ingest src/parts/assets/emblem.svg --out
|
|
23
|
+
// src/parts/assets/emblem.vector.json`. plate.vector.json
|
|
23
24
|
// is hand-authored — no ingest step, no source SVG — and is kept legible enough
|
|
24
25
|
// to serve as documentation's worked example of a multi-shape, role-composed file.
|
|
25
26
|
import plate from "./assets/plate.vector.json" with { type: "json" };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Reference part for the `images` field / `type: "image"` control / `k.heightfield`
|
|
2
|
+
// (docs/AUTHORING-PARTS.md "Image controls"): a depth map becomes a printable relief
|
|
3
|
+
// plate. `relief` is a `type: "image"` control — pick a replacement PNG from the
|
|
4
|
+
// panel, or leave it empty and the bundled `assets/relief-demo.png` (a synthetic
|
|
5
|
+
// concentric-ripple depth map) is used, so the part builds with no network access
|
|
6
|
+
// and `partforge measure`/CI never need to fetch anything. `pitch` trades sampling
|
|
7
|
+
// detail against triangle count — and therefore STEP size, since a fine pitch on a
|
|
8
|
+
// high-frequency image produces many non-coplanar faces (see heightfieldMesh's own
|
|
9
|
+
// STEP-size warning in the OCCT backend).
|
|
10
|
+
//
|
|
11
|
+
// DEMO_RELIEF_RANGE: the bundled asset's luminance only spans ~39–75% of the
|
|
12
|
+
// 16-bit sample range (measured: 25443–48830 of 65535) — the ripple formula that
|
|
13
|
+
// generated it decays toward its 50%-gray baseline away from the first ring, so
|
|
14
|
+
// most pixels sit close to mid-gray. `k.heightfield`'s default `range: [0, 1]` is
|
|
15
|
+
// an IDENTITY map (raw sample value straight to 0..1), not an auto-normalize, so
|
|
16
|
+
// left alone the demo would use well under half of `maxZ`. This stretches the
|
|
17
|
+
// default asset's own measured extent to the full 0..1 span so the shipped demo
|
|
18
|
+
// shows the full relief amplitude. Applied only when the bundled default is in
|
|
19
|
+
// use (`p.relief` empty) — a picked custom image's tonal range is unknown ahead
|
|
20
|
+
// of build time, so it gets the identity range instead.
|
|
21
|
+
export const DEMO_RELIEF_RANGE = [25443 / 65535, 48830 / 65535];
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
meta: { title: "Relief plate", units: "mm", background: 0x15181d },
|
|
25
|
+
parameters: [
|
|
26
|
+
{
|
|
27
|
+
id: "image",
|
|
28
|
+
title: "Image",
|
|
29
|
+
description: "The depth map the relief is sampled from. Bright = high, dark = low, unless inverted.",
|
|
30
|
+
controls: [
|
|
31
|
+
{ key: "relief", type: "image", label: "Depth map",
|
|
32
|
+
description: "Pick a PNG from the catalog or paste a URL. Empty falls back to the bundled sample ripple." },
|
|
33
|
+
{ key: "invert", type: "checkbox", label: "Invert",
|
|
34
|
+
description: "Swap which end of the image is raised — bright becomes low, dark becomes high." },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "plate",
|
|
39
|
+
title: "Plate",
|
|
40
|
+
description: "Footprint and relief depth of the printed plate.",
|
|
41
|
+
advanced: [
|
|
42
|
+
{ key: "w", label: "Width", unit: "mm", min: 20, max: 200, step: 1,
|
|
43
|
+
description: "Plate footprint along X." },
|
|
44
|
+
{ key: "d", label: "Depth", unit: "mm", min: 20, max: 200, step: 1,
|
|
45
|
+
description: "Plate footprint along Y." },
|
|
46
|
+
{ key: "base", label: "Base", unit: "mm", min: 0.5, max: 10, step: 0.1,
|
|
47
|
+
description: "Solid slab thickness under the relief — keep it thick enough to print flat and stay rigid." },
|
|
48
|
+
{ key: "maxZ", label: "Relief height", unit: "mm", min: 0.2, max: 10, step: 0.1,
|
|
49
|
+
description: "How far the tallest sample rises above the base." },
|
|
50
|
+
{ key: "pitch", label: "Detail", unit: "mm", min: 0.2, max: 2, step: 0.1,
|
|
51
|
+
description: "Grid spacing of the height sampling. Smaller is crisper but costs more triangles — see the file header." },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
defaults: { relief: "", invert: 0, w: 60, d: 60, base: 1.5, maxZ: 3, pitch: 0.5 },
|
|
56
|
+
// The default is the bundled asset, so the part builds offline; a picked value
|
|
57
|
+
// (a URL or catalog source from the `type: "image"` control) replaces it.
|
|
58
|
+
images: (p) => ({
|
|
59
|
+
relief: p.relief || new URL("./assets/relief-demo.png", import.meta.url),
|
|
60
|
+
}),
|
|
61
|
+
parts: {
|
|
62
|
+
plate: {
|
|
63
|
+
label: "Relief plate",
|
|
64
|
+
views: ["relief"],
|
|
65
|
+
export: { name: "relief" },
|
|
66
|
+
build: (k, p) => k.heightfield("relief", {
|
|
67
|
+
w: p.w, d: p.d, base: p.base, maxZ: p.maxZ, pitch: p.pitch, invert: p.invert,
|
|
68
|
+
...(p.relief ? {} : { range: DEMO_RELIEF_RANGE }),
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
views: { relief: { label: "Relief" } },
|
|
73
|
+
// Self-verification: a heightfield solid is watertight and hole-free by
|
|
74
|
+
// construction (grid + skirt + cap, no cuts) — this pins that invariant rather
|
|
75
|
+
// than asserting anything image-specific. bbox bounds catch a runaway parameter;
|
|
76
|
+
// fdm-pla opts into the bed-fit gate for a plate meant to actually be printed.
|
|
77
|
+
verify: {
|
|
78
|
+
process: "fdm-pla",
|
|
79
|
+
expect: {
|
|
80
|
+
plate: { watertight: true, holes: 0, bbox: "<=[200,200,20]" },
|
|
81
|
+
_view: { overlaps: 0 },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
package/src/testing/manifold.js
CHANGED
|
@@ -6,11 +6,12 @@ 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 { ensureImages } from "../framework/images.js";
|
|
9
10
|
import { ensureVectors } from "../framework/vectors.js";
|
|
10
11
|
import { nodeAssetSources } from "./assets.js";
|
|
11
12
|
import { tessellateStepAssets } from "./step-mesh.js";
|
|
12
13
|
|
|
13
|
-
export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes, vectors } = {}) {
|
|
14
|
+
export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes, images, vectors } = {}) {
|
|
14
15
|
const wasm = await Module();
|
|
15
16
|
wasm.setup();
|
|
16
17
|
const kernel = createManifoldKernel(wasm, { quality });
|
|
@@ -25,6 +26,11 @@ export async function bootManifoldKernel({ quality = "preview", fonts, imports,
|
|
|
25
26
|
const meshes = importMeshes ?? (stepEntries.length ? await tessellateStepAssets(stepEntries) : null);
|
|
26
27
|
await ensureImports(kernel, decl, meshes);
|
|
27
28
|
}
|
|
29
|
+
// Third asset sibling: register declared images the same way as fonts/imports
|
|
30
|
+
// above, so a part using `k.heightfield` builds headlessly instead of hitting
|
|
31
|
+
// `heightfield: unknown image "…"` — file: sources need the same Node mapping
|
|
32
|
+
// (global fetch can't read them) that fonts/imports get from nodeAssetSources.
|
|
33
|
+
if (images && Object.keys(images).length) await ensureImages(kernel, nodeAssetSources(images));
|
|
28
34
|
if (vectors) await ensureVectors(kernel, nodeAssetSources(vectors));
|
|
29
35
|
return kernel;
|
|
30
36
|
}
|
package/src/testing/occt.js
CHANGED
|
@@ -8,10 +8,11 @@ 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 { ensureImages } from "../framework/images.js";
|
|
11
12
|
import { ensureVectors } from "../framework/vectors.js";
|
|
12
13
|
import { nodeAssetSources } from "./assets.js";
|
|
13
14
|
|
|
14
|
-
export async function bootOcctKernel({ fonts, imports, importMeshes, vectors } = {}) {
|
|
15
|
+
export async function bootOcctKernel({ fonts, imports, importMeshes, images, vectors } = {}) {
|
|
15
16
|
const require = createRequire(import.meta.url);
|
|
16
17
|
globalThis.require = globalThis.require ?? require;
|
|
17
18
|
globalThis.__dirname = globalThis.__dirname ?? path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -23,6 +24,8 @@ export async function bootOcctKernel({ fonts, imports, importMeshes, vectors } =
|
|
|
23
24
|
if (fonts) { const opentype = normalizeOpentype(await import("opentype.js"));
|
|
24
25
|
for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, parseFont(opentype, buf, name)); }
|
|
25
26
|
if (imports) await ensureImports(kernel, nodeAssetSources(imports), importMeshes ?? null);
|
|
27
|
+
// Third asset sibling: see bootManifoldKernel's matching comment.
|
|
28
|
+
if (images && Object.keys(images).length) await ensureImages(kernel, nodeAssetSources(images));
|
|
26
29
|
if (vectors) await ensureVectors(kernel, nodeAssetSources(vectors));
|
|
27
30
|
return kernel;
|
|
28
31
|
}
|