partforge 0.7.0 → 0.9.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 +47 -14
- package/docs/AUTHORING-PARTS.md +118 -19
- package/docs/ERROR-PATTERNS.md +150 -0
- package/package.json +2 -1
- package/skills/partforge/SKILL.md +5 -0
- package/src/app-faceted-vase.js +10 -0
- package/src/faceted-vase-worker.js +3 -0
- package/src/framework/assembly.js +9 -3
- package/src/framework/geometry/helix-tube.js +10 -20
- package/src/framework/geometry/kernel-front.js +6 -0
- package/src/framework/geometry/kernel.js +5 -2
- package/src/framework/geometry/loft.js +79 -0
- package/src/framework/geometry/manifold-backend.js +22 -1
- package/src/framework/geometry/mesh-build.js +53 -0
- package/src/framework/geometry/occt-backend.js +70 -10
- package/src/framework/geometry/polygon.js +89 -0
- package/src/framework/geometry/profile.js +96 -0
- package/src/framework/geometry/sweep.js +151 -0
- package/src/parts/faceted-vase.js +75 -0
- package/src/testing/error-patterns.js +78 -0
- package/src/testing/measure.js +3 -1
- package/src/testing/verify.js +51 -17
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Example PartDefinition — the motivating showcase for k.loft(). Silhouette rings are
|
|
2
|
+
// stacked up a smooth base→waist→rim curve; each ring is a regular n-gon rotated by a
|
|
3
|
+
// running twist plus an alternating half-facet offset, so the facets zig-zag into a
|
|
4
|
+
// woven look. A second, wall-inset loft is cut from the body to hollow it (Manifold
|
|
5
|
+
// backend, so it stays fast — no OCCT). See docs/AUTHORING-PARTS.md for the conventions.
|
|
6
|
+
import { regularPolygon } from "partforge/geometry";
|
|
7
|
+
|
|
8
|
+
const RINGS = 28; // silhouette resolution (ring count up the height)
|
|
9
|
+
|
|
10
|
+
// Body radius at height fraction t (0..1): a quadratic Bézier through base/waist/rim.
|
|
11
|
+
const silhouette = (t, p) => { const a = 1 - t; return a * a * p.baseR + 2 * a * t * p.waistR + t * t * p.rimR; };
|
|
12
|
+
|
|
13
|
+
// Ring list for a wall at radial `inner` inset (offset along the face normal so the
|
|
14
|
+
// perpendicular wall stays == p.wall on every facet). inner=false → outer surface.
|
|
15
|
+
const vaseRings = (p, inner) => {
|
|
16
|
+
const inset = inner ? p.wall / Math.cos(Math.PI / p.facets) : 0;
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let i = 0; i <= RINGS; i++) {
|
|
19
|
+
const t = i / RINGS;
|
|
20
|
+
const radius = Math.max(silhouette(t, p) - inset, 0.5);
|
|
21
|
+
const rotate = p.twist * t + (i % 2) * (180 / p.facets); // running twist + alternating half-facet
|
|
22
|
+
out.push({ sides: p.facets, radius, z: p.height * t, rotate });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
meta: { title: "Faceted Vase", units: "mm", background: 0x15181d },
|
|
29
|
+
parameters: [
|
|
30
|
+
{
|
|
31
|
+
id: "body",
|
|
32
|
+
title: "Body",
|
|
33
|
+
description: "A faceted, twisting vase built from stacked cross-sections (`k.loft`). " +
|
|
34
|
+
"Pick a preset, or open **Advanced** for exact dimensions. **Facets** and **Twist** are the styling; **Wall** decides whether it prints cleanly.",
|
|
35
|
+
presets: {
|
|
36
|
+
"Tulip vase": { height: 150, baseR: 35, waistR: 26, rimR: 40, facets: 5, twist: 40, wall: 2 },
|
|
37
|
+
"Barrel pot": { height: 90, baseR: 40, waistR: 44, rimR: 38, facets: 8, twist: 0, wall: 2.4 },
|
|
38
|
+
"Twist column": { height: 180, baseR: 30, waistR: 30, rimR: 30, facets: 6, twist: 120, wall: 2 },
|
|
39
|
+
},
|
|
40
|
+
advanced: [
|
|
41
|
+
{ key: "height", label: "Height", unit: "mm", min: 40, max: 220, step: 1, description: "Overall height along the axis." },
|
|
42
|
+
{ key: "baseR", label: "Base radius", unit: "mm", min: 15, max: 70, step: 1, description: "Across-corners radius at the foot." },
|
|
43
|
+
{ key: "waistR", label: "Waist radius", unit: "mm", min: 12, max: 80, step: 1, description: "Radius at mid-height — set below base+rim to pinch a waist, above to bulge a belly." },
|
|
44
|
+
{ key: "rimR", label: "Rim radius", unit: "mm", min: 12, max: 80, step: 1, description: "Across-corners radius at the mouth." },
|
|
45
|
+
{ key: "facets", label: "Facets", min: 3, max: 12, step: 1, description: "Sides of each cross-section. Low counts read crystalline; high counts approach smooth." },
|
|
46
|
+
{ key: "twist", label: "Twist", unit: "°", min: 0, max: 180, step: 5, description: "Total rotation of the facets from foot to rim, for a spiral." },
|
|
47
|
+
{ key: "wall", label: "Wall thickness", unit: "mm", min: 1, max: 5, step: 0.1, description: "Perpendicular wall thickness. The fdm-pla profile wants **≥ 1.2 mm**." },
|
|
48
|
+
{ key: "floor", label: "Floor thickness", unit: "mm", min: 1, max: 8, step: 0.5, hidden: true, description: "Internal: solid base thickness; hidden but drives the geometry." },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
defaults: { height: 150, baseR: 35, waistR: 26, rimR: 40, facets: 5, twist: 40, wall: 2, floor: 3 },
|
|
53
|
+
parts: {
|
|
54
|
+
vase: {
|
|
55
|
+
label: "Vase", views: ["vase"], export: { name: "vase" },
|
|
56
|
+
build: (k, p) => {
|
|
57
|
+
const body = k.loft(vaseRings(p, false)).label("Faceted wall");
|
|
58
|
+
// Hollow it: an inset loft clipped to z ≥ floor (so the base stays solid), cut from the body.
|
|
59
|
+
const cavity = k.loft(vaseRings(p, true))
|
|
60
|
+
.intersect(k.box([-1e4, -1e4, p.floor], [1e4, 1e4, p.height + 10])).label("Cavity");
|
|
61
|
+
return body.cut(cavity);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
views: { vase: { label: "Vase" } },
|
|
66
|
+
// Self-verification: opt into the FDM-PLA profile (bed-fit gate + min-wall warning) and
|
|
67
|
+
// pin the intent — an open vessel (no through-holes), fits the bed, no interpenetration.
|
|
68
|
+
verify: {
|
|
69
|
+
process: "fdm-pla",
|
|
70
|
+
expect: {
|
|
71
|
+
vase: { holes: 0, bbox: "<=[220,220,230]" },
|
|
72
|
+
_view: { overlaps: 0 },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Shared parser + matcher for docs/ERROR-PATTERNS.md — the symptom-indexed
|
|
2
|
+
// error→pattern library (issue #28). The parser here is the single source of
|
|
3
|
+
// truth: the format lint (test/error-patterns.test.js) and the CLI crash-path
|
|
4
|
+
// matcher (issue #27) both import it. Contract: partforge code that throws must
|
|
5
|
+
// throw strings appearing verbatim, in a backtick literal AT THE START of some
|
|
6
|
+
// entry's Symptom line — only a leading literal participates in matching, so
|
|
7
|
+
// backticks used for prose mid-sentence never mis-attribute an unrelated crash.
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
// Single-pass, fence-aware parse: a heading inside a ``` / ~~~ fence is quoted
|
|
11
|
+
// content, not structure. Each `## <id>` entry records the `# <section>` it sits
|
|
12
|
+
// under; its body runs to the next h1/h2 heading. (Moved verbatim from the lint
|
|
13
|
+
// test, then enriched with symptom/cause/fix extraction.)
|
|
14
|
+
export function parsePatterns(md) {
|
|
15
|
+
const entries = [];
|
|
16
|
+
let section = null;
|
|
17
|
+
let entry = null;
|
|
18
|
+
let inFence = false;
|
|
19
|
+
for (const line of md.split("\n")) {
|
|
20
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
21
|
+
inFence = !inFence;
|
|
22
|
+
if (entry) entry.body += line + "\n";
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!inFence) {
|
|
26
|
+
const h1 = line.match(/^# (.+)$/);
|
|
27
|
+
const h2 = line.match(/^## (.+)$/);
|
|
28
|
+
if (h1) { section = h1[1]; entry = null; continue; }
|
|
29
|
+
if (h2) { entry = { id: h2[1], section, body: "" }; entries.push(entry); continue; }
|
|
30
|
+
}
|
|
31
|
+
if (entry) entry.body += line + "\n";
|
|
32
|
+
}
|
|
33
|
+
const field = (body, label) => {
|
|
34
|
+
const i = body.indexOf(`- **${label}:**`);
|
|
35
|
+
return i < 0 ? null : body.slice(i).split("\n")[0].replace(`- **${label}:**`, "").trim();
|
|
36
|
+
};
|
|
37
|
+
return entries.map((e) => {
|
|
38
|
+
const symptom = field(e.body, "Symptom");
|
|
39
|
+
// Leading-literal convention: only a backtick literal at the very START of the
|
|
40
|
+
// Symptom text is a match literal (kept as a ≤1-element array for compatibility
|
|
41
|
+
// — tests and the matcher read symptomStrings). Mid-line backticks are prose.
|
|
42
|
+
const leading = symptom?.match(/^`([^`]+)`/);
|
|
43
|
+
return {
|
|
44
|
+
...e,
|
|
45
|
+
symptom,
|
|
46
|
+
cause: field(e.body, "Cause"),
|
|
47
|
+
fix: field(e.body, "Fix"),
|
|
48
|
+
symptomStrings: leading ? [leading[1]] : [],
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cached read of the live doc, resolved relative to this module so it works from
|
|
54
|
+
// a consuming app's node_modules too. Any read/parse error → null (callers treat
|
|
55
|
+
// that as "no patterns available", never an error).
|
|
56
|
+
let cached;
|
|
57
|
+
export function loadPatterns() {
|
|
58
|
+
if (cached !== undefined) return cached;
|
|
59
|
+
try {
|
|
60
|
+
cached = parsePatterns(readFileSync(new URL("../../docs/ERROR-PATTERNS.md", import.meta.url), "utf8"));
|
|
61
|
+
} catch {
|
|
62
|
+
cached = null;
|
|
63
|
+
}
|
|
64
|
+
return cached;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Leading symptom literals ≥ 6 chars, longest match wins. Never throws.
|
|
68
|
+
export function matchPattern(message, patterns = loadPatterns()) {
|
|
69
|
+
if (!patterns || typeof message !== "string") return null;
|
|
70
|
+
let best = null;
|
|
71
|
+
let bestLen = 5;
|
|
72
|
+
for (const p of patterns) {
|
|
73
|
+
for (const s of p.symptomStrings) {
|
|
74
|
+
if (s.length > bestLen && message.includes(s)) { best = p; bestLen = s.length; }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return best ? { id: best.id, fix: best.fix } : null;
|
|
78
|
+
}
|
package/src/testing/measure.js
CHANGED
|
@@ -20,6 +20,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
20
20
|
const subparts = built.map(({ name, solid, mesh }) => {
|
|
21
21
|
const b = bounds(mesh.positions);
|
|
22
22
|
subBounds.push(b);
|
|
23
|
+
const mw = opts.minWall ? minWall(mesh) : null;
|
|
23
24
|
return {
|
|
24
25
|
name,
|
|
25
26
|
bbox: size(b),
|
|
@@ -28,7 +29,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
28
29
|
triangleCount: mesh.triangles,
|
|
29
30
|
watertight: typeof solid.isEmpty === "function" ? !solid.isEmpty() : null,
|
|
30
31
|
holes: typeof solid.genus === "function" ? solid.genus() : null,
|
|
31
|
-
minWall:
|
|
32
|
+
minWall: mw?.value ?? null,
|
|
33
|
+
minWallAt: mw?.location ?? null,
|
|
32
34
|
};
|
|
33
35
|
});
|
|
34
36
|
|
package/src/testing/verify.js
CHANGED
|
@@ -4,36 +4,70 @@ import { resolveProfile } from "./dfm-profiles.js";
|
|
|
4
4
|
import { expandCases } from "./cases.js";
|
|
5
5
|
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../framework/param-deps.js";
|
|
6
6
|
|
|
7
|
-
// Metric registry: name → how to pull the value out of facts,
|
|
8
|
-
// is a hard gate or a warning
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
// Metric registry: name → how to pull the value out of facts, whether a failure
|
|
8
|
+
// is a hard gate or a warning, and the diagnostics attached to a non-pass check:
|
|
9
|
+
// `hint` (required — the report contract promises one on every fail/warn),
|
|
10
|
+
// `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
|
|
11
|
+
// [x,y,z] source). `manifoldOnly` facts are null on OCCT parts.
|
|
12
|
+
export const SUBPART_METRICS = {
|
|
13
|
+
holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
|
|
14
|
+
hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
|
|
15
|
+
watertight: { kind: "gate", manifoldOnly: true, extract: (s) => s.watertight,
|
|
16
|
+
hint: "a boolean produced an open shell — check for coplanar faces or a cut that exactly grazes a surface",
|
|
17
|
+
pattern: "boolean-not-watertight" },
|
|
18
|
+
volume: { kind: "gate", extract: (s) => s.volume,
|
|
19
|
+
hint: "solid volume is out of range — a feature is missing, doubled, or a governing parameter is mis-scaled" },
|
|
20
|
+
surfaceArea: { kind: "gate", extract: (s) => s.surfaceArea,
|
|
21
|
+
hint: "surface area is out of range — detail features (facets, ribs, textures) are missing or doubled" },
|
|
22
|
+
triangleCount: { kind: "gate", extract: (s) => s.triangleCount,
|
|
23
|
+
hint: "triangle count is out of range — tessellation quality or feature count changed unexpectedly" },
|
|
24
|
+
bbox: { kind: "gate", extract: (s) => s.bbox,
|
|
25
|
+
hint: "bounding box is out of range — check the governing dimensions and the part's orientation" },
|
|
26
|
+
minWall: { kind: "warn", extract: (s) => s.minWall,
|
|
27
|
+
hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
|
|
28
|
+
pattern: "minwall-sliver-triangles",
|
|
29
|
+
locate: (s) => s.minWallAt },
|
|
17
30
|
};
|
|
18
|
-
const VIEW_METRICS = {
|
|
19
|
-
bbox: { kind: "gate", extract: (r) => r.aggregate.bbox
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
export const VIEW_METRICS = {
|
|
32
|
+
bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
|
|
33
|
+
hint: "the assembled view exceeds its size limit — shrink the assembly or pick a process with a larger bed" },
|
|
34
|
+
volume: { kind: "gate", extract: (r) => r.aggregate.volume,
|
|
35
|
+
hint: "total assembly volume is out of range — a sub-part is missing, doubled, or mis-scaled" },
|
|
36
|
+
overlaps: { kind: "gate", extract: (r) => r.overlaps.length,
|
|
37
|
+
hint: "sub-parts interpenetrate near the reported location — adjust placement or add clearance in derive()",
|
|
38
|
+
locate: (r) => r.overlaps[0]?.location ?? null },
|
|
22
39
|
};
|
|
23
40
|
|
|
24
|
-
|
|
41
|
+
// An expectation is a bare expression (string/number/boolean) or { expr, hint }.
|
|
42
|
+
const normalizeExpectation = (spec) =>
|
|
43
|
+
spec !== null && typeof spec === "object" && !Array.isArray(spec) && "expr" in spec
|
|
44
|
+
? { expr: spec.expr, hint: spec.hint }
|
|
45
|
+
: { expr: spec, hint: undefined };
|
|
46
|
+
|
|
47
|
+
function check(scope, subpart, metric, spec, registry, factsObj) {
|
|
25
48
|
const reg = registry[metric];
|
|
26
49
|
if (!reg) throw new Error(`unknown ${scope} metric "${metric}"${subpart ? ` on sub-part "${subpart}"` : ""}`);
|
|
50
|
+
const { expr, hint: partHint } = normalizeExpectation(spec);
|
|
27
51
|
const actual = reg.extract(factsObj);
|
|
28
52
|
const base = { scope, subpart, metric, kind: reg.kind, expr: String(expr) };
|
|
29
53
|
if (actual === null || actual === undefined) {
|
|
30
54
|
if (reg.manifoldOnly) return { ...base, actual, status: "skip", pass: null, message: "n/a (OCCT backend)" };
|
|
31
|
-
if (metric === "minWall")
|
|
55
|
+
if (metric === "minWall") {
|
|
56
|
+
return { ...base, actual, status: "warn", pass: null, message: "min wall unavailable",
|
|
57
|
+
hint: partHint ?? "no min-wall reading for this mesh — treat thin features as unverified" };
|
|
58
|
+
}
|
|
32
59
|
return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
|
|
33
60
|
}
|
|
34
61
|
const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
|
|
35
62
|
const status = pass ? "pass" : reg.kind === "warn" ? "warn" : "fail";
|
|
36
|
-
|
|
63
|
+
const out = { ...base, actual, status, pass, message };
|
|
64
|
+
if (!pass) {
|
|
65
|
+
out.hint = partHint ?? reg.hint;
|
|
66
|
+
if (reg.pattern) out.pattern = reg.pattern;
|
|
67
|
+
const loc = reg.locate?.(factsObj);
|
|
68
|
+
if (loc) out.location = loc;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
37
71
|
}
|
|
38
72
|
|
|
39
73
|
// Pure policy: profile rules + per-part expect → checks for one case's facts.
|