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,69 @@
|
|
|
1
|
+
// src/framework/ingest/node-dom.js
|
|
2
|
+
// A headless DOM for the `partforge ingest` CLI verb, promoted from the old
|
|
3
|
+
// scripts/ingest-svg.mjs (deleted — see bin/cli.js's `ingest` command).
|
|
4
|
+
// paper-core (imported by svg-ingest.js) builds a canvas and asks for a 2D
|
|
5
|
+
// context at MODULE LOAD time, so installNodeDom() must run and finish BEFORE
|
|
6
|
+
// svg-ingest.js is ever imported — the CLI installs this DOM first and only
|
|
7
|
+
// then dynamically imports the converter (registry.js's `convert` thunk for
|
|
8
|
+
// the "vector" kind).
|
|
9
|
+
//
|
|
10
|
+
// happy-dom is an OPTIONAL peer dependency (see package.json): most consumers
|
|
11
|
+
// never touch the SVG ingest path, and this keeps its ~17 MB out of their
|
|
12
|
+
// installs. It is imported dynamically, HERE, inside installNodeDom() — never
|
|
13
|
+
// at this module's top level — so bin/cli.js (and every other CLI verb) keeps
|
|
14
|
+
// loading and working when happy-dom isn't installed; the failure only
|
|
15
|
+
// surfaces on this path, with a message naming the install command.
|
|
16
|
+
//
|
|
17
|
+
// stubCanvas2dContext is exported separately from installNodeDom's own DOM
|
|
18
|
+
// setup: test/setup/happy-dom-patches.js needs the identical no-op 2D context
|
|
19
|
+
// for VITEST's own happy-dom test environment (a wholly different Window than
|
|
20
|
+
// the one installNodeDom() constructs here — vitest boots its own). Importing
|
|
21
|
+
// this one function from both places is what replaces what used to be two
|
|
22
|
+
// independently hand-typed copies of the same stub.
|
|
23
|
+
export function stubCanvas2dContext() {
|
|
24
|
+
return {
|
|
25
|
+
save() {}, restore() {}, beginPath() {}, closePath() {}, moveTo() {}, lineTo() {},
|
|
26
|
+
bezierCurveTo() {}, quadraticCurveTo() {}, arc() {}, rect() {}, fill() {}, stroke() {},
|
|
27
|
+
clip() {}, translate() {}, scale() {}, rotate() {}, transform() {}, setTransform() {},
|
|
28
|
+
clearRect() {}, fillRect() {}, strokeRect() {}, setLineDash() {},
|
|
29
|
+
fillText() {}, strokeText() {},
|
|
30
|
+
measureText: () => ({ width: 0 }),
|
|
31
|
+
getImageData: () => ({ data: new Uint8ClampedArray(4) }),
|
|
32
|
+
createImageData: (w, h) => ({ data: new Uint8ClampedArray(Math.max(0, w) * Math.max(0, h) * 4), width: w, height: h }),
|
|
33
|
+
putImageData() {}, drawImage() {}, isPointInPath: () => false,
|
|
34
|
+
createLinearGradient: () => ({ addColorStop() {} }),
|
|
35
|
+
canvas: { width: 1, height: 1 },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Installs a headless DOM as globals, for a plain Node process (the CLI) that
|
|
40
|
+
// otherwise has none. Idempotent enough for the CLI's one-shot use — it is not
|
|
41
|
+
// meant to be called more than once per process.
|
|
42
|
+
export async function installNodeDom() {
|
|
43
|
+
let Window;
|
|
44
|
+
try {
|
|
45
|
+
({ Window } = await import("happy-dom"));
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"ingest: converting SVG headlessly needs happy-dom (an optional peer dependency) — " +
|
|
49
|
+
"install it with `npm install happy-dom`",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const window = new Window();
|
|
53
|
+
// Node 21+ defines a getter-only `navigator` global of its own, so a plain
|
|
54
|
+
// Object.assign throws ("Cannot set property navigator of #<Object> which
|
|
55
|
+
// has only a getter") the moment it reaches that key. Define each property
|
|
56
|
+
// explicitly instead, overriding whatever accessor Node already installed.
|
|
57
|
+
for (const [key, value] of Object.entries({
|
|
58
|
+
window, self: window, document: window.document, navigator: window.navigator,
|
|
59
|
+
DOMParser: window.DOMParser, HTMLCanvasElement: window.HTMLCanvasElement,
|
|
60
|
+
Image: window.Image, SVGElement: window.SVGElement,
|
|
61
|
+
})) {
|
|
62
|
+
Object.defineProperty(globalThis, key, { value, writable: true, configurable: true, enumerable: true });
|
|
63
|
+
}
|
|
64
|
+
// paper-core builds a canvas and asks for a 2D context at module load;
|
|
65
|
+
// happy-dom has no canvas backend, and paper never touches the raster
|
|
66
|
+
// context for geometry (see svg-ingest.js's own header for why a no-op
|
|
67
|
+
// stub is enough).
|
|
68
|
+
globalThis.HTMLCanvasElement.prototype.getContext = stubCanvas2dContext;
|
|
69
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/framework/ingest/registry.js
|
|
2
|
+
// The one table that answers "what does this file become?" — read by the panel's
|
|
3
|
+
// drop widget AND by `partforge ingest`. It does NOT route to part fields: the
|
|
4
|
+
// declaration-function pattern (`images: (p) => ({ relief: p.relief })`) already
|
|
5
|
+
// does that, because a control writes into its own param key and the author's
|
|
6
|
+
// declaration puts that key in the right field.
|
|
7
|
+
//
|
|
8
|
+
// DOM-free, node:-free, and converter-free AT MODULE SCOPE: `convert` is a thunk
|
|
9
|
+
// returning a dynamic import, so reading the table costs nothing and the CLI
|
|
10
|
+
// never loads paper.js for a font.
|
|
11
|
+
import { sniffMediaType } from "./sniff.js";
|
|
12
|
+
|
|
13
|
+
export const ASSET_KINDS = ["image", "vector", "font"];
|
|
14
|
+
|
|
15
|
+
const ROWS = [
|
|
16
|
+
{
|
|
17
|
+
kind: "image",
|
|
18
|
+
name: "Image", // the human slot name — "Try the ${name} control" in a refusal message
|
|
19
|
+
label: "an image (PNG, JPG or WebP)",
|
|
20
|
+
accepts: ["image/png", "image/jpeg", "image/webp"],
|
|
21
|
+
convert: () => import("./image-ingest.js").then((m) => m.imageToPng),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
kind: "vector",
|
|
25
|
+
name: "Artwork",
|
|
26
|
+
label: "artwork (SVG)",
|
|
27
|
+
accepts: ["image/svg+xml"],
|
|
28
|
+
convert: () => import("./svg-ingest.js").then((m) => m.ingestSvg),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
kind: "font",
|
|
32
|
+
name: "Font",
|
|
33
|
+
label: "a font (TTF or OTF)",
|
|
34
|
+
accepts: ["font/ttf", "font/otf"],
|
|
35
|
+
convert: null, // used as-is; validated, never converted
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export const rowFor = (kind) => ROWS.find((r) => r.kind === kind);
|
|
40
|
+
|
|
41
|
+
// Which kind, if any, WOULD accept this media type — the "use the Artwork slot"
|
|
42
|
+
// hint. null when no slot accepts it (an unsupported format, e.g. WOFF2).
|
|
43
|
+
const kindAccepting = (mediaType) => ROWS.find((r) => r.accepts.includes(mediaType))?.kind ?? null;
|
|
44
|
+
|
|
45
|
+
export function classify(bytes, kind) {
|
|
46
|
+
const mediaType = sniffMediaType(bytes);
|
|
47
|
+
const row = rowFor(kind);
|
|
48
|
+
if (row && mediaType && row.accepts.includes(mediaType)) return { ok: true, mediaType };
|
|
49
|
+
return { ok: false, reason: mediaType ? "wrong-type" : "unrecognised",
|
|
50
|
+
mediaType, suggestKind: mediaType ? kindAccepting(mediaType) : null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function convertFor(kind, mediaType) {
|
|
54
|
+
const row = rowFor(kind);
|
|
55
|
+
if (!row || !row.accepts.includes(mediaType)) return null;
|
|
56
|
+
return row.convert ? row.convert() : null;
|
|
57
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// src/framework/ingest/sniff.js
|
|
2
|
+
// Bytes -> media type. The whole point is that a caller passes BYTES, never a
|
|
3
|
+
// filename: a file's claimed type is user input, and this is the path a
|
|
4
|
+
// mislabelled upload would travel. Nothing here trusts an extension because
|
|
5
|
+
// nothing here is given one.
|
|
6
|
+
//
|
|
7
|
+
// DOM-free and node:-free, and free of any converter import, so both the panel
|
|
8
|
+
// and the CLI can read it without loading paper.js.
|
|
9
|
+
|
|
10
|
+
const MAGIC = [
|
|
11
|
+
{ type: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
12
|
+
{ type: "image/jpeg", bytes: [0xff, 0xd8, 0xff] },
|
|
13
|
+
{ type: "font/otf", bytes: [0x4f, 0x54, 0x54, 0x4f] }, // "OTTO"
|
|
14
|
+
{ type: "font/woff2", bytes: [0x77, 0x4f, 0x46, 0x32] }, // "wOF2"
|
|
15
|
+
{ type: "font/ttf", bytes: [0x00, 0x01, 0x00, 0x00] },
|
|
16
|
+
{ type: "font/ttf", bytes: [0x74, 0x72, 0x75, 0x65] }, // "true"
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const startsWith = (u8, sig) => {
|
|
20
|
+
if (u8.length < sig.length) return false;
|
|
21
|
+
for (let i = 0; i < sig.length; i++) if (u8[i] !== sig[i]) return false;
|
|
22
|
+
return true;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// How far into a text file we look for an <svg root. Enough for an XML
|
|
26
|
+
// declaration, a DOCTYPE and a licence comment; bounded so a huge non-SVG text
|
|
27
|
+
// file is not scanned end to end. An SVG behind more than 4 KB of leading
|
|
28
|
+
// comments returns null rather than being recognised — an accepted bounded false
|
|
29
|
+
// negative.
|
|
30
|
+
const SVG_SCAN = 4096;
|
|
31
|
+
|
|
32
|
+
export function sniffMediaType(input) {
|
|
33
|
+
if (!input) return null;
|
|
34
|
+
const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
35
|
+
if (u8.length === 0) return null;
|
|
36
|
+
|
|
37
|
+
for (const m of MAGIC) if (startsWith(u8, m.bytes)) return m.type;
|
|
38
|
+
|
|
39
|
+
// WebP is RIFF????WEBP — the size field sits between the two tags.
|
|
40
|
+
if (u8.length >= 12 && startsWith(u8, [0x52, 0x49, 0x46, 0x46])
|
|
41
|
+
&& u8[8] === 0x57 && u8[9] === 0x45 && u8[10] === 0x42 && u8[11] === 0x50) {
|
|
42
|
+
return "image/webp";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// SVG has no magic number. Look for an <svg ROOT element by skipping leading
|
|
46
|
+
// prologue (BOM, whitespace, XML declaration, comments, DOCTYPE), then
|
|
47
|
+
// requiring the next real tag to be <svg followed by whitespace, >, or /.
|
|
48
|
+
// This anchors to the document root and rejects HTML files with inline <svg>.
|
|
49
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
50
|
+
const head = decoder.decode(u8.subarray(0, SVG_SCAN));
|
|
51
|
+
|
|
52
|
+
let pos = 0;
|
|
53
|
+
const len = head.length;
|
|
54
|
+
|
|
55
|
+
// Skip UTF-8 BOM
|
|
56
|
+
if (head.charCodeAt(0) === 0xFEFF) pos = 1;
|
|
57
|
+
|
|
58
|
+
// Skip leading whitespace
|
|
59
|
+
while (pos < len && /\s/.test(head[pos])) pos++;
|
|
60
|
+
|
|
61
|
+
// Skip XML declaration
|
|
62
|
+
if (head.substring(pos).startsWith("<?xml")) {
|
|
63
|
+
const end = head.indexOf("?>", pos);
|
|
64
|
+
if (end !== -1) {
|
|
65
|
+
pos = end + 2;
|
|
66
|
+
while (pos < len && /\s/.test(head[pos])) pos++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Skip DOCTYPE
|
|
71
|
+
if (head.substring(pos).startsWith("<!DOCTYPE")) {
|
|
72
|
+
const end = head.indexOf(">", pos);
|
|
73
|
+
if (end !== -1) {
|
|
74
|
+
pos = end + 1;
|
|
75
|
+
while (pos < len && /\s/.test(head[pos])) pos++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Skip comments
|
|
80
|
+
while (head.substring(pos).startsWith("<!--")) {
|
|
81
|
+
const end = head.indexOf("-->", pos);
|
|
82
|
+
if (end !== -1) {
|
|
83
|
+
pos = end + 3;
|
|
84
|
+
while (pos < len && /\s/.test(head[pos])) pos++;
|
|
85
|
+
} else {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if the next real tag is <svg
|
|
91
|
+
if (head.substring(pos).match(/^<svg[\s>\/]/i)) {
|
|
92
|
+
return "image/svg+xml";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -7,9 +7,12 @@ import { exportablePartNames } from "./export-select.js";
|
|
|
7
7
|
import { fontControlAllows, fontSourceAllowed, isNoFontSource } from "./font-source.js";
|
|
8
8
|
import { fontsFor, resolveFonts } from "./fonts.js";
|
|
9
9
|
import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
|
|
10
|
+
import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "./image-source.js";
|
|
11
|
+
import { imagesFor, ensureImages } from "./images.js";
|
|
10
12
|
import { ensureImports, resolveImports } from "./imports.js";
|
|
11
13
|
import { safeName } from "./safe-name.js";
|
|
12
|
-
import {
|
|
14
|
+
import { vectorControlAllows, vectorSourceAllowed, isNoVectorSource } from "./vector-source.js";
|
|
15
|
+
import { vectorsFor, ensureVectors } from "./vectors.js";
|
|
13
16
|
import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
14
17
|
|
|
15
18
|
// The oracle loads LAZILY, per job family, never at worker boot. It is the largest
|
|
@@ -146,10 +149,14 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
146
149
|
// default.
|
|
147
150
|
const { p, d } = resolveParams(part, msg.params, (params) => {
|
|
148
151
|
// A param bound to a `type: "font"` control is user input — on a shared
|
|
149
|
-
// link
|
|
150
|
-
// turn into a fetch URL.
|
|
151
|
-
//
|
|
152
|
-
//
|
|
152
|
+
// link a STRING value is arbitrary attacker-supplied text that
|
|
153
|
+
// `fonts: (p) => …` would turn into a fetch URL. A BYTE value
|
|
154
|
+
// (ArrayBuffer/typed array) always passes fontSourceAllowed regardless of
|
|
155
|
+
// `allow` — see font-source.js's header: it cannot have arrived via a
|
|
156
|
+
// share link (a URL can't carry megabytes), only from the host's own
|
|
157
|
+
// trusted panel (the drop-target path). Refuse out-of-`allow` values back
|
|
158
|
+
// to the part's own default rather than failing the build: a bad link
|
|
159
|
+
// should show the part, not an error page.
|
|
153
160
|
for (const [key, allow] of fontControlAllows(part)) {
|
|
154
161
|
const v = params[key];
|
|
155
162
|
if (isNoFontSource(v) || fontSourceAllowed(v, allow)) continue;
|
|
@@ -158,6 +165,48 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
158
165
|
jobWarnings.push({ part: null, message }); // …and the durable record
|
|
159
166
|
params[key] = part.defaults?.[key];
|
|
160
167
|
}
|
|
168
|
+
// Same shape for `type: "image"` controls — a param bound to one is user
|
|
169
|
+
// input, and on a shared link a STRING value is arbitrary attacker text
|
|
170
|
+
// that `images: (p) => …` would turn into a fetch URL. A BYTE value
|
|
171
|
+
// (ArrayBuffer/typed array) always passes imageSourceAllowed regardless of
|
|
172
|
+
// `allow` — see image-source.js's header: it cannot have arrived via a
|
|
173
|
+
// share link (a URL can't carry megabytes), only from the host's own
|
|
174
|
+
// trusted panel (the partforge-cloud sandbox path). Runs in this same
|
|
175
|
+
// sanitize hook, not after resolveParams returns, for the reason the
|
|
176
|
+
// comment above states: rewriting p[key] afterwards would leave derive()
|
|
177
|
+
// — and therefore `d` and the geometry — holding the refused value while
|
|
178
|
+
// build() saw the default.
|
|
179
|
+
for (const [key, allow] of imageControlAllows(part)) {
|
|
180
|
+
const v = params[key];
|
|
181
|
+
if (isNoImageSource(v) || imageSourceAllowed(v, allow)) continue;
|
|
182
|
+
const message = `image source for "${key}" is not allowed — using the default`;
|
|
183
|
+
onProgress(message);
|
|
184
|
+
jobWarnings.push({ part: null, message });
|
|
185
|
+
params[key] = part.defaults?.[key];
|
|
186
|
+
}
|
|
187
|
+
// Same shape again for `type: "vector"` controls — a param bound to one
|
|
188
|
+
// is user input, and on a shared link a STRING value is arbitrary
|
|
189
|
+
// attacker text that `vectors: (p) => …` would turn into a fetch URL.
|
|
190
|
+
// Unlike the two blocks above, a non-string value here does NOT bypass
|
|
191
|
+
// the check on a "can't survive a link" plausibility argument — see
|
|
192
|
+
// vector-source.js's header: a parsed vector document is plain JSON and
|
|
193
|
+
// survives a link just fine. It's permitted for the narrower, still-
|
|
194
|
+
// sufficient structural reason that vectorSourceAllowed applies:
|
|
195
|
+
// asset-resolve.js only ever calls `fetch` for a string/URL source, so
|
|
196
|
+
// an object (or bytes) can never become the SSRF-style request `allow`
|
|
197
|
+
// exists to gate. Runs in this same sanitize hook, not after
|
|
198
|
+
// resolveParams returns, for the reason both comments above state:
|
|
199
|
+
// rewriting p[key] afterwards would leave derive() — and therefore `d`
|
|
200
|
+
// and the geometry — holding the refused value while build() saw the
|
|
201
|
+
// default.
|
|
202
|
+
for (const [key, allow] of vectorControlAllows(part)) {
|
|
203
|
+
const v = params[key];
|
|
204
|
+
if (isNoVectorSource(v) || vectorSourceAllowed(v, allow)) continue;
|
|
205
|
+
const message = `vector source for "${key}" is not allowed — using the default`;
|
|
206
|
+
onProgress(message);
|
|
207
|
+
jobWarnings.push({ part: null, message });
|
|
208
|
+
params[key] = part.defaults?.[key];
|
|
209
|
+
}
|
|
161
210
|
});
|
|
162
211
|
// Preload any part-declared fonts into the kernel before building. A lazy
|
|
163
212
|
// dynamic import because this is async context (unlike the synchronous
|
|
@@ -227,6 +276,40 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
227
276
|
// lazy-error policy that keeps a STEP import inert until a build actually
|
|
228
277
|
// calls k.import on it.
|
|
229
278
|
if (part.imports) await ensureImports(kernel, part.imports, opts.importMeshes ?? null);
|
|
279
|
+
// Register this part's declared images on the kernel running this job — the
|
|
280
|
+
// third asset sibling beside fonts and imports. The allow-check already ran
|
|
281
|
+
// as resolveParams' sanitize hook above, so `p` here already reflects any
|
|
282
|
+
// refusal (reset to the part's default) — this step only resolves and
|
|
283
|
+
// uploads the resulting bytes.
|
|
284
|
+
//
|
|
285
|
+
// Gated on the part DECLARING `images` at all, not on this job having a
|
|
286
|
+
// source to resolve — the prune below has to run on the empty declaration
|
|
287
|
+
// too (a cleared pick), and a part with no `images` field must not touch
|
|
288
|
+
// the kernel's image map (a host or test harness may have seeded it
|
|
289
|
+
// directly via _registerImage).
|
|
290
|
+
if (part.images && typeof kernel._registerImage === "function") {
|
|
291
|
+
const imagesDecl = imagesFor(part, p) ?? {};
|
|
292
|
+
const declared = Object.fromEntries(
|
|
293
|
+
Object.entries(imagesDecl).filter(([name, src]) => {
|
|
294
|
+
if (!isNoImageSource(src)) return true;
|
|
295
|
+
onProgress(`no image source declared for "${name}" — skipping`);
|
|
296
|
+
return false;
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
if (Object.keys(declared).length) {
|
|
300
|
+
onProgress("resolving images");
|
|
301
|
+
await ensureImages(kernel, declared);
|
|
302
|
+
}
|
|
303
|
+
// Drop every registered name this build's declaration does not supply.
|
|
304
|
+
// `_pruneImages` is the images twin of the `kernel._fonts` prune above:
|
|
305
|
+
// `heightfield(name)` looks a name up by the part's declared key, not by
|
|
306
|
+
// content, so a relief the user picked and then CLEARED would otherwise
|
|
307
|
+
// stay registered under its old name and go on rendering instead of
|
|
308
|
+
// whatever a missing source actually does — the unknown-image throw, or
|
|
309
|
+
// a branch a part's own build() takes around the call — the
|
|
310
|
+
// stale-registration bug of spec §5, one asset over.
|
|
311
|
+
kernel._pruneImages?.(new Set(Object.keys(declared)));
|
|
312
|
+
}
|
|
230
313
|
// Vector art, the third asset family after fonts and imports. Same pre-build
|
|
231
314
|
// timing; ensureVectors owns the prune, so this stays one line. Call it
|
|
232
315
|
// unconditionally, even when this part has no `vectors` at all: ensureVectors
|
|
@@ -236,7 +319,25 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
236
319
|
// prevent. Guarding this on `part.vectors` would skip exactly the case where
|
|
237
320
|
// pruning matters most: a worker rebound from a part WITH artwork to one
|
|
238
321
|
// WITHOUT would leave the old names resolvable forever.
|
|
239
|
-
|
|
322
|
+
//
|
|
323
|
+
// Empty sources are filtered out first, exactly as the fonts and images
|
|
324
|
+
// blocks above do with their own isNo*Source helper: an empty default is
|
|
325
|
+
// the natural shape for a drop-target control (`defaults: { art: "" }`,
|
|
326
|
+
// with `vectors: (p) => ({ badge: p.art })`), and passing "" through to
|
|
327
|
+
// ensureVectors reaches `fetch("")` and fails with "Failed to parse URL
|
|
328
|
+
// from" — a first-run error message that names nothing an author can act
|
|
329
|
+
// on. An unset source declares NO artwork for that name; a build that
|
|
330
|
+
// still calls k.vector2d on it gets the ordinary unknown-vector throw, and
|
|
331
|
+
// the progress note keeps a genuine typo visible rather than swallowed.
|
|
332
|
+
const vectorsDecl = vectorsFor(part, p) ?? {};
|
|
333
|
+
const declaredVectors = Object.fromEntries(
|
|
334
|
+
Object.entries(vectorsDecl).filter(([name, src]) => {
|
|
335
|
+
if (!isNoVectorSource(src)) return true;
|
|
336
|
+
onProgress(`no vector source declared for "${name}" — skipping`);
|
|
337
|
+
return false;
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
await ensureVectors(kernel, declaredVectors);
|
|
240
341
|
// Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
|
|
241
342
|
const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
|
|
242
343
|
// Explicit selection (headless exportParts) overrides view-derived selection.
|
|
@@ -17,10 +17,11 @@ import { ANIMATION_RULES } from "./rules-animations.js";
|
|
|
17
17
|
import { PLACE_RULES } from "./rules-place.js";
|
|
18
18
|
import { IMPORT_RULES } from "./rules-imports.js";
|
|
19
19
|
import { FONT_RULES } from "./rules-fonts.js";
|
|
20
|
+
import { IMAGE_RULES } from "./rules-images.js";
|
|
20
21
|
import { SOURCE_RULES } from "./rules-source.js";
|
|
21
22
|
import { VECTOR_RULES } from "./rules-vector.js";
|
|
22
23
|
|
|
23
|
-
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
|
|
24
|
+
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...IMAGE_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
|
|
24
25
|
|
|
25
26
|
// A usable sources input, or null. Deliberately forgiving: lintPart's callers
|
|
26
27
|
// include hosted paths handing over user/LLM-authored trees, so a malformed
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Group 10 — image-control well-formedness + heightfield name resolution. The
|
|
2
|
+
// sibling of rules-fonts.js for the `type: "image"` control / `images` field /
|
|
3
|
+
// `k.heightfield()` triangle: same silent-failure shapes, same source-scheme
|
|
4
|
+
// concern, plus one this group owns alone — a `k.heightfield(name, opts)` call
|
|
5
|
+
// naming an image the part never declared.
|
|
6
|
+
//
|
|
7
|
+
// image-control-not-in-images and heightfield-unknown-image are deliberately
|
|
8
|
+
// COMPLEMENTARY, not overlapping: each fires in exactly the case the other
|
|
9
|
+
// skips. A function-form `images` can depend on a param — good, but its return
|
|
10
|
+
// value can't be read without calling it, so image-control-not-in-images
|
|
11
|
+
// actually calls it (with a sentinel substituted for the control's key) rather
|
|
12
|
+
// than settling for font-control-not-in-fonts's cheaper "is it a function at
|
|
13
|
+
// all?" question; a static `images` object provably CANNOT depend on a
|
|
14
|
+
// param — that mistake belongs to image-control-not-in-images too, and would
|
|
15
|
+
// fire on every correctly-authored fixed-image part if this rule also ran
|
|
16
|
+
// there, so it is skipped entirely for a static `images`. Conversely, only a
|
|
17
|
+
// static `images` object has statically-knowable keys, so heightfield-unknown-
|
|
18
|
+
// image runs only there and skips whenever `images` is a function.
|
|
19
|
+
import { err, warn } from "./finding.js";
|
|
20
|
+
import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "../image-source.js";
|
|
21
|
+
|
|
22
|
+
// A URL-shaped sentinel (has a "://"), not an arbitrary string — some `images`
|
|
23
|
+
// functions run `new URL(v)` or similar on the value before deciding whether to
|
|
24
|
+
// use it, and an arbitrary string would make that throw and short-circuit the
|
|
25
|
+
// probe for reasons that have nothing to do with whether the key is read.
|
|
26
|
+
const SENTINEL = "pf-lint-sentinel://image-control-not-in-images";
|
|
27
|
+
|
|
28
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
29
|
+
|
|
30
|
+
export const IMAGE_RULES = [
|
|
31
|
+
{
|
|
32
|
+
id: "image-control-not-in-images",
|
|
33
|
+
run: ({ part, p }) => {
|
|
34
|
+
// Static-object `images` always fails this check by construction — it
|
|
35
|
+
// cannot read a param at all — so it is a different mistake
|
|
36
|
+
// (image-source-scheme's business, or none) and not this rule's.
|
|
37
|
+
if (typeof part?.images !== "function") return [];
|
|
38
|
+
const controls = imageControlAllows(part);
|
|
39
|
+
if (controls.size === 0) return [];
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const key of controls.keys()) {
|
|
42
|
+
let resolved;
|
|
43
|
+
try { resolved = part.images({ ...p, [key]: SENTINEL }); }
|
|
44
|
+
catch { continue; } // can't be probed safely — not evidence either way
|
|
45
|
+
const reached = isPlainObject(resolved) && Object.values(resolved).includes(SENTINEL);
|
|
46
|
+
if (reached) continue;
|
|
47
|
+
out.push(err("image-control-not-in-images",
|
|
48
|
+
`control "${key}" is an image picker, but this part's \`images\` function never returns the picked value — the picked value is never resolved.`,
|
|
49
|
+
`Reference p.${key} from images, e.g. images: (p) => ({ ${key}: p.${key} }), and consume it with k.heightfield("${key}", opts).`,
|
|
50
|
+
"images"));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "heightfield-unknown-image",
|
|
57
|
+
run: ({ part, probe }) => {
|
|
58
|
+
// Only a static `images` object has statically-knowable names; a
|
|
59
|
+
// function's return value depends on params lint doesn't exhaustively
|
|
60
|
+
// enumerate, so it's skipped here (see file header).
|
|
61
|
+
if (typeof part?.images === "function") return [];
|
|
62
|
+
const known = new Set(isPlainObject(part?.images) ? Object.keys(part.images) : []);
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const call of probe().calls) {
|
|
66
|
+
if (call.scope !== "kernel" || call.op !== "heightfield") continue;
|
|
67
|
+
// k.heightfield(nameOrGrid, opts) also accepts an INLINE grid object as
|
|
68
|
+
// its first argument — args are recorded via JSON.stringify (probe.js's
|
|
69
|
+
// `describe`), so a grid parses back to an object, not a string. Only a
|
|
70
|
+
// string-literal first argument names a declared image; anything else
|
|
71
|
+
// (an inline grid, a computed/non-literal value the probe can't read)
|
|
72
|
+
// is silently skipped rather than flagged — flagging a supported inline
|
|
73
|
+
// grid as an "unknown image" would be a false positive.
|
|
74
|
+
let name;
|
|
75
|
+
try { name = JSON.parse(call.args[0]); } catch { name = null; }
|
|
76
|
+
if (typeof name !== "string" || known.has(name) || seen.has(name)) continue;
|
|
77
|
+
seen.add(name);
|
|
78
|
+
out.push(err("heightfield-unknown-image",
|
|
79
|
+
`build calls k.heightfield with name "${name}", which the part's images field does not declare: ${[...known].join(", ") || "(nothing)"}`,
|
|
80
|
+
"Declare the source under images: { name: source }, or fix the name to match an existing entry.",
|
|
81
|
+
"images"));
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "image-source-scheme",
|
|
88
|
+
run: ({ part }) => {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const [key, allow] of imageControlAllows(part)) {
|
|
91
|
+
const v = part?.defaults?.[key];
|
|
92
|
+
// An empty source declares no image (jobs.js's sanitize hook drops it
|
|
93
|
+
// before it reaches ensureImages, and a build() that still calls
|
|
94
|
+
// k.heightfield for it gets the ordinary unknown-image throw — there
|
|
95
|
+
// is no automatic "no relief" fallback) — a legitimate way to author
|
|
96
|
+
// an optional relief, not a source the allow list is refusing. A
|
|
97
|
+
// bytes source (ArrayBuffer/typed array) always
|
|
98
|
+
// passes imageSourceAllowed regardless of allow — see image-source.js's
|
|
99
|
+
// header — so it never reaches this branch either.
|
|
100
|
+
if (isNoImageSource(v) || imageSourceAllowed(v, allow)) continue;
|
|
101
|
+
out.push(warn("image-source-scheme",
|
|
102
|
+
`defaults.${key} is "${String(v).slice(0, 120)}", which control "${key}" would refuse (allow: ${allow.join(", ")}).`,
|
|
103
|
+
`Use a source the allow list accepts, or widen \`allow\` on the control. At build time this value is replaced by defaults.${key}, so as written the part has no usable image.`,
|
|
104
|
+
"defaults"));
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// Group 10 — vector-art call well-formedness
|
|
2
|
-
//
|
|
3
|
-
// where an authoring agent wants them.
|
|
1
|
+
// Group 10 — vector-art call well-formedness, plus vector-control wiring. The
|
|
2
|
+
// three call rules catch conditions that throw at build time anyway; they move
|
|
3
|
+
// them ahead of the kernel boot, which is where an authoring agent wants them.
|
|
4
|
+
// The fourth (vector-control-not-in-vectors) catches the one shape that never
|
|
5
|
+
// throws at all — a picker wired to nothing.
|
|
4
6
|
//
|
|
5
7
|
// All read `probe().calls`, whose `args` are JSON.stringify of the RESOLVED
|
|
6
8
|
// argument values under the part's default params (probe.js's `describe`), not
|
|
@@ -16,9 +18,19 @@
|
|
|
16
18
|
// package's header), so without a supplied document neither rule can tell
|
|
17
19
|
// units from shapes and both stay silent rather than guess.
|
|
18
20
|
import { err } from "./finding.js";
|
|
21
|
+
import { vectorControlAllows } from "../vector-source.js";
|
|
19
22
|
|
|
20
23
|
const declaredVectors = (part) => Object.keys(part?.vectors ?? {});
|
|
21
24
|
|
|
25
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
26
|
+
|
|
27
|
+
// A URL-shaped sentinel (has a "://"), not an arbitrary string — some `vectors`
|
|
28
|
+
// functions run `new URL(v)` or similar on the value before deciding whether to
|
|
29
|
+
// use it, and an arbitrary string would make that throw and short-circuit the
|
|
30
|
+
// probe for reasons that have nothing to do with whether the key is read. Same
|
|
31
|
+
// device, and the same reason, as rules-images.js's.
|
|
32
|
+
const SENTINEL = "pf-lint-sentinel://vector-control-not-in-vectors";
|
|
33
|
+
|
|
22
34
|
// The name arrives JSON-serialized, so JSON.parse recovers it — and yields null
|
|
23
35
|
// for anything that is not a string (import-unknown-name reads its name the same way).
|
|
24
36
|
const literalName = (src) => {
|
|
@@ -42,6 +54,15 @@ export const VECTOR_RULES = [
|
|
|
42
54
|
{
|
|
43
55
|
id: "vector-unknown-name",
|
|
44
56
|
run: ({ part, probe }) => {
|
|
57
|
+
// Only a STATIC `vectors` object has statically-knowable names. A
|
|
58
|
+
// function's return value depends on params lint doesn't enumerate, and
|
|
59
|
+
// `Object.keys(fn)` is `[]` — without this guard every k.vector2d call in
|
|
60
|
+
// a function-form part reports as unknown, and since `partforge measure`
|
|
61
|
+
// runs lint first, the part cannot be measured at all. Exactly the guard
|
|
62
|
+
// rules-fonts.js and rules-images.js already carry; the function form of
|
|
63
|
+
// `vectors` arrived after this rule and nobody carried it across.
|
|
64
|
+
// vector-control-not-in-vectors below is what covers the function form.
|
|
65
|
+
if (typeof part?.vectors === "function") return [];
|
|
45
66
|
const known = new Set(declaredVectors(part));
|
|
46
67
|
const seen = new Set();
|
|
47
68
|
const out = [];
|
|
@@ -109,4 +130,48 @@ export const VECTOR_RULES = [
|
|
|
109
130
|
return out;
|
|
110
131
|
},
|
|
111
132
|
},
|
|
133
|
+
{
|
|
134
|
+
// The vectors twin of image-control-not-in-images / font-control-not-in-fonts:
|
|
135
|
+
// a `type: "vector"` control whose key never reaches `vectors:` changes a
|
|
136
|
+
// param and nothing else — the artwork never moves — and without this the
|
|
137
|
+
// mistake only shows up at build time, or not at all.
|
|
138
|
+
//
|
|
139
|
+
// It covers BOTH shapes of the mistake, because for vectors both are real:
|
|
140
|
+
// a static `vectors` object provably cannot read a param, so a vector
|
|
141
|
+
// control beside one is inert by construction (font-control-not-in-fonts's
|
|
142
|
+
// question); and a function-form `vectors` that simply never returns the
|
|
143
|
+
// picked value is inert too, which can only be established by calling it
|
|
144
|
+
// with a sentinel (image-control-not-in-images's question). Together with
|
|
145
|
+
// vector-unknown-name — which runs only for the static form — every part
|
|
146
|
+
// shape is judged by exactly one of the two rules.
|
|
147
|
+
id: "vector-control-not-in-vectors",
|
|
148
|
+
run: ({ part, p }) => {
|
|
149
|
+
const controls = vectorControlAllows(part);
|
|
150
|
+
if (controls.size === 0) return [];
|
|
151
|
+
const isFn = typeof part?.vectors === "function";
|
|
152
|
+
// A function may wrap the picked value (`new URL(p.art)`) rather than
|
|
153
|
+
// pass it through, so match on the sentinel appearing in the value, not
|
|
154
|
+
// on identity.
|
|
155
|
+
const mentions = (v) => (typeof v === "string" || v instanceof URL) && String(v).includes(SENTINEL);
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const key of controls.keys()) {
|
|
158
|
+
if (isFn) {
|
|
159
|
+
let resolved;
|
|
160
|
+
try { resolved = part.vectors({ ...p, [key]: SENTINEL }); }
|
|
161
|
+
catch { continue; } // can't be probed safely — not evidence either way
|
|
162
|
+
if (isPlainObject(resolved) && Object.values(resolved).some(mentions)) continue;
|
|
163
|
+
out.push(err("vector-control-not-in-vectors",
|
|
164
|
+
`control "${key}" is a vector picker, but this part's \`vectors\` function never returns the picked value — the picked value is never resolved.`,
|
|
165
|
+
`Reference p.${key} from vectors, e.g. vectors: (p) => ({ ${key}: p.${key} }), and consume it with k.vector2d("${key}", { width: 20 }).`,
|
|
166
|
+
"vectors"));
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
out.push(err("vector-control-not-in-vectors",
|
|
170
|
+
`control "${key}" is a vector picker, but this part's \`vectors\` is ${part?.vectors ? "a static object" : "missing"} — the picked value is never resolved.`,
|
|
171
|
+
`Declare vectors as a function of params, e.g. vectors: (p) => ({ ${key}: p.${key} }), and reference it with k.vector2d("${key}", { width: 20 }).`,
|
|
172
|
+
"vectors"));
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
},
|
|
176
|
+
},
|
|
112
177
|
];
|
|
@@ -13,6 +13,7 @@ import { raycastViewer } from "../selection/raycast.js";
|
|
|
13
13
|
import { createFeatureHighlight } from "../selection/feature-highlight.js";
|
|
14
14
|
import { createDragTracker } from "../selection/drag-tracker.js";
|
|
15
15
|
import { subPartReadKeys, RELEVANT_ALL } from "../param-deps.js";
|
|
16
|
+
import { byteAwareReplacer } from "../geometry/solid-hash.js";
|
|
16
17
|
import { classifyFeature, bboxSpec, unionBounds } from "./feature-dims.js";
|
|
17
18
|
import { paramMatches } from "./param-link.js";
|
|
18
19
|
import { createPinStore, occurrenceOf } from "./pins.js";
|
|
@@ -136,7 +137,7 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
|
|
|
136
137
|
// direct test) falls back to the content hash.
|
|
137
138
|
let readsKey = null, readsMap = null;
|
|
138
139
|
function readsFor(view, params) {
|
|
139
|
-
const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params)}`;
|
|
140
|
+
const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params, byteAwareReplacer)}`;
|
|
140
141
|
if (readsKey !== key) {
|
|
141
142
|
readsKey = key;
|
|
142
143
|
try { readsMap = subPartReadKeys(part, view, params); } catch { readsMap = null; }
|
package/src/framework/mount.js
CHANGED
|
@@ -299,11 +299,24 @@ function createCleanupStack() {
|
|
|
299
299
|
// // message — and calls runtime.annotate.send() itself.
|
|
300
300
|
// // Ignored without onAnnotationSend (there is no toolbar
|
|
301
301
|
// // to place it in).
|
|
302
|
+
// onAssetUpload(blob, { kind, filename }) // the drop widget's (widgets/file-drop.js) upload hook:
|
|
303
|
+
// // async, resolves to a source string (an https: URL or a
|
|
304
|
+
// // host token) that gets written into the param. `blob` is
|
|
305
|
+
// // the CONVERTED artifact — a PNG, a partforge-vector JSON
|
|
306
|
+
// // blob, or the original file for a font — never the user's
|
|
307
|
+
// // raw drop. Omit it and the converted bytes land straight
|
|
308
|
+
// // in the param instead (what the partforge-cloud sandbox
|
|
309
|
+
// // needs, since it cannot fetch URLs — a correct destination,
|
|
310
|
+
// // not a degraded one). A rejection is reported through the
|
|
311
|
+
// // control's own onError; the widget keeps the converted blob
|
|
312
|
+
// // so a retry costs a network call, not a reconvert.
|
|
302
313
|
// Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
|
|
303
314
|
// exactly once here — submodules take element refs and never query the document.
|
|
304
315
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
305
316
|
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
|
|
306
317
|
fontCatalog,
|
|
318
|
+
imageCatalog,
|
|
319
|
+
onAssetUpload,
|
|
307
320
|
viewerState,
|
|
308
321
|
annotateSend = "viewbar",
|
|
309
322
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
@@ -963,7 +976,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
963
976
|
}, onParamsCommit
|
|
964
977
|
? (changed) => onParamsCommit({ changed, params: { ...params } })
|
|
965
978
|
: undefined,
|
|
966
|
-
{ fontCatalog });
|
|
979
|
+
{ fontCatalog, imageCatalog, onAssetUpload });
|
|
967
980
|
cleanup.defer(() => panel.dispose());
|
|
968
981
|
panelRef = panel;
|
|
969
982
|
const updateRelevance = () => {
|