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/viewer.js
CHANGED
|
@@ -6,7 +6,7 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
|
|
|
6
6
|
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
7
7
|
import { createCutaway } from "./cutaway.js";
|
|
8
8
|
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
|
|
9
|
-
import { flashWorldRadius } from "./pick-flash.js";
|
|
9
|
+
import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js";
|
|
10
10
|
import { createCameraTween } from "./camera-tween.js";
|
|
11
11
|
import { orbitPose } from "./camera-orbit.js";
|
|
12
12
|
import { orthoFrustum, perspectiveDistance } from "./projection.js";
|
|
@@ -871,6 +871,17 @@ export function createViewer(container, part) {
|
|
|
871
871
|
liveLights.key.visible = false;
|
|
872
872
|
liveLights.fill.visible = false;
|
|
873
873
|
scene.add(capKey, capKey.target, capFill, capFill.target);
|
|
874
|
+
// A pick marker is transient UI feedback about a click, never part of the
|
|
875
|
+
// part, so it belongs in no capture. It used to be near enough true that a
|
|
876
|
+
// capture would miss one — a dot faded after 1200ms — but a HELD dot lives
|
|
877
|
+
// as long as the host's UI hangs off it, so any capture taken during a pick
|
|
878
|
+
// would now bake the yellow sphere into an agent-facing render or a
|
|
879
|
+
// published thumbnail. Hidden HERE, at the one offscreen chokepoint, rather
|
|
880
|
+
// than via canonicalCaptureHidden: captureCurrent deliberately ignores that
|
|
881
|
+
// set. Only dots that were actually shown are restored, so this cannot
|
|
882
|
+
// resurrect one the live canvas had hidden for its own reasons.
|
|
883
|
+
const reshowFlashDots = [];
|
|
884
|
+
for (const dot of flashDots) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
|
|
874
885
|
try {
|
|
875
886
|
renderer.setRenderTarget(rt);
|
|
876
887
|
renderer.render(renderScene, cam);
|
|
@@ -878,11 +889,13 @@ export function createViewer(container, part) {
|
|
|
878
889
|
// reads antialiased pixels.
|
|
879
890
|
renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
|
|
880
891
|
} finally {
|
|
881
|
-
// Never leave the user's own view unlit
|
|
892
|
+
// Never leave the user's own view unlit, pointed at the offscreen target,
|
|
893
|
+
// or missing a marker the user is still looking at.
|
|
882
894
|
renderer.setRenderTarget(null);
|
|
883
895
|
scene.remove(capKey, capKey.target, capFill, capFill.target);
|
|
884
896
|
liveLights.key.visible = true;
|
|
885
897
|
liveLights.fill.visible = true;
|
|
898
|
+
for (const dot of reshowFlashDots) dot.visible = true;
|
|
886
899
|
if (!cachedSize) rt.dispose();
|
|
887
900
|
}
|
|
888
901
|
const canvas = document.createElement("canvas");
|
|
@@ -1055,6 +1068,12 @@ export function createViewer(container, part) {
|
|
|
1055
1068
|
// a dot is only alive for about a second, but orbiting or zooming inside
|
|
1056
1069
|
// that second must not resize it.
|
|
1057
1070
|
for (const dot of flashDots) scaleFlashDot(dot);
|
|
1071
|
+
// …and re-project the held one, so a host anchored to it follows the
|
|
1072
|
+
// camera. Change-gated: a still camera publishes nothing.
|
|
1073
|
+
if (anchorDot) {
|
|
1074
|
+
const next = projectPoint([anchorDot.position.x, anchorDot.position.y, anchorDot.position.z]);
|
|
1075
|
+
if (anchorMoved(lastAnchor, next)) publishAnchor(next);
|
|
1076
|
+
}
|
|
1058
1077
|
renderer.render(scene, activeCamera);
|
|
1059
1078
|
cutaway.renderOverlay(renderer, activeCamera);
|
|
1060
1079
|
}
|
|
@@ -1157,15 +1176,48 @@ export function createViewer(container, part) {
|
|
|
1157
1176
|
//
|
|
1158
1177
|
// The sphere is a UNIT sphere scaled per frame (see below) so it reads as a
|
|
1159
1178
|
// constant handful of CSS pixels instead of a fixed millimetre size.
|
|
1179
|
+
const _flashWorld = new THREE.Vector3();
|
|
1160
1180
|
const FLASH_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
|
|
1161
1181
|
const flashTimers = new Set();
|
|
1162
1182
|
const flashDots = new Set();
|
|
1183
|
+
// The subset that will not fade. A HELD marker outlives the pick that made
|
|
1184
|
+
// it, because the host has hung something off it — see holdFlashPoint.
|
|
1185
|
+
const heldDots = new Set();
|
|
1186
|
+
let lastFlashed = null; // the newest marker, which is what hold() holds
|
|
1187
|
+
let anchorDot = null; // the held marker the anchor stream follows
|
|
1188
|
+
let lastAnchor = null;
|
|
1189
|
+
const anchorListeners = new Set();
|
|
1163
1190
|
const flashGeometry = new THREE.SphereGeometry(1, 16, 12);
|
|
1164
1191
|
const flashViewport = new THREE.Vector2();
|
|
1192
|
+
|
|
1165
1193
|
function scaleFlashDot(dot) {
|
|
1166
1194
|
renderer.getSize(flashViewport); // CSS px, which is what a pixel radius means
|
|
1167
1195
|
dot.scale.setScalar(flashWorldRadius(activeCamera, dot.position, flashViewport.y));
|
|
1168
1196
|
}
|
|
1197
|
+
|
|
1198
|
+
function projectPoint(world) {
|
|
1199
|
+
renderer.getSize(flashViewport);
|
|
1200
|
+
_flashWorld.set(world[0], world[1], world[2]);
|
|
1201
|
+
return projectToScreen(activeCamera, _flashWorld, flashViewport.x, flashViewport.y);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function publishAnchor(anchor) {
|
|
1205
|
+
lastAnchor = anchor;
|
|
1206
|
+
// A throwing subscriber must not stop the render loop or the other
|
|
1207
|
+
// subscribers — same containment the frame listeners get.
|
|
1208
|
+
for (const cb of [...anchorListeners]) {
|
|
1209
|
+
try { cb(anchor); } catch (e) { console.warn("partforge: anchor listener failed", e); }
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function dropFlashDot(dot) {
|
|
1214
|
+
scene.remove(dot);
|
|
1215
|
+
dot.material.dispose(); // the geometry is shared and freed in dispose()
|
|
1216
|
+
flashDots.delete(dot);
|
|
1217
|
+
heldDots.delete(dot);
|
|
1218
|
+
if (lastFlashed === dot) lastFlashed = null;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1169
1221
|
function flashPoint(world) {
|
|
1170
1222
|
const dot = new THREE.Mesh(
|
|
1171
1223
|
flashGeometry,
|
|
@@ -1178,15 +1230,53 @@ export function createViewer(container, part) {
|
|
|
1178
1230
|
scaleFlashDot(dot); // sized before its first frame, not one frame late
|
|
1179
1231
|
scene.add(dot);
|
|
1180
1232
|
flashDots.add(dot);
|
|
1233
|
+
lastFlashed = dot;
|
|
1181
1234
|
const t = setTimeout(() => {
|
|
1182
1235
|
flashTimers.delete(t);
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
scene.remove(dot); dot.material.dispose();
|
|
1236
|
+
dot.userData.fadeTimer = null;
|
|
1237
|
+
dropFlashDot(dot);
|
|
1186
1238
|
}, 1200);
|
|
1239
|
+
dot.userData.fadeTimer = t;
|
|
1187
1240
|
flashTimers.add(t);
|
|
1188
1241
|
}
|
|
1189
1242
|
|
|
1243
|
+
// Keep the newest marker on screen until released. Earlier held markers stay
|
|
1244
|
+
// held: picking a second spot should light both, and one release() clears
|
|
1245
|
+
// them together. The anchor stream follows the newest, which is the one a
|
|
1246
|
+
// host's own UI is anchored to.
|
|
1247
|
+
function holdFlashPoint() {
|
|
1248
|
+
if (!lastFlashed) return false;
|
|
1249
|
+
const dot = lastFlashed;
|
|
1250
|
+
if (dot.userData.fadeTimer) {
|
|
1251
|
+
clearTimeout(dot.userData.fadeTimer);
|
|
1252
|
+
flashTimers.delete(dot.userData.fadeTimer);
|
|
1253
|
+
dot.userData.fadeTimer = null;
|
|
1254
|
+
}
|
|
1255
|
+
heldDots.add(dot);
|
|
1256
|
+
anchorDot = dot;
|
|
1257
|
+
publishAnchor(projectPoint([dot.position.x, dot.position.y, dot.position.z]));
|
|
1258
|
+
return true;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function releaseFlashPoints() {
|
|
1262
|
+
if (heldDots.size === 0 && !anchorDot) return;
|
|
1263
|
+
for (const dot of [...heldDots]) dropFlashDot(dot);
|
|
1264
|
+
anchorDot = null;
|
|
1265
|
+
publishAnchor(null);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function onFlashAnchorChange(cb) {
|
|
1269
|
+
// The `disposed` half is the same guard cutaway's onHandleHoverChange takes,
|
|
1270
|
+
// and for the same two reasons: a subscribe racing teardown (effect-cleanup
|
|
1271
|
+
// ordering, a StrictMode remount) would otherwise be handed an anchor for a
|
|
1272
|
+
// dot no longer in the scene, and would re-populate a listener set that
|
|
1273
|
+
// dispose() will never clear again — retaining the embedder's closure.
|
|
1274
|
+
if (disposed || typeof cb !== "function") return () => {};
|
|
1275
|
+
anchorListeners.add(cb);
|
|
1276
|
+
cb(lastAnchor); // current state on subscribe, like onCutawayHandleHover
|
|
1277
|
+
return () => anchorListeners.delete(cb);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1190
1280
|
// Full teardown: render loop, observers, controls, timers, GPU resources, DOM.
|
|
1191
1281
|
// Idempotent. Cached sub-part geometries and their edge lines are freed; the
|
|
1192
1282
|
// shared and per-part cloned materials tolerate double-dispose.
|
|
@@ -1211,10 +1301,15 @@ export function createViewer(container, part) {
|
|
|
1211
1301
|
controls.dispose();
|
|
1212
1302
|
for (const t of flashTimers) clearTimeout(t);
|
|
1213
1303
|
flashTimers.clear();
|
|
1214
|
-
// A dot whose timer was just cancelled
|
|
1215
|
-
// sphere geometry is shared
|
|
1304
|
+
// A dot whose timer was just cancelled — or one held indefinitely — still
|
|
1305
|
+
// holds its own material; the sphere geometry is shared and freed once.
|
|
1216
1306
|
for (const dot of flashDots) { scene.remove(dot); dot.material.dispose(); }
|
|
1217
1307
|
flashDots.clear();
|
|
1308
|
+
heldDots.clear();
|
|
1309
|
+
lastFlashed = null;
|
|
1310
|
+
anchorDot = null;
|
|
1311
|
+
lastAnchor = null;
|
|
1312
|
+
anchorListeners.clear();
|
|
1218
1313
|
flashGeometry.dispose();
|
|
1219
1314
|
cutaway.dispose();
|
|
1220
1315
|
for (const n of names) {
|
|
@@ -1284,6 +1379,10 @@ export function createViewer(container, part) {
|
|
|
1284
1379
|
__subMesh: (n) => subMesh[n], // test hooks (cf. attachAnimationControls' __viewer)
|
|
1285
1380
|
__subLines: (n) => subLines[n],
|
|
1286
1381
|
flashPoint,
|
|
1382
|
+
projectPoint,
|
|
1383
|
+
holdFlashPoint,
|
|
1384
|
+
releaseFlashPoints,
|
|
1385
|
+
onFlashAnchorChange,
|
|
1287
1386
|
cutawaySupported: () => cutaway.isSupported,
|
|
1288
1387
|
cutawayEnabled: () => cutaway.isEnabled,
|
|
1289
1388
|
setCutawayEnabled,
|
package/src/framework/worker.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// in docs/KERNEL-CONTRACT.md.
|
|
11
11
|
import { handle } from "./jobs.js";
|
|
12
12
|
import { lintPart } from "../lint.js";
|
|
13
|
+
import { cachedVectorDocs } from "./vectors.js";
|
|
13
14
|
|
|
14
15
|
async function manifoldKernels() {
|
|
15
16
|
const [{ default: Module }, { createManifoldKernel }] = await Promise.all([
|
|
@@ -140,7 +141,51 @@ export function runWorker(part, opts = {}) {
|
|
|
140
141
|
// the pump awaits that boot, so routing lint through the queue would drag in
|
|
141
142
|
// OCCT's ~11 MB WASM to run a check that never calls the kernel at all.
|
|
142
143
|
if (e.data?.type === "lint") {
|
|
143
|
-
|
|
144
|
+
// `vectorDocs` is what the two document-dependent vector rules need —
|
|
145
|
+
// vector-size-missing (does this file's `units` require a size?) and
|
|
146
|
+
// vector-unknown-shape (does it declare that shape name?). Without it both
|
|
147
|
+
// stay silent, which is how they are designed to degrade.
|
|
148
|
+
//
|
|
149
|
+
// CACHED ONLY, and deliberately so. This handler must stay synchronous:
|
|
150
|
+
// lint is instant and offline by construction, which is the property that
|
|
151
|
+
// lets a sandbox run it on every keystroke. Awaiting the async resolver
|
|
152
|
+
// here made lint hangable (asset-resolve.js's fetch has no timeout) and
|
|
153
|
+
// made the reply order depend on how fast each part's vectors fetched.
|
|
154
|
+
// cachedVectorDocs reads only bytes that are already resolved and never
|
|
155
|
+
// initiates a fetch, so a hosted sandbox gets both rules as soon as one
|
|
156
|
+
// build has run — the common case — and never waits for them.
|
|
157
|
+
//
|
|
158
|
+
// Guarded even so: lintPart's never-throws contract is worth nothing to a
|
|
159
|
+
// host if the handler around it can throw first and post NOTHING at all —
|
|
160
|
+
// in a real worker that surfaces as `unhandledrejection`, not an `error`
|
|
161
|
+
// event, so a host waiting on a lint-report just waits.
|
|
162
|
+
//
|
|
163
|
+
// Two levels, because they mean different things. The inner one covers a
|
|
164
|
+
// hostile `vectors` (a throwing getter, a Proxy whose ownKeys trap throws):
|
|
165
|
+
// that means "no documents", exactly as if the caller passed none, so the
|
|
166
|
+
// report is the same one lintPart alone would produce. The outer one is the
|
|
167
|
+
// last resort for anything neither of us has thought of.
|
|
168
|
+
let report;
|
|
169
|
+
try {
|
|
170
|
+
let vectorDocs;
|
|
171
|
+
try { vectorDocs = Object.fromEntries(cachedVectorDocs(current?.vectors)); }
|
|
172
|
+
catch { vectorDocs = undefined; }
|
|
173
|
+
report = lintPart(current, { params: e.data.params, vectorDocs });
|
|
174
|
+
} catch (cause) {
|
|
175
|
+
report = {
|
|
176
|
+
ok: false,
|
|
177
|
+
errors: [{
|
|
178
|
+
rule: "lint-context-error",
|
|
179
|
+
severity: "error",
|
|
180
|
+
message: `partforge/lint could not run: ${cause?.message || String(cause)}`,
|
|
181
|
+
hint: "The part or the lint request is too malformed to analyze — make sure `vectors`, `defaults` and `params` are plain, side-effect-free data rather than throwing getters or hostile Proxies.",
|
|
182
|
+
path: "",
|
|
183
|
+
}],
|
|
184
|
+
warnings: [],
|
|
185
|
+
notes: [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
postMessage({ type: "lint-report", report });
|
|
144
189
|
return;
|
|
145
190
|
}
|
|
146
191
|
// Only generates supersede each other; exports/inspect always run (cancelling
|
package/src/ingest.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The published `partforge/ingest` entry: SVG -> the partforge-vector JSON
|
|
2
|
+
// format. DOM-required and main-thread only — a host runs it once per artwork
|
|
3
|
+
// and stores the result, the same division of labour as `fontCatalog`.
|
|
4
|
+
// partforge does not write files.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately NOT re-exported from `partforge` (the main entry) or from
|
|
7
|
+
// `partforge/geometry`: this must stay unreachable from the geometry worker.
|
|
8
|
+
export { ingestSvg } from "./framework/ingest/svg-ingest.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
|
2
|
+
<!-- A filled shape and a stroked OPEN shape, so this one file exercises both
|
|
3
|
+
of ingest's geometry paths. The union's tight bbox is 40 x 30 artwork
|
|
4
|
+
units: the circle spans 14..34 in both axes, and the stroked bar spans
|
|
5
|
+
4..44 in x (round caps add half the 4-unit width at each end) and 40..44
|
|
6
|
+
in y. Deliberately NOT centred in the viewBox, so anything that sizes from
|
|
7
|
+
the viewBox instead of the geometry gets a visibly wrong answer. -->
|
|
8
|
+
<circle cx="24" cy="24" r="10" fill="#111"/>
|
|
9
|
+
<polyline points="6 42 42 42" fill="none" stroke="#111" stroke-width="4" stroke-linecap="round"/>
|
|
10
|
+
</svg>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": "partforge-vector",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"units": "artwork",
|
|
5
|
+
"note": "Filled 2-D outlines for k.vector2d. `units` is \"mm\" (coordinates are millimetres, placed as authored) or \"artwork\" (no physical meaning; a size is required at every call site). `shapes` maps a name to a list of filled regions; each region's `outer` is its boundary and `holes` are subtracted from it. A contour is a `kind`: \"path\", \"circle\", \"rect\", or \"polygon\". Path segments run head-to-tail from `start`, and the contour closes implicitly from the last `to` back to `start`. y points UP. See docs/VECTOR-FORMAT.md.",
|
|
6
|
+
"source": "emblem.svg",
|
|
7
|
+
"bbox": {
|
|
8
|
+
"minX": 4,
|
|
9
|
+
"minY": -44,
|
|
10
|
+
"maxX": 44,
|
|
11
|
+
"maxY": -14
|
|
12
|
+
},
|
|
13
|
+
"shapes": {
|
|
14
|
+
"artwork": [
|
|
15
|
+
{
|
|
16
|
+
"outer": {
|
|
17
|
+
"kind": "path",
|
|
18
|
+
"start": [
|
|
19
|
+
14,
|
|
20
|
+
-24
|
|
21
|
+
],
|
|
22
|
+
"segments": [
|
|
23
|
+
{
|
|
24
|
+
"kind": "arc",
|
|
25
|
+
"to": [
|
|
26
|
+
31.071068,
|
|
27
|
+
-31.071068
|
|
28
|
+
],
|
|
29
|
+
"through": [
|
|
30
|
+
20.173166,
|
|
31
|
+
-33.238795
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"kind": "arc",
|
|
36
|
+
"to": [
|
|
37
|
+
24,
|
|
38
|
+
-14
|
|
39
|
+
],
|
|
40
|
+
"through": [
|
|
41
|
+
33.238795,
|
|
42
|
+
-20.173166
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"kind": "arc",
|
|
47
|
+
"to": [
|
|
48
|
+
14,
|
|
49
|
+
-24
|
|
50
|
+
],
|
|
51
|
+
"through": [
|
|
52
|
+
16.928932,
|
|
53
|
+
-16.928932
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"holes": []
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"outer": {
|
|
62
|
+
"kind": "path",
|
|
63
|
+
"start": [
|
|
64
|
+
6,
|
|
65
|
+
-40
|
|
66
|
+
],
|
|
67
|
+
"segments": [
|
|
68
|
+
{
|
|
69
|
+
"kind": "arc",
|
|
70
|
+
"to": [
|
|
71
|
+
6,
|
|
72
|
+
-44
|
|
73
|
+
],
|
|
74
|
+
"through": [
|
|
75
|
+
4,
|
|
76
|
+
-42
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"kind": "line",
|
|
81
|
+
"to": [
|
|
82
|
+
42,
|
|
83
|
+
-44
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"kind": "arc",
|
|
88
|
+
"to": [
|
|
89
|
+
42,
|
|
90
|
+
-40
|
|
91
|
+
],
|
|
92
|
+
"through": [
|
|
93
|
+
44,
|
|
94
|
+
-42
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"kind": "line",
|
|
99
|
+
"to": [
|
|
100
|
+
6,
|
|
101
|
+
-40
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
},
|
|
106
|
+
"holes": []
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": "partforge-vector",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"units": "mm",
|
|
5
|
+
"note": "Emblem backing plate. Drawn at 40 x 24 mm with M3 clearance holes on 28 mm centres and a keyway placed low, in the gap between the emblem artwork's disc and its bar, so it stays a real through-slot rather than getting capped by the emboss at the default emblem_w. Coordinates are millimetres and place as authored, so `body`, `holes`, and `keyway` share one frame — the cut in build lands where it is drawn.",
|
|
6
|
+
"shapes": {
|
|
7
|
+
"body": {
|
|
8
|
+
"role": "add",
|
|
9
|
+
"regions": [
|
|
10
|
+
{ "outer": { "kind": "rect", "center": [0, 0], "width": 40, "height": 24, "radius": 4 } }
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"holes": {
|
|
14
|
+
"role": "subtract",
|
|
15
|
+
"regions": [
|
|
16
|
+
{ "outer": { "kind": "circle", "center": [-14, 0], "r": 1.7 } },
|
|
17
|
+
{ "outer": { "kind": "circle", "center": [14, 0], "r": 1.7 } }
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"keyway": {
|
|
21
|
+
"role": "subtract",
|
|
22
|
+
"regions": [
|
|
23
|
+
{ "outer": { "kind": "polygon", "points": [[-3, -8], [3, -8], [0, -4]] } }
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The k.vector2d reference part — for BOTH paths the format supports: ingested
|
|
2
|
+
// artwork (`emblem`, `units: "artwork"`, sized per call site) and an authored
|
|
3
|
+
// millimetre drawing (`plate`, `units: "mm"`, placed exactly as drawn). The two
|
|
4
|
+
// vectors share one build, composed together with an ordinary boolean.
|
|
5
|
+
//
|
|
6
|
+
// `vectors` is declared with `new URL(..., import.meta.url)`, the same form
|
|
7
|
+
// import-demo.js uses for its STL: Vite turns it into a bundled asset URL, and
|
|
8
|
+
// in Node it is a file: URL that src/testing/assets.js reads off disk. A bare
|
|
9
|
+
// `() => import("./assets/emblem.vector.json")` would work in Vite and fail in the CLI.
|
|
10
|
+
//
|
|
11
|
+
// The source artwork lives beside it as emblem.svg, and the .json is regenerated
|
|
12
|
+
// with `node scripts/ingest-svg.mjs src/parts/assets/emblem.svg`. plate.vector.json
|
|
13
|
+
// is hand-authored — no ingest step, no source SVG — and is kept legible enough
|
|
14
|
+
// to serve as documentation's worked example of a multi-shape, role-composed file.
|
|
15
|
+
export default {
|
|
16
|
+
meta: { title: "Emblem", units: "mm", background: 0x15181d },
|
|
17
|
+
vectors: {
|
|
18
|
+
emblem: new URL("./assets/emblem.vector.json", import.meta.url),
|
|
19
|
+
plate: new URL("./assets/plate.vector.json", import.meta.url),
|
|
20
|
+
},
|
|
21
|
+
parameters: [
|
|
22
|
+
{
|
|
23
|
+
id: "plate",
|
|
24
|
+
title: "Plate",
|
|
25
|
+
description: "The backing plate the artwork is embossed on. Its outline — including the bolt "
|
|
26
|
+
+ "holes and keyway — is drawn in plate.vector.json, not parameterized.",
|
|
27
|
+
advanced: [
|
|
28
|
+
{ key: "plate_t", label: "Thickness", unit: "mm", min: 1, max: 10, step: 0.5, description: "Plate thickness." },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "art",
|
|
33
|
+
title: "Artwork",
|
|
34
|
+
description: "The embossed vector art. `emblem.svg` carries a filled circle and a stroked bar, so both of ingest's geometry paths are exercised.",
|
|
35
|
+
advanced: [
|
|
36
|
+
{ key: "emblem_w", label: "Emblem width", unit: "mm", min: 8, max: 70, step: 1,
|
|
37
|
+
description: "Width of the artwork's **tight bounding box** in mm — not its `viewBox`. Stroke thickness scales with it." },
|
|
38
|
+
{ key: "emboss", label: "Emboss height", unit: "mm", min: 0.4, max: 4, step: 0.2,
|
|
39
|
+
description: "How far the artwork stands proud of the plate." },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
defaults: { plate_t: 3, emblem_w: 30, emboss: 1 },
|
|
44
|
+
parts: {
|
|
45
|
+
plate: {
|
|
46
|
+
label: "Plate",
|
|
47
|
+
views: ["plate"],
|
|
48
|
+
export: { name: "emblem-plate" },
|
|
49
|
+
// No shape named and no size: the file's own roles compose it (body minus
|
|
50
|
+
// holes minus keyway), and units "mm" places it exactly as drawn. A size
|
|
51
|
+
// HERE would be safe — the composed call measures the whole document at
|
|
52
|
+
// once — but it would also be pointless. What is not safe is fetching
|
|
53
|
+
// body/holes/keyway separately and sizing each call: each would scale
|
|
54
|
+
// against ITS OWN bounds, and the drawing's shared frame would be gone
|
|
55
|
+
// (ERROR-PATTERNS.md#vector-mm-shapes-misscaled). Millimetres place as
|
|
56
|
+
// authored; that is the whole point of the units mode.
|
|
57
|
+
// The keyway sits clear of the artwork at the default `emblem_w` (30) —
|
|
58
|
+
// confirmed by measurement, not eyeballed: their 2-D footprints have zero
|
|
59
|
+
// intersection. That clearance is deliberate, not incidental: a much
|
|
60
|
+
// larger `emblem_w` would grow the emboss until it overlaps the keyway's
|
|
61
|
+
// footprint again, and the union below would then cap it from above —
|
|
62
|
+
// a through-slot the drawing marks `role: "subtract"` quietly becoming a
|
|
63
|
+
// blind pocket. This is exactly the failure mode the `holes` gate below
|
|
64
|
+
// exists to catch, which is why that gate is only asserted at defaults.
|
|
65
|
+
build: (k, p) => k
|
|
66
|
+
.vector2d("plate")
|
|
67
|
+
.extrude({ h: p.plate_t })
|
|
68
|
+
.union(k.vector2d("emblem", { width: p.emblem_w }).extrude({ h: p.emboss }).translate([0, 0, p.plate_t])),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
views: { plate: { label: "Plate" } },
|
|
72
|
+
verify: {
|
|
73
|
+
expect: {
|
|
74
|
+
plate: {
|
|
75
|
+
// Tight in all three axes, by the same +1 mm convention as
|
|
76
|
+
// import-demo.js's gates: verify evaluates the `defaults` case only
|
|
77
|
+
// (this part declares no presets), so bbox is checked against one
|
|
78
|
+
// deterministic value (40 x 24 x 4) forever, not a swept range —
|
|
79
|
+
// z = plate_t + emboss = 3 + 1 = 4 at these defaults specifically,
|
|
80
|
+
// not the schema's wider plate_t/emboss envelope. Revisit this bound
|
|
81
|
+
// if a preset is ever added that sweeps plate_t or emboss.
|
|
82
|
+
bbox: "<=[41,25,5]",
|
|
83
|
+
// Measured at defaults: 3013 mm^3 (`npx partforge measure`). The bare
|
|
84
|
+
// plate (no emboss union) is 2748 mm^3 — comfortably under this bound —
|
|
85
|
+
// so a silently-vanished emboss union fails here. Complemented by the
|
|
86
|
+
// `holes` gate below for the opposite failure (a cut that stops working
|
|
87
|
+
// raises volume, not lowers it, so this bound alone can't catch that).
|
|
88
|
+
volume: ">=2900",
|
|
89
|
+
watertight: true,
|
|
90
|
+
// Three through-holes: the two bolt circles, plus the keyway triangle —
|
|
91
|
+
// all cut clean through the extruded plate and, at this part's default
|
|
92
|
+
// `emblem_w`, none of them sit under the artwork's emboss (see the
|
|
93
|
+
// build comment above for why that placement matters). Confirmed with
|
|
94
|
+
// `npx partforge measure`, and falsified by temporarily flipping
|
|
95
|
+
// "holes"/"keyway" to role "add" in plate.vector.json (which drops
|
|
96
|
+
// this to 0, proving the gate can fail).
|
|
97
|
+
holes: 3,
|
|
98
|
+
},
|
|
99
|
+
_view: { overlaps: 0 },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
package/src/testing/manifold.js
CHANGED
|
@@ -6,10 +6,11 @@ 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 { ensureVectors } from "../framework/vectors.js";
|
|
9
10
|
import { nodeAssetSources } from "./assets.js";
|
|
10
11
|
import { tessellateStepAssets } from "./step-mesh.js";
|
|
11
12
|
|
|
12
|
-
export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes } = {}) {
|
|
13
|
+
export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes, vectors } = {}) {
|
|
13
14
|
const wasm = await Module();
|
|
14
15
|
wasm.setup();
|
|
15
16
|
const kernel = createManifoldKernel(wasm, { quality });
|
|
@@ -24,5 +25,6 @@ export async function bootManifoldKernel({ quality = "preview", fonts, imports,
|
|
|
24
25
|
const meshes = importMeshes ?? (stepEntries.length ? await tessellateStepAssets(stepEntries) : null);
|
|
25
26
|
await ensureImports(kernel, decl, meshes);
|
|
26
27
|
}
|
|
28
|
+
if (vectors) await ensureVectors(kernel, nodeAssetSources(vectors));
|
|
27
29
|
return kernel;
|
|
28
30
|
}
|
package/src/testing/occt.js
CHANGED
|
@@ -8,9 +8,10 @@ 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 { ensureVectors } from "../framework/vectors.js";
|
|
11
12
|
import { nodeAssetSources } from "./assets.js";
|
|
12
13
|
|
|
13
|
-
export async function bootOcctKernel({ fonts, imports, importMeshes } = {}) {
|
|
14
|
+
export async function bootOcctKernel({ fonts, imports, importMeshes, vectors } = {}) {
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
globalThis.require = globalThis.require ?? require;
|
|
16
17
|
globalThis.__dirname = globalThis.__dirname ?? path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -22,5 +23,6 @@ export async function bootOcctKernel({ fonts, imports, importMeshes } = {}) {
|
|
|
22
23
|
if (fonts) { const opentype = normalizeOpentype(await import("opentype.js"));
|
|
23
24
|
for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, parseFont(opentype, buf, name)); }
|
|
24
25
|
if (imports) await ensureImports(kernel, nodeAssetSources(imports), importMeshes ?? null);
|
|
26
|
+
if (vectors) await ensureVectors(kernel, nodeAssetSources(vectors));
|
|
25
27
|
return kernel;
|
|
26
28
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface PickEvent {
|
|
|
44
44
|
prompt: string;
|
|
45
45
|
/** The selection formatted as a compact token. */
|
|
46
46
|
token: string;
|
|
47
|
+
/** Where the pick's marker sits on the canvas, in CSS px from its top-left. */
|
|
48
|
+
anchor: { x: number; y: number };
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/** Fired once per completed build. NOT fired for a pose-only edit. */
|
|
@@ -244,6 +246,24 @@ export interface AnnotateRuntime {
|
|
|
244
246
|
onModeChange(cb: () => void): () => void;
|
|
245
247
|
}
|
|
246
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The yellow marker a pick flashes, as a thing with a lifetime — for a host
|
|
251
|
+
* that hangs its own UI (a chat bubble, a callout) off the dot. A marker fades
|
|
252
|
+
* on its own about a second after the pick, so `hold()` belongs in the `onPick`
|
|
253
|
+
* handler, not behind a later user action.
|
|
254
|
+
*/
|
|
255
|
+
export interface PickMarkerRuntime {
|
|
256
|
+
/** Keep the newest marker on screen; false when there is none. Earlier held markers stay held. */
|
|
257
|
+
hold(): boolean;
|
|
258
|
+
/** Clear every held marker. */
|
|
259
|
+
release(): void;
|
|
260
|
+
/**
|
|
261
|
+
* Where the newest held marker is, as the camera moves; null when nothing is
|
|
262
|
+
* held. Fires immediately with the current state. Returns an unsubscribe.
|
|
263
|
+
*/
|
|
264
|
+
onAnchorChange(cb: (anchor: { x: number; y: number; visible: boolean } | null) => void): () => void;
|
|
265
|
+
}
|
|
266
|
+
|
|
247
267
|
/** Where playback is: idle, swinging the camera to an intro cue, playing, or paused. */
|
|
248
268
|
export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
|
|
249
269
|
|
|
@@ -359,6 +379,8 @@ export interface PartRuntime {
|
|
|
359
379
|
measure: MeasureRuntime;
|
|
360
380
|
/** Annotation mode's runtime-controls API — mode on/off, ink state, and send. Always present; a no-op stand-in when `onAnnotationSend` was not supplied. */
|
|
361
381
|
annotate: AnnotateRuntime;
|
|
382
|
+
/** The pick marker's lifetime — hold it on screen and follow it across the canvas. Always present (a no-op stand-in outside `makeHandle` tests). */
|
|
383
|
+
pickMarker: PickMarkerRuntime;
|
|
362
384
|
}
|
|
363
385
|
|
|
364
386
|
/** Mount a full parametric-part app from a `PartDefinition`. */
|