partforge 0.91.0 → 0.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +6 -3
- package/docs/AUTHORING-PARTS.md +184 -1
- package/docs/ERROR-PATTERNS.md +97 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/docs/VECTOR-FORMAT.md +737 -0
- package/package.json +9 -1
- package/src/app-emblem.js +15 -0
- package/src/emblem-worker.js +3 -0
- package/src/framework/asset-resolve.js +5 -4
- package/src/framework/geometry/arc-fit.js +146 -0
- package/src/framework/geometry/contour-offset.js +5 -0
- package/src/framework/geometry/curve-fill.js +57 -7
- package/src/framework/geometry/kernel-front.js +46 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/probe.js +1 -1
- package/src/framework/geometry/stroke-outline.js +119 -0
- package/src/framework/geometry/vector-format.js +334 -0
- package/src/framework/geometry/vector2d.js +96 -0
- package/src/framework/ingest/svg-ingest.js +212 -0
- package/src/framework/jobs.js +11 -0
- package/src/framework/lint/index.js +28 -3
- package/src/framework/lint/rules-vector.js +112 -0
- package/src/framework/mount.js +30 -2
- package/src/framework/pick-flash.js +31 -0
- package/src/framework/selection/pick.js +16 -1
- package/src/framework/vectors.js +170 -0
- package/src/framework/viewer.js +106 -7
- package/src/framework/worker.js +46 -1
- package/src/ingest.js +8 -0
- package/src/parts/assets/emblem.svg +10 -0
- package/src/parts/assets/emblem.vector.json +110 -0
- package/src/parts/assets/plate.vector.json +27 -0
- package/src/parts/emblem.js +102 -0
- package/src/testing/manifold.js +3 -1
- package/src/testing/occt.js +3 -1
- package/types/index.d.ts +22 -0
- package/types/ingest.d.ts +118 -0
- package/types/kernel.d.ts +28 -0
package/src/framework/jobs.js
CHANGED
|
@@ -9,6 +9,7 @@ import { fontsFor, resolveFonts } from "./fonts.js";
|
|
|
9
9
|
import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
|
|
10
10
|
import { ensureImports, resolveImports } from "./imports.js";
|
|
11
11
|
import { safeName } from "./safe-name.js";
|
|
12
|
+
import { ensureVectors } from "./vectors.js";
|
|
12
13
|
import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
13
14
|
|
|
14
15
|
// The oracle loads LAZILY, per job family, never at worker boot. It is the largest
|
|
@@ -226,6 +227,16 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
226
227
|
// lazy-error policy that keeps a STEP import inert until a build actually
|
|
227
228
|
// calls k.import on it.
|
|
228
229
|
if (part.imports) await ensureImports(kernel, part.imports, opts.importMeshes ?? null);
|
|
230
|
+
// Vector art, the third asset family after fonts and imports. Same pre-build
|
|
231
|
+
// timing; ensureVectors owns the prune, so this stays one line. Call it
|
|
232
|
+
// unconditionally, even when this part has no `vectors` at all: ensureVectors
|
|
233
|
+
// treats a nullish declaration as `{}` and its prune loop is what drops
|
|
234
|
+
// names a *previous* part (on a rebound worker) registered — the same
|
|
235
|
+
// stale-registration bug the unconditional fonts prune above exists to
|
|
236
|
+
// prevent. Guarding this on `part.vectors` would skip exactly the case where
|
|
237
|
+
// pruning matters most: a worker rebound from a part WITH artwork to one
|
|
238
|
+
// WITHOUT would leave the old names resolvable forever.
|
|
239
|
+
await ensureVectors(kernel, part.vectors);
|
|
229
240
|
// Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
|
|
230
241
|
const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
|
|
231
242
|
// Explicit selection (headless exportParts) overrides view-derived selection.
|
|
@@ -18,8 +18,9 @@ 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
20
|
import { SOURCE_RULES } from "./rules-source.js";
|
|
21
|
+
import { VECTOR_RULES } from "./rules-vector.js";
|
|
21
22
|
|
|
22
|
-
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...SOURCE_RULES];
|
|
23
|
+
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
|
|
23
24
|
|
|
24
25
|
// A usable sources input, or null. Deliberately forgiving: lintPart's callers
|
|
25
26
|
// include hosted paths handing over user/LLM-authored trees, so a malformed
|
|
@@ -44,6 +45,24 @@ function normalizeSources(sources) {
|
|
|
44
45
|
return { files, entrypoint };
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// The caller's parsed vector files, or null. Deliberately forgiving for the same
|
|
49
|
+
// reason normalizeSources is: hosted callers hand over user- and agent-authored
|
|
50
|
+
// trees, and a malformed input must mean "no document-dependent findings",
|
|
51
|
+
// never a throw. Lint itself never reads a file — it is pure and synchronous by
|
|
52
|
+
// contract (see this file's header); the caller does the I/O and passes the
|
|
53
|
+
// result in.
|
|
54
|
+
function normalizeVectorDocs(docs) {
|
|
55
|
+
if (!docs || typeof docs !== "object") return null;
|
|
56
|
+
const out = Object.create(null);
|
|
57
|
+
let any = false;
|
|
58
|
+
for (const [name, doc] of Object.entries(docs)) {
|
|
59
|
+
if (!doc || typeof doc !== "object") continue;
|
|
60
|
+
out[name] = doc;
|
|
61
|
+
any = true;
|
|
62
|
+
}
|
|
63
|
+
return any ? out : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
// Every rule runs inside a guard. lintPart is called on a user-facing hosted path
|
|
48
67
|
// (partforge-cloud's sandbox), and a linter that takes down the preview it exists to
|
|
49
68
|
// protect is worse than no linter — so a throwing rule becomes a WARNING, never an
|
|
@@ -98,9 +117,12 @@ export function lintContext(part, params) {
|
|
|
98
117
|
/**
|
|
99
118
|
* Lint a PartDefinition. Never throws.
|
|
100
119
|
* @param {object} part the default-exported PartDefinition
|
|
101
|
-
* @param {{params?: object, sources?: {files?: Record<string, string>, entrypoint?: string}}} [opts]
|
|
120
|
+
* @param {{params?: object, sources?: {files?: Record<string, string>, entrypoint?: string}, vectorDocs?: Record<string, object>}} [opts]
|
|
102
121
|
* `params` are layered over part.defaults for the probe pass; `sources` is the part's own
|
|
103
122
|
* source text, which unlocks the source rules (Group 9) — omit it and lint behaves as before.
|
|
123
|
+
* `vectorDocs` is `{ name: parsedDocument }`, the raw parsed JSON of the part's declared
|
|
124
|
+
* vector files (see vectors.js's resolveVectorDocs) — omit it and the document-dependent
|
|
125
|
+
* vector rules (vector-size-missing, vector-unknown-shape) stay silent rather than guess.
|
|
104
126
|
* @returns {{ok: boolean, errors: object[], warnings: object[], notes: object[]}}
|
|
105
127
|
*/
|
|
106
128
|
export function lintPart(part, opts) {
|
|
@@ -108,7 +130,7 @@ export function lintPart(part, opts) {
|
|
|
108
130
|
// parameter only fires on `undefined` — a caller passing `lintPart(part, null)`
|
|
109
131
|
// (a plausible downstream-harness call) would otherwise throw destructuring
|
|
110
132
|
// `{ params }` out of `null` before this function's body ever runs.
|
|
111
|
-
const { params, sources } = opts ?? {};
|
|
133
|
+
const { params, sources, vectorDocs } = opts ?? {};
|
|
112
134
|
// lintContext already guards its own internals (see its comment above), but it
|
|
113
135
|
// is user-authored data all the way down — wrap the call itself too, so a
|
|
114
136
|
// failure mode neither of us has thought of still degrades to a report instead
|
|
@@ -136,6 +158,9 @@ export function lintPart(part, opts) {
|
|
|
136
158
|
// host filtering source findings out to keep them non-blocking would instead
|
|
137
159
|
// refuse to render a part that builds fine.
|
|
138
160
|
try { ctx.sources = normalizeSources(sources); } catch { ctx.sources = null; }
|
|
161
|
+
// Same deliberate own-guard as sources above: a malformed vectorDocs input
|
|
162
|
+
// means "the document-dependent vector rules stay quiet", never a broken part.
|
|
163
|
+
try { ctx.vectorDocs = normalizeVectorDocs(vectorDocs); } catch { ctx.vectorDocs = null; }
|
|
139
164
|
const findings = runRules(RULES, ctx);
|
|
140
165
|
// `p` (params merged from `defaults`) failed to build — every rule still ran
|
|
141
166
|
// against the `{}` fallback (each guarded individually by runRules), but the
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Group 10 — vector-art call well-formedness. All three conditions throw at
|
|
2
|
+
// build time anyway; these rules move them ahead of the kernel boot, which is
|
|
3
|
+
// where an authoring agent wants them.
|
|
4
|
+
//
|
|
5
|
+
// All read `probe().calls`, whose `args` are JSON.stringify of the RESOLVED
|
|
6
|
+
// argument values under the part's default params (probe.js's `describe`), not
|
|
7
|
+
// source text. So these judge what the part actually builds by default — the
|
|
8
|
+
// same basis rules-imports.js's import-unknown-name already uses. A call that
|
|
9
|
+
// only goes wrong for non-default params is not caught here and still fails
|
|
10
|
+
// correctly at build time; this catches the common case early, it does not
|
|
11
|
+
// replace that authority.
|
|
12
|
+
//
|
|
13
|
+
// vector-size-missing and vector-unknown-shape additionally need `ctx.vectorDocs`
|
|
14
|
+
// — the caller's parsed vector files (see lint/index.js's normalizeVectorDocs,
|
|
15
|
+
// vectors.js's resolveVectorDocs). Lint itself never reads a file (this
|
|
16
|
+
// package's header), so without a supplied document neither rule can tell
|
|
17
|
+
// units from shapes and both stay silent rather than guess.
|
|
18
|
+
import { err } from "./finding.js";
|
|
19
|
+
|
|
20
|
+
const declaredVectors = (part) => Object.keys(part?.vectors ?? {});
|
|
21
|
+
|
|
22
|
+
// The name arrives JSON-serialized, so JSON.parse recovers it — and yields null
|
|
23
|
+
// for anything that is not a string (import-unknown-name reads its name the same way).
|
|
24
|
+
const literalName = (src) => {
|
|
25
|
+
try { const v = JSON.parse(src); return typeof v === "string" ? v : null; } catch { return null; }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Probe args are JSON-serialized resolved VALUES (probe.js's `describe`), not
|
|
29
|
+
// source text — so an options object arrives as `{"width":10,"shape":"body"}`.
|
|
30
|
+
// Parsing it back is exact for the literal cases these rules judge; anything
|
|
31
|
+
// that does not parse to a plain object means "cannot tell", and the rule stays
|
|
32
|
+
// quiet rather than guessing. A missing argument (`src == null`) is not
|
|
33
|
+
// malformed — it means "no options object" — so it reads as `{}`, not "unknown".
|
|
34
|
+
const optsOf = (src) => {
|
|
35
|
+
if (src == null) return {};
|
|
36
|
+
try { const v = JSON.parse(src); return v && typeof v === "object" && !Array.isArray(v) ? v : null; } catch { return null; }
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const vectorCalls = (probe) => probe().calls.filter((c) => c.scope === "kernel" && c.op === "vector2d");
|
|
40
|
+
|
|
41
|
+
export const VECTOR_RULES = [
|
|
42
|
+
{
|
|
43
|
+
id: "vector-unknown-name",
|
|
44
|
+
run: ({ part, probe }) => {
|
|
45
|
+
const known = new Set(declaredVectors(part));
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const call of vectorCalls(probe)) {
|
|
49
|
+
const name = literalName(call.args[0]);
|
|
50
|
+
if (name == null || known.has(name) || seen.has(name)) continue;
|
|
51
|
+
seen.add(name);
|
|
52
|
+
out.push(err("vector-unknown-name",
|
|
53
|
+
`build calls k.vector2d with name "${name}", which the part's vectors field does not declare: ${[...known].join(", ") || "(nothing)"}`,
|
|
54
|
+
"Declare the ingested artwork under vectors: { name: source }, or fix the name to match an existing entry.",
|
|
55
|
+
"vectors"));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "vector-size-missing",
|
|
62
|
+
run: ({ probe, vectorDocs }) => {
|
|
63
|
+
if (!vectorDocs) return []; // caller supplied nothing — cannot judge units
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const call of vectorCalls(probe)) {
|
|
66
|
+
const name = literalName(call.args[0]);
|
|
67
|
+
if (name == null) continue;
|
|
68
|
+
const doc = Object.hasOwn(vectorDocs, name) ? vectorDocs[name] : null;
|
|
69
|
+
if (doc?.units !== "artwork") continue; // mm files place as authored; a size is optional
|
|
70
|
+
const opts = optsOf(call.args[1]);
|
|
71
|
+
if (opts == null) continue;
|
|
72
|
+
if (opts.width != null || opts.height != null || opts.fit != null) continue;
|
|
73
|
+
out.push(err("vector-size-missing",
|
|
74
|
+
`k.vector2d("${name}", …) declares no size, and "${name}" has units "artwork" — one of { width }, { height }, or { fit } is required, in millimetres`,
|
|
75
|
+
"Artwork units have no physical meaning, so there is no safe default to fall back on (unlike k.text2d's cap-height `size`). "
|
|
76
|
+
+ `Add one, e.g. k.vector2d("${name}", { width: 20 }) — or re-author the file with "units": "mm" if its coordinates really are millimetres.`,
|
|
77
|
+
"build"));
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "vector-unknown-shape",
|
|
84
|
+
run: ({ probe, vectorDocs }) => {
|
|
85
|
+
if (!vectorDocs) return [];
|
|
86
|
+
// Keyed by JSON.stringify([name, shape]) — a JSON array literal cannot be
|
|
87
|
+
// produced by any other (name, shape) pairing, so two distinct pairings can
|
|
88
|
+
// never collide even though both halves are arbitrary author-chosen strings.
|
|
89
|
+
// Same pairing repeated across calls reports once; a DIFFERENT bad shape
|
|
90
|
+
// name on the same vector still reports separately.
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const call of vectorCalls(probe)) {
|
|
94
|
+
const name = literalName(call.args[0]);
|
|
95
|
+
const opts = optsOf(call.args[1]);
|
|
96
|
+
if (name == null || opts == null || typeof opts.shape !== "string") continue;
|
|
97
|
+
const key = JSON.stringify([name, opts.shape]);
|
|
98
|
+
if (seen.has(key)) continue;
|
|
99
|
+
const doc = Object.hasOwn(vectorDocs, name) ? vectorDocs[name] : null;
|
|
100
|
+
const shapes = doc?.shapes;
|
|
101
|
+
if (!shapes || typeof shapes !== "object" || Array.isArray(shapes)) continue;
|
|
102
|
+
if (Object.hasOwn(shapes, opts.shape)) continue;
|
|
103
|
+
seen.add(key);
|
|
104
|
+
out.push(err("vector-unknown-shape",
|
|
105
|
+
`k.vector2d("${name}", { shape: "${opts.shape}" }) names a shape "${opts.shape}" that "${name}" does not contain: ${Object.keys(shapes).join(", ") || "(none)"}`,
|
|
106
|
+
"Fix the shape name to match one the file declares, or omit `shape` to use the role-composed result — every \"add\" shape unioned, minus every \"subtract\" shape.",
|
|
107
|
+
"build"));
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
];
|
package/src/framework/mount.js
CHANGED
|
@@ -61,7 +61,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
|
|
|
61
61
|
// carries the worker's own error text. See the correlated "error" case below.
|
|
62
62
|
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
63
63
|
|
|
64
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection }) {
|
|
64
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
|
|
65
65
|
return {
|
|
66
66
|
ready, dispose, setParams,
|
|
67
67
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -123,6 +123,16 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
123
123
|
set: () => {},
|
|
124
124
|
onChange: () => () => {},
|
|
125
125
|
},
|
|
126
|
+
// The pick marker as a thing with a lifetime, for a host that hangs its own
|
|
127
|
+
// UI off it: hold() keeps the newest marker on screen (earlier held ones
|
|
128
|
+
// stay held), release() clears them all, and onAnchorChange reports where
|
|
129
|
+
// the newest one is on the canvas as the camera moves. Same shape
|
|
130
|
+
// convention as `measure`, `annotate` and `projection`.
|
|
131
|
+
pickMarker: pickMarker ?? {
|
|
132
|
+
hold: () => false,
|
|
133
|
+
release: () => {},
|
|
134
|
+
onAnchorChange: () => () => {},
|
|
135
|
+
},
|
|
126
136
|
};
|
|
127
137
|
}
|
|
128
138
|
|
|
@@ -188,6 +198,15 @@ function createCleanupStack() {
|
|
|
188
198
|
// // { sync, hide, detach } — call sync() after you
|
|
189
199
|
// // toggle a button's disabled state. Detached
|
|
190
200
|
// // automatically on dispose().
|
|
201
|
+
// runtime.pickMarker.hold(); // keep the marker from the last pick on screen
|
|
202
|
+
// // (earlier held markers stay held), and start
|
|
203
|
+
// // reporting where it is. False when there is
|
|
204
|
+
// // nothing to hold — a marker's flash has a
|
|
205
|
+
// // lifetime, so hold promptly after a pick.
|
|
206
|
+
// runtime.pickMarker.onAnchorChange((a) => …); // {x, y, visible} in CSS px from the
|
|
207
|
+
// // canvas's top-left, as the camera moves; null when
|
|
208
|
+
// // nothing is held. Returns an unsubscribe.
|
|
209
|
+
// runtime.pickMarker.release(); // clear every held marker
|
|
191
210
|
// runtime.setActive(false); // park the viewer: stop the render loop and release
|
|
192
211
|
// // both large GPU allocations (the drawing buffer and
|
|
193
212
|
// // the cached capture target). For a host that hides the
|
|
@@ -580,11 +599,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
580
599
|
// deliberately not guarded — one is armed by an explicit dev toggle,
|
|
581
600
|
// the other per agent request.
|
|
582
601
|
suppressed: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
|
|
583
|
-
onPick: (selection) => onPick({
|
|
602
|
+
onPick: (selection, anchor) => onPick({
|
|
584
603
|
selection,
|
|
585
604
|
label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
|
|
586
605
|
prompt: formatSelection(selection, { style: "prompt" }),
|
|
587
606
|
token: formatSelection(selection, { style: "token" }),
|
|
607
|
+
// Where the marker is on the canvas, in CSS px from its top-left, so
|
|
608
|
+
// a host can put its own UI beside the dot on the first frame rather
|
|
609
|
+
// than a round trip later.
|
|
610
|
+
anchor,
|
|
588
611
|
}),
|
|
589
612
|
});
|
|
590
613
|
cleanup.defer(() => picker.detach());
|
|
@@ -1098,6 +1121,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
1098
1121
|
set: (mode) => viewer.setProjection(mode),
|
|
1099
1122
|
onChange: (cb) => viewer.onProjectionChange(cb),
|
|
1100
1123
|
},
|
|
1124
|
+
pickMarker: {
|
|
1125
|
+
hold: () => viewer.holdFlashPoint(),
|
|
1126
|
+
release: () => viewer.releaseFlashPoints(),
|
|
1127
|
+
onAnchorChange: (cb) => viewer.onFlashAnchorChange(cb),
|
|
1128
|
+
},
|
|
1101
1129
|
});
|
|
1102
1130
|
} catch (error) {
|
|
1103
1131
|
try {
|
|
@@ -42,3 +42,34 @@ export function flashWorldRadius(
|
|
|
42
42
|
const radius = worldPerPixel(camera, worldPoint, viewportHeightPx) * pixelRadius;
|
|
43
43
|
return Number.isFinite(radius) && radius > MIN_RADIUS ? radius : MIN_RADIUS;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
const _projected = new THREE.Vector3();
|
|
47
|
+
|
|
48
|
+
// Where a world point lands on the canvas, in CSS px from its top-left.
|
|
49
|
+
//
|
|
50
|
+
// `visible` is the answer to "is the marker actually on screen", which is two
|
|
51
|
+
// questions: is it in FRONT of the camera (a point behind a perspective eye
|
|
52
|
+
// projects to a mirrored position with no warning — `ndc.z > 1` is what
|
|
53
|
+
// catches it), and is it inside the canvas. The position is reported either
|
|
54
|
+
// way: an off-screen anchor is information, and the caller decides what to do
|
|
55
|
+
// with it.
|
|
56
|
+
export function projectToScreen(camera, worldPoint, width, height) {
|
|
57
|
+
_projected.copy(worldPoint).project(camera);
|
|
58
|
+
const x = (_projected.x * 0.5 + 0.5) * width;
|
|
59
|
+
// NDC y grows upward, screen y downward — the flip is the whole reason this
|
|
60
|
+
// is a named function rather than two lines at each call site.
|
|
61
|
+
const y = (0.5 - _projected.y * 0.5) * height;
|
|
62
|
+
const inFront = _projected.z >= -1 && _projected.z <= 1;
|
|
63
|
+
const onCanvas = x >= 0 && x <= width && y >= 0 && y <= height;
|
|
64
|
+
return { x, y, visible: inFront && onCanvas };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Is `next` different enough from `previous` to be worth telling anyone about?
|
|
68
|
+
// A still camera re-projects to the same pixel every frame, and publishing that
|
|
69
|
+
// 60 times a second across a postMessage boundary is pure noise.
|
|
70
|
+
export function anchorMoved(previous, next, epsilon = 0.5) {
|
|
71
|
+
if (!previous || !next) return previous !== next;
|
|
72
|
+
return previous.visible !== next.visible
|
|
73
|
+
|| Math.abs(previous.x - next.x) >= epsilon
|
|
74
|
+
|| Math.abs(previous.y - next.y) >= epsilon;
|
|
75
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { raycastViewer, worldToSubPartLocal } from "./raycast.js";
|
|
4
4
|
import { resolveSelection } from "./resolve.js";
|
|
5
5
|
import { createDragTracker } from "./drag-tracker.js";
|
|
6
|
+
import { projectToScreen } from "../pick-flash.js";
|
|
6
7
|
|
|
7
8
|
export { worldToSubPartLocal };
|
|
8
9
|
|
|
@@ -10,6 +11,8 @@ export { worldToSubPartLocal };
|
|
|
10
11
|
// whose suppression condition lives elsewhere (mount passes measure mode's
|
|
11
12
|
// isEnabled): while it returns true a click neither raycasts, flashes, nor
|
|
12
13
|
// picks — no resync bookkeeping the way an event-driven setActive would need.
|
|
14
|
+
// onPick receives (selection, anchor): where the marker this click flashed
|
|
15
|
+
// landed on the canvas, in CSS px from its top-left.
|
|
13
16
|
export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
|
|
14
17
|
let active = false;
|
|
15
18
|
const drag = createDragTracker();
|
|
@@ -25,7 +28,19 @@ export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
|
|
|
25
28
|
if (!hit) return;
|
|
26
29
|
const selection = resolveSelection(part, getContext(), hit);
|
|
27
30
|
viewer.flashPoint([hit.pointWorld.x, hit.pointWorld.y, hit.pointWorld.z]);
|
|
28
|
-
|
|
31
|
+
// The anchor is the MARKER's projection, not the pointer's position, even
|
|
32
|
+
// though the two coincide at this instant: the host's follow-the-camera
|
|
33
|
+
// stream projects the same world point through the same function every
|
|
34
|
+
// frame after, so the first answer is of a piece with the rest.
|
|
35
|
+
// Sized from the rect the raycast just used, which is what makes this
|
|
36
|
+
// anchor land back on the pixel the user clicked. The stream sizes from the
|
|
37
|
+
// renderer's last setSize instead — also CSS px, but the CONTAINER's
|
|
38
|
+
// integer clientWidth/Height as of the last ResizeObserver call, and 1x1
|
|
39
|
+
// while the viewer is parked. They agree in steady state; the divergence is
|
|
40
|
+
// staleness, never units.
|
|
41
|
+
const rect = viewer.domElement.getBoundingClientRect();
|
|
42
|
+
const anchor = projectToScreen(viewer.camera, hit.pointWorld, rect.width, rect.height);
|
|
43
|
+
onPick(selection, { x: anchor.x, y: anchor.y });
|
|
29
44
|
}
|
|
30
45
|
|
|
31
46
|
viewer.domElement.addEventListener("pointerdown", drag.onDown);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Resolve a part's declared `vectors` ({ name: source }) to internal regions before
|
|
2
|
+
// the synchronous build — the vector-art sibling of fonts.js and imports.js:
|
|
3
|
+
// same source grammar and identity-memoization rule, built on the shared core in
|
|
4
|
+
// asset-resolve.js. The source resolves to JSON in the partforge-vector format,
|
|
5
|
+
// not to SVG; conversion happened once, in a browser, at ingest.
|
|
6
|
+
//
|
|
7
|
+
// No content digest, deliberately. It looks like a missing piece next to
|
|
8
|
+
// imports.js and is not: k.vector2d lowers to k.shape2d(regions) and the Shape2D
|
|
9
|
+
// hash keys on the actual coordinates, so different artwork gives a different
|
|
10
|
+
// cache entry automatically. Imports need a digest because a Solid master is
|
|
11
|
+
// registered by NAME and is opaque to that hash; parsed regions are not. Same
|
|
12
|
+
// argument kernel-front.js:117-121 records for text2d.
|
|
13
|
+
//
|
|
14
|
+
// DOM-free and node:-free.
|
|
15
|
+
import { makeAssetResolver, resolveDecl } from "./asset-resolve.js";
|
|
16
|
+
import { toInternalDocument } from "./geometry/vector-format.js";
|
|
17
|
+
|
|
18
|
+
const cache = new Map(); // source → Promise<Uint8Array> (raw bytes)
|
|
19
|
+
// source → { units, shapes }. The bytes memo above stops a refetch; this stops a re-PARSE
|
|
20
|
+
// (UTF-8 decode + JSON.parse + validation + a bbox recomputation that tessellates
|
|
21
|
+
// every contour). Without it every regen of a part with artwork redoes all of that.
|
|
22
|
+
// Fonts solve the same problem with kernel._fontsBySource (jobs.js:206-212) and
|
|
23
|
+
// imports with a digest comparison; this is the third pipeline's version of it.
|
|
24
|
+
// Keyed on the SOURCE, not the name: one worker outlives many parts, and a name is
|
|
25
|
+
// not an identity. Only successes are cached — a parse failure must throw again
|
|
26
|
+
// under the next name that declares it, with that name in the message.
|
|
27
|
+
const parsed = new Map();
|
|
28
|
+
|
|
29
|
+
function parseDocument(bytes, label) {
|
|
30
|
+
let text;
|
|
31
|
+
try { text = new TextDecoder().decode(bytes); }
|
|
32
|
+
catch { throw new Error(`vector2d: "${label}" could not be decoded as UTF-8 text`); }
|
|
33
|
+
let doc;
|
|
34
|
+
try { doc = JSON.parse(text); }
|
|
35
|
+
catch (e) {
|
|
36
|
+
throw new Error(`vector2d: "${label}" is not valid JSON — ${e.message}. `
|
|
37
|
+
+ "A vectors source is an ingested partforge-vector file, not an .svg file; see docs/VECTOR-FORMAT.md");
|
|
38
|
+
}
|
|
39
|
+
return toInternalDocument(doc, label);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// source -> resolved bytes, recorded only once a source has SUCCESSFULLY resolved.
|
|
43
|
+
// `cache` above cannot answer that question: makeAssetResolver stores the promise
|
|
44
|
+
// synchronously, so `cache.has(source)` is true the instant a fetch starts and
|
|
45
|
+
// stays true if it never finishes. This map is what `cachedVectorDocs` reads, and
|
|
46
|
+
// its whole point is that membership means "the bytes are here, now".
|
|
47
|
+
const bytesBySource = new Map();
|
|
48
|
+
// source -> raw parsed JSON, or null if it is not a JSON object. Filled LAZILY by
|
|
49
|
+
// cachedVectorDocs rather than at resolve time, so the build path never pays a
|
|
50
|
+
// JSON.parse for lint's benefit, and lint never re-parses the same file twice.
|
|
51
|
+
const rawBySource = new Map();
|
|
52
|
+
|
|
53
|
+
// The resolver memoizes by source identity and cannot see the declared name, so
|
|
54
|
+
// the name is bound per declaration below rather than baked into the resolver.
|
|
55
|
+
const resolveOne = makeAssetResolver(
|
|
56
|
+
cache,
|
|
57
|
+
(bytes, _value, source) => { bytesBySource.set(source, bytes); return bytes; },
|
|
58
|
+
"resolveVectors: a vector source must be bytes, a URL, or a thunk returning one",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export async function resolveVectors(vectorsDecl) {
|
|
62
|
+
// A function reaching here means a caller passed `part.vectors` raw, the way
|
|
63
|
+
// fonts.js's resolveFonts guards against the same mistake for `part.fonts`.
|
|
64
|
+
// `Object.entries` on a function is `[]`, not a thrown error, so without
|
|
65
|
+
// this check a function-valued `vectors` would silently resolve to an empty
|
|
66
|
+
// map and only surface much later as `vector2d: unknown vector "…"` — a name a
|
|
67
|
+
// part author declared correctly, that k.vector2d insists doesn't exist. No
|
|
68
|
+
// part currently declares `vectors` as a function (unlike fonts, which a
|
|
69
|
+
// `type: "font"` control already drives this way) — this exists so the
|
|
70
|
+
// day that form is added, it fails loudly instead of silently.
|
|
71
|
+
if (typeof vectorsDecl === "function") {
|
|
72
|
+
throw new Error("resolveVectors: `vectors` is a function — it is not resolved against params yet; pass the plain object form");
|
|
73
|
+
}
|
|
74
|
+
const raw = await resolveDecl(vectorsDecl, resolveOne);
|
|
75
|
+
const out = new Map();
|
|
76
|
+
for (const [name, bytes] of raw) {
|
|
77
|
+
let doc = parsed.get(bytes);
|
|
78
|
+
if (!doc) { doc = parseDocument(bytes, name); parsed.set(bytes, doc); }
|
|
79
|
+
out.set(name, doc);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The RAW parsed JSON, before validation or conversion — what lint's
|
|
85
|
+
// document-dependent rules need to read `units` and `shapes`. Never throws: a
|
|
86
|
+
// source that will not fetch, decode, or parse (to a JSON object) maps to
|
|
87
|
+
// null, and the rules that depend on it stay quiet for that name only —
|
|
88
|
+
// resolution is PER-SOURCE, not all-or-nothing, so one broken vector among
|
|
89
|
+
// several does not silence document-aware lint for its siblings. Deliberately
|
|
90
|
+
// calls `resolveOne` itself rather than going through `resolveDecl` (whose
|
|
91
|
+
// `Promise.all` rejects the whole batch on a single failure) — this still
|
|
92
|
+
// shares `resolveOne`'s bytes memo with resolveVectors, so running `lint`
|
|
93
|
+
// ahead of `measure` (the CLI does both) costs no extra fetch. It does its own
|
|
94
|
+
// decode/parse rather than reusing the `parsed` memo, which holds the
|
|
95
|
+
// VALIDATED/converted document, not the raw JSON this needs.
|
|
96
|
+
export async function resolveVectorDocs(vectorsDecl) {
|
|
97
|
+
if (typeof vectorsDecl === "function") return new Map();
|
|
98
|
+
const decl = vectorsDecl ?? {};
|
|
99
|
+
const out = new Map();
|
|
100
|
+
await Promise.all(Object.entries(decl).map(async ([name, source]) => {
|
|
101
|
+
try {
|
|
102
|
+
const bytes = await resolveOne(source);
|
|
103
|
+
const doc = JSON.parse(new TextDecoder().decode(bytes));
|
|
104
|
+
out.set(name, doc && typeof doc === "object" ? doc : null);
|
|
105
|
+
} catch {
|
|
106
|
+
out.set(name, null);
|
|
107
|
+
}
|
|
108
|
+
}));
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The SYNCHRONOUS, FETCH-FREE sibling of resolveVectorDocs: the raw parsed JSON
|
|
113
|
+
// for every declared vector whose bytes are ALREADY resolved, and nothing for the
|
|
114
|
+
// rest. Never fetches, never awaits, never throws.
|
|
115
|
+
//
|
|
116
|
+
// This exists because lint is instant and offline BY CONSTRUCTION — that is the
|
|
117
|
+
// property that lets a browser sandbox run it on every keystroke — and calling
|
|
118
|
+
// the async resolver from the lint path quietly gave that away: a slow or hanging
|
|
119
|
+
// vector URL (asset-resolve.js's fetch has no timeout) would stall the lint reply
|
|
120
|
+
// forever, and a throwing `vectors` getter would reject in a floating promise,
|
|
121
|
+
// which surfaces as `unhandledrejection` rather than an `error` event, so a host
|
|
122
|
+
// waiting on a lint-report simply waits.
|
|
123
|
+
//
|
|
124
|
+
// The degradation is the one the two document-dependent rules are designed for:
|
|
125
|
+
// no document, no finding. In a hosted sandbox a build has almost always run
|
|
126
|
+
// first, so the bytes are already in the memo and both rules light up; before the
|
|
127
|
+
// first build they stay silent, exactly as they do today for a caller that passes
|
|
128
|
+
// no vectorDocs at all. Nothing regresses, and lint cannot become slower or
|
|
129
|
+
// hangable than it was before vectors existed.
|
|
130
|
+
//
|
|
131
|
+
// `decl` is caller data all the way down (a throwing getter, a Proxy whose
|
|
132
|
+
// ownKeys trap throws), so every step that touches it is guarded per-name and the
|
|
133
|
+
// whole walk is guarded once.
|
|
134
|
+
export function cachedVectorDocs(vectorsDecl) {
|
|
135
|
+
const out = new Map();
|
|
136
|
+
if (!vectorsDecl || typeof vectorsDecl !== "object") return out;
|
|
137
|
+
let entries;
|
|
138
|
+
try { entries = Object.entries(vectorsDecl); } catch { return out; }
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
try {
|
|
141
|
+
const [name, source] = entry;
|
|
142
|
+
if (!bytesBySource.has(source)) continue; // not resolved yet — stay silent
|
|
143
|
+
if (!rawBySource.has(source)) {
|
|
144
|
+
let doc = null;
|
|
145
|
+
try {
|
|
146
|
+
const parsedJson = JSON.parse(new TextDecoder().decode(bytesBySource.get(source)));
|
|
147
|
+
doc = parsedJson && typeof parsedJson === "object" ? parsedJson : null;
|
|
148
|
+
} catch { doc = null; } // not JSON, or not decodable
|
|
149
|
+
rawBySource.set(source, doc);
|
|
150
|
+
}
|
|
151
|
+
out.set(name, rawBySource.get(source));
|
|
152
|
+
} catch { /* one hostile entry silences itself, not its siblings */ }
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Register a part's vectors on a booted kernel. Called in the async phase before
|
|
158
|
+
// every job's synchronous build — worker (jobs.js) and Node boots alike.
|
|
159
|
+
export async function ensureVectors(kernel, vectorsDecl) {
|
|
160
|
+
if (!kernel?._vectors) return;
|
|
161
|
+
const declared = vectorsDecl ?? {};
|
|
162
|
+
for (const [name, doc] of await resolveVectors(declared)) kernel._vectors.set(name, doc);
|
|
163
|
+
// Drop names this declaration does not supply. `_vectors` is the kernel's and the
|
|
164
|
+
// kernel outlives the job (worker-rebind, many parts), so without this a name
|
|
165
|
+
// from a previous part stays resolvable — the stale-registration bug jobs.js's
|
|
166
|
+
// font prune exists to prevent.
|
|
167
|
+
for (const name of [...kernel._vectors.keys()]) {
|
|
168
|
+
if (!Object.hasOwn(declared, name)) kernel._vectors.delete(name);
|
|
169
|
+
}
|
|
170
|
+
}
|