partforge 0.60.2 → 0.62.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/README.md +4 -3
- package/bin/cli.js +17 -1
- package/docs/AUTHORING-PARTS.md +52 -32
- package/docs/ERROR-PATTERNS.md +16 -4
- package/docs/KERNEL-CONTRACT.md +65 -13
- package/package.json +1 -1
- package/src/app-mixed-smoke.js +9 -0
- package/src/framework/backend-select.js +57 -24
- package/src/framework/geometry/creased-normals.js +13 -1
- package/src/framework/geometry/kernel.js +14 -7
- package/src/framework/geometry/manifold-backend.js +37 -0
- package/src/framework/geometry/mesh-fillet.js +576 -0
- package/src/framework/geometry/probe.js +9 -10
- package/src/framework/jobs.js +3 -1
- package/src/framework/lint/rules-build.js +6 -5
- package/src/framework/mount.js +33 -11
- package/src/framework/regen-loop.js +13 -9
- package/src/mixed-smoke-worker.js +3 -0
- package/src/parts/filleted-box.js +3 -2
- package/src/parts/mixed-smoke.js +43 -0
- package/types/kernel.d.ts +9 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Geometry-free build execution. Two consumers share one Proxy implementation:
|
|
2
2
|
//
|
|
3
3
|
// • createProbeKernel() — records op NAMES so ../backend-select.js's
|
|
4
|
-
// detectBackend() can route a part to OCCT when it uses
|
|
4
|
+
// detectBackend() can route a part to OCCT when it uses Solid.shell.
|
|
5
5
|
// • createValidatingProbe() — additionally checks op names against the kernel
|
|
6
6
|
// contract's op lists and routes options-form calls through the same op-options
|
|
7
7
|
// normalizers the real backends use, so partforge/lint can catch a bad call in
|
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
// lists, which test/kernel-contract.test.js pins to both backend implementations.
|
|
16
16
|
import {
|
|
17
17
|
KERNEL_OPS, KERNEL_OPTIONAL_OPS,
|
|
18
|
-
SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS,
|
|
18
|
+
SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, ROUTED_CAD_OPS,
|
|
19
19
|
} from "./kernel.js";
|
|
20
20
|
import { KERNEL_OP_SPECS, SOLID_OP_SPECS, isPlainOptions } from "./op-options.js";
|
|
21
21
|
|
|
22
22
|
// The probe hands out TWO chainable handles: k.shape2d()/k.text2d() yield a Shape2D
|
|
23
23
|
// handle (whose extrude/revolve yield the Solid handle), everything else yields the
|
|
24
|
-
// Solid handle. The split
|
|
25
|
-
// is
|
|
26
|
-
// `Solid.
|
|
27
|
-
//
|
|
28
|
-
// an error
|
|
24
|
+
// Solid handle. The split lets lint and routing distinguish handle-specific ops:
|
|
25
|
+
// `Shape2D.fillet` is shared pure JS, `Solid.fillet` starts mesh-native and may
|
|
26
|
+
// request a runtime OCCT fallback, and `Solid.shell` probe-routes up front.
|
|
27
|
+
// Handle-level VALIDATION stays deliberately permissive (one union allowlist for
|
|
28
|
+
// both handle kinds), so the split can never false-positive on an error rule.
|
|
29
29
|
const KERNEL_ALLOWED = new Set([...KERNEL_OPS, ...KERNEL_OPTIONAL_OPS]);
|
|
30
30
|
const SOLID_ALLOWED = new Set([...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS]);
|
|
31
31
|
|
|
@@ -92,14 +92,13 @@ function makeProbe(onCall) {
|
|
|
92
92
|
return { kernel, proxy, shape2d };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
const CAD_OPS = new Set(
|
|
95
|
+
const CAD_OPS = new Set(ROUTED_CAD_OPS);
|
|
96
96
|
|
|
97
97
|
export function createProbeKernel() {
|
|
98
98
|
const used = new Set(); // every op name, any handle kind
|
|
99
99
|
const solidUsed = new Set(); // ops recorded on kernel/Solid handles only — the
|
|
100
100
|
// routing set: Shape2D.fillet must not look like Solid.fillet
|
|
101
|
-
const cadCalls = []; //
|
|
102
|
-
// routing needs the magnitude (fillet(0) is identity, stays on Manifold)
|
|
101
|
+
const cadCalls = []; // Probe-routed Solid ops WITH args (currently shell).
|
|
103
102
|
const { kernel } = makeProbe((scope, key, args) => {
|
|
104
103
|
used.add(key);
|
|
105
104
|
if (scope !== "shape2d") {
|
package/src/framework/jobs.js
CHANGED
|
@@ -244,7 +244,9 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
244
244
|
post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
|
|
245
245
|
}
|
|
246
246
|
} catch (err) {
|
|
247
|
-
|
|
247
|
+
// `subparts` (generate jobs only) tells the reroute policy which sub-parts the
|
|
248
|
+
// failed job covered, so only those latch to OCCT — not the whole part.
|
|
249
|
+
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId, subparts: msg.subparts });
|
|
248
250
|
else post({ type: "error", message: String(err?.message || err), jobId: msg.jobId });
|
|
249
251
|
} finally {
|
|
250
252
|
kernel.cleanup?.();
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// the validating probe with no geometry kernel. Every error in this group already
|
|
3
3
|
// throws at runtime; the value is reaching it in microseconds, before a WASM boot.
|
|
4
4
|
import { err, warn } from "./finding.js";
|
|
5
|
-
import {
|
|
5
|
+
import { ROUTED_CAD_OPS } from "../geometry/kernel.js";
|
|
6
6
|
import { MAX_PROBE_OPS } from "../geometry/probe.js";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// fillet/chamfer are implemented on the mesh backend now (mesh-fillet.js), so a
|
|
9
|
+
// pinned-Manifold part may use them; only the still-unimplemented ops error here.
|
|
10
|
+
const OCCT_ONLY = new Set(ROUTED_CAD_OPS);
|
|
9
11
|
const unique = (xs) => [...new Set(xs)];
|
|
10
12
|
|
|
11
13
|
export const BUILD_RULES = [
|
|
@@ -52,9 +54,8 @@ export const BUILD_RULES = [
|
|
|
52
54
|
id: "manifold-backend-uses-occt-op",
|
|
53
55
|
run: ({ part, probe }) => {
|
|
54
56
|
if (part?.meta?.backend !== "manifold") return [];
|
|
55
|
-
// solidUsed, not used:
|
|
56
|
-
// JS and
|
|
57
|
-
// these names are CAD-only.
|
|
57
|
+
// solidUsed, not used: a Shape2D method with the same name as a routed Solid
|
|
58
|
+
// op is still backend-identical pure JS and is fine under pinned Manifold.
|
|
58
59
|
return [...probe().solidUsed].filter((op) => OCCT_ONLY.has(op))
|
|
59
60
|
.map((op) => err("manifold-backend-uses-occt-op",
|
|
60
61
|
`\`meta.backend\` pins Manifold, but the build calls \`${op}\`, which only OCCT implements`,
|
package/src/framework/mount.js
CHANGED
|
@@ -402,6 +402,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
402
402
|
|
|
403
403
|
// The regenerate state machine (ready gating / debounce / stale-redo) lives in
|
|
404
404
|
// regen-loop.js; this send callback is the one place a build job is dispatched.
|
|
405
|
+
// Routing is per SUB-part: the missing set splits by backend and each group goes
|
|
406
|
+
// to its own worker in parallel, so a mixed part previews its plain sub-parts at
|
|
407
|
+
// Manifold speed while only the OCCT-routed ones wait. The return value is
|
|
408
|
+
// the job count — the loop holds the cycle open until every group has replied.
|
|
405
409
|
const loop = createRegenLoop({
|
|
406
410
|
missingParts,
|
|
407
411
|
send: (missing) => {
|
|
@@ -410,7 +414,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
410
414
|
lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length, posed: pendingPosed.size };
|
|
411
415
|
pendingPosed.clear(); // consumed — never counted against a second build
|
|
412
416
|
ui.showBusy("generating");
|
|
413
|
-
|
|
417
|
+
const backends = backendPolicy.backendsFor(params);
|
|
418
|
+
let jobs = 0;
|
|
419
|
+
for (const backend of ["manifold", "occt"]) {
|
|
420
|
+
const subparts = missing.filter((n) => (backends[n] ?? "manifold") === backend);
|
|
421
|
+
if (subparts.length === 0) continue;
|
|
422
|
+
service.send({ type: "generate", subparts, view: view(), params, cache: cachingOn }, backend);
|
|
423
|
+
jobs++;
|
|
424
|
+
}
|
|
425
|
+
return jobs;
|
|
414
426
|
},
|
|
415
427
|
});
|
|
416
428
|
cleanup.defer(() => loop.dispose());
|
|
@@ -509,7 +521,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
509
521
|
// delivered, which buildDone() true guarantees is at the live params.
|
|
510
522
|
fastPath.recordDelivered(m.name);
|
|
511
523
|
}
|
|
512
|
-
|
|
524
|
+
// A split dispatch answers in two meshes replies; the busy spinner
|
|
525
|
+
// stays up until the view has everything (the other worker's job may
|
|
526
|
+
// still be running — often OCCT, the slow one).
|
|
527
|
+
if (missingParts().length === 0) ui.hideBusy();
|
|
513
528
|
refreshView();
|
|
514
529
|
if (data.ms && missingParts().length === 0) {
|
|
515
530
|
const tris = viewSubParts(part, view(), params).reduce((s, n) => s + viewer.subTriangles(n), 0);
|
|
@@ -517,11 +532,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
517
532
|
}
|
|
518
533
|
dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
|
|
519
534
|
onBuild?.({ status: "success", ms: data.ms });
|
|
520
|
-
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
535
|
+
// ready/autoplay wait for the WHOLE view: a split dispatch delivers in
|
|
536
|
+
// two replies, and a host acting on `ready` (screenshotting, measuring)
|
|
537
|
+
// must never see half an assembly.
|
|
538
|
+
if (missingParts().length === 0) {
|
|
539
|
+
if (!readySettled) { readySettled = true; resolveReady(); }
|
|
540
|
+
// First-show autoplay: latched separately from `ready`, which the
|
|
541
|
+
// error branch also settles — a part whose first build fails but
|
|
542
|
+
// whose retry succeeds still deserves its autoplay.
|
|
543
|
+
if (!autoplayKicked) { autoplayKicked = true; animCtl?.autoplayKick(); }
|
|
544
|
+
}
|
|
525
545
|
} else if (lastAnimApplyVersion === loop.version()) {
|
|
526
546
|
// Stale ONLY because animation frames kept bumping the version:
|
|
527
547
|
// show the delivered meshes anyway — that IS best-effort playback —
|
|
@@ -553,10 +573,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
553
573
|
ui.setStatus(`${data.filename} downloaded`);
|
|
554
574
|
break;
|
|
555
575
|
case "needs-occt":
|
|
556
|
-
// Probe missed for the current params — rebuild
|
|
557
|
-
//
|
|
558
|
-
// part
|
|
559
|
-
|
|
576
|
+
// Probe missed for the current params — rebuild the failed job's
|
|
577
|
+
// sub-parts on OCCT (an export reply has no subparts and latches the
|
|
578
|
+
// whole part). The latch is per params snapshot, so changing params
|
|
579
|
+
// re-consults the probe and sub-parts drop back to Manifold when the
|
|
580
|
+
// unsupported edge class or other runtime-only requirement goes away.
|
|
581
|
+
backendPolicy.noteNeedsOcct(params, data.subparts);
|
|
560
582
|
loop.buildDone();
|
|
561
583
|
loop.kick();
|
|
562
584
|
break;
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Invariants (pinned by test/framework/regen-loop.test.js):
|
|
6
6
|
// - nothing is sent until ready() (the worker announced its kernel);
|
|
7
|
-
// - at most one
|
|
8
|
-
// caller re-kicks after buildDone()
|
|
7
|
+
// - at most one dispatch CYCLE is in flight; kicks while generating are absorbed
|
|
8
|
+
// and the caller re-kicks after the cycle's last buildDone(). `send` may fan a
|
|
9
|
+
// dispatch out to several workers (per-sub-part backend routing) and reports
|
|
10
|
+
// how many jobs it posted; each worker reply is one buildDone();
|
|
9
11
|
// - markDirty() bumps the params version and debounces a kick, so dragging a
|
|
10
12
|
// slider queues one build per pause, not one per pixel;
|
|
11
13
|
// - a build that a mid-flight edit outdated is reported stale by buildDone()
|
|
@@ -14,19 +16,19 @@
|
|
|
14
16
|
// fast-apply path kicks explicitly).
|
|
15
17
|
export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
|
|
16
18
|
let kernelReady = false;
|
|
17
|
-
let
|
|
19
|
+
let pending = 0; // worker replies still owed for the in-flight dispatch cycle
|
|
18
20
|
let disposed = false;
|
|
19
21
|
let paramsVersion = 0; // bumped on every settings edit
|
|
20
22
|
let genVersion = -1; // the params version the in-flight build is building
|
|
21
23
|
let timer = null;
|
|
22
24
|
|
|
23
25
|
function kick() {
|
|
24
|
-
if (disposed || !kernelReady ||
|
|
26
|
+
if (disposed || !kernelReady || pending > 0) return; // re-kicked when the current cycle finishes
|
|
25
27
|
const missing = missingParts();
|
|
26
28
|
if (missing.length === 0) return;
|
|
27
|
-
generating = true;
|
|
28
29
|
genVersion = paramsVersion;
|
|
29
|
-
send(
|
|
30
|
+
// A send with no return value is the single-job case (one worker, one reply).
|
|
31
|
+
pending = send(missing) ?? 1;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
return {
|
|
@@ -42,10 +44,12 @@ export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
|
|
|
42
44
|
clearTimeout(timer);
|
|
43
45
|
if (debounce) timer = setTimeout(kick, debounceMs);
|
|
44
46
|
},
|
|
45
|
-
//
|
|
46
|
-
// is still current; the caller applies the meshes only on true, then
|
|
47
|
+
// One job of the cycle finished (meshes / needs-occt / error). Returns whether
|
|
48
|
+
// its result is still current; the caller applies the meshes only on true, then
|
|
49
|
+
// kicks (a no-op until the cycle's last reply). Guarded decrement: a reply
|
|
50
|
+
// outside any cycle (an export-triggered needs-occt) must not pre-close the next.
|
|
47
51
|
buildDone() {
|
|
48
|
-
|
|
52
|
+
if (pending > 0) pending--;
|
|
49
53
|
return genVersion === paramsVersion;
|
|
50
54
|
},
|
|
51
55
|
version: () => paramsVersion,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Example part exercising
|
|
2
|
-
//
|
|
1
|
+
// Example part exercising the CAD dress-up ops. Since contract v3 fillet and
|
|
2
|
+
// chamfer build natively on the fast Manifold backend (mesh-fillet.js); STEP
|
|
3
|
+
// export still uses OCCT's exact B-rep blends. Vertical edges are rounded
|
|
3
4
|
// and an optional bore drilled; the base chamfer is off by default (turn it up to
|
|
4
5
|
// try chamfering, kept off in defaults so the gating build uses only the fillet).
|
|
5
6
|
export default {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// CI fixture for per-sub-part backend routing (the mixed-backend twin of
|
|
2
|
+
// text-smoke.js): one shelled sub-part that routes to OCCT next to a plain one
|
|
3
|
+
// that stays on Manifold, so the smoke check exercises a split generate — two
|
|
4
|
+
// workers answering one regen cycle — in a real browser. Dial "Wall" to 0 and
|
|
5
|
+
// the whole part drops back to Manifold (the build skips the shell branch).
|
|
6
|
+
// Shell is the routing exemplar since contract v3 — fillet/chamfer build
|
|
7
|
+
// natively on the mesh backend and no longer route.
|
|
8
|
+
export default {
|
|
9
|
+
meta: { title: "Mixed smoke", units: "mm" },
|
|
10
|
+
parameters: [
|
|
11
|
+
{
|
|
12
|
+
id: "body",
|
|
13
|
+
title: "Body",
|
|
14
|
+
advanced: [
|
|
15
|
+
{ key: "w", label: "Width", unit: "mm", min: 10, max: 60, step: 1 },
|
|
16
|
+
{ key: "t", label: "Wall", unit: "mm", min: 0, max: 5, step: 0.5 },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
defaults: { w: 30, t: 2 },
|
|
21
|
+
parts: {
|
|
22
|
+
body: {
|
|
23
|
+
label: "Body",
|
|
24
|
+
views: ["assembly"],
|
|
25
|
+
export: { name: "body" },
|
|
26
|
+
// The t > 0 branch is what drives the routing: the probe re-runs with live
|
|
27
|
+
// params, so the shell call is only seen (and OCCT only engaged) when the
|
|
28
|
+
// wall is dialed on.
|
|
29
|
+
build: (k, p) => {
|
|
30
|
+
const box = k.box({ min: [0, 0, 0], max: [p.w, p.w, 10] });
|
|
31
|
+
return p.t > 0 ? box.shell({ t: p.t, open: { dir: "Z" } }) : box;
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
lid: {
|
|
35
|
+
label: "Lid",
|
|
36
|
+
views: ["assembly"],
|
|
37
|
+
export: { name: "lid" },
|
|
38
|
+
build: (k, p) => k.box({ min: [0, 0, 0], max: [p.w, p.w, 2] }),
|
|
39
|
+
place: (s) => s.at([0, 0, 12]),
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
views: { assembly: { label: "Assembly" } },
|
|
43
|
+
};
|
package/types/kernel.d.ts
CHANGED
|
@@ -128,7 +128,7 @@ export type CornerSelector =
|
|
|
128
128
|
/** A mirror line for `Shape2D.mirror`. */
|
|
129
129
|
export type MirrorAxis2 = "x" | "y" | { point: Point2; dir: Point2 };
|
|
130
130
|
|
|
131
|
-
// --- edge / face selectors
|
|
131
|
+
// --- edge / face selectors --------------------------------------------------
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
134
|
* Which edges a `fillet`/`chamfer` applies to. Omit for every edge. The object
|
|
@@ -254,12 +254,15 @@ export interface Solid {
|
|
|
254
254
|
toSTL(opts?: { quality?: MeshQuality }): Promise<ArrayBuffer>;
|
|
255
255
|
toIndexedMesh(): IndexedMesh;
|
|
256
256
|
/**
|
|
257
|
-
* Round edges
|
|
258
|
-
*
|
|
259
|
-
* accepted until contract v2.
|
|
257
|
+
* Round edges. Manifold handles straight and circular chains natively and
|
|
258
|
+
* triggers an automatic OCCT fallback for unsupported edge classes. Legacy
|
|
259
|
+
* `(r, selector)` is accepted until contract v2.
|
|
260
260
|
*/
|
|
261
261
|
fillet(r: number | { r: number; edges?: EdgeSelector }): Solid;
|
|
262
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* Bevel edges, with the same mesh-native coverage and OCCT fallback as
|
|
264
|
+
* `fillet`. Legacy `(d, selector)` is accepted until contract v2.
|
|
265
|
+
*/
|
|
263
266
|
chamfer(d: number | { d: number; edges?: EdgeSelector }): Solid;
|
|
264
267
|
/**
|
|
265
268
|
* Hollow inward, wall `t`, opening the faces `open` selects — OCCT only.
|
|
@@ -454,7 +457,7 @@ export interface GeometryKernel {
|
|
|
454
457
|
/** Rim round-overs via one lathe revolve; curve-exact in STEP. */
|
|
455
458
|
roundedCylinder(o: RoundedCylinderOptions): Solid;
|
|
456
459
|
torus(o: TorusOptions): Solid;
|
|
457
|
-
/** Selective edge rounding
|
|
460
|
+
/** Selective edge rounding built directly as one Manifold mesh. */
|
|
458
461
|
roundedBox(o: RoundedBoxOptions): Solid;
|
|
459
462
|
/** N-ary boolean union. */
|
|
460
463
|
union(solids: Solid[]): Solid;
|