partforge 0.76.0 → 0.78.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 +150 -6
- package/docs/AUTHORING-PARTS.md +196 -0
- package/docs/ERROR-PATTERNS.md +42 -0
- package/package.json +1 -1
- package/src/framework/jobs.js +31 -0
- package/src/framework/lint/index.js +37 -3
- package/src/framework/lint/rules-schema.js +39 -11
- package/src/framework/lint/rules-source.js +108 -0
- package/src/framework/lint/source-scan.js +333 -0
- package/src/framework/oracle/describe/accept.js +188 -0
- package/src/framework/oracle/describe/features/dressups.js +173 -0
- package/src/framework/oracle/describe/features/holes.js +129 -0
- package/src/framework/oracle/describe/features/prismatic.js +454 -0
- package/src/framework/oracle/describe/features/sweeps.js +233 -0
- package/src/framework/oracle/describe/fit.js +535 -0
- package/src/framework/oracle/describe/hints.js +91 -0
- package/src/framework/oracle/describe/limits.js +19 -0
- package/src/framework/oracle/describe/patterns.js +494 -0
- package/src/framework/oracle/describe/ransac.js +391 -0
- package/src/framework/oracle/describe/report.js +217 -0
- package/src/framework/oracle/describe/segment.js +498 -0
- package/src/framework/oracle/describe/snap.js +83 -0
- package/src/framework/oracle/describe/surface-graph.js +396 -0
- package/src/framework/oracle/describe/topology.js +121 -0
- package/src/framework/oracle/describe.js +538 -0
- package/src/lint.js +5 -0
- package/src/testing.js +5 -0
- package/types/lint.d.ts +44 -2
- package/types/testing.d.ts +178 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
// The describe orchestrator: mesh in, semantic report out. Sits beside measure.js and
|
|
2
|
+
// plays the same role for an imported mesh that measure plays for a built part.
|
|
3
|
+
//
|
|
4
|
+
// THE MEMO IS THE POINT OF THE WHOLE CACHING STORY (spec §4.1). measure and verify
|
|
5
|
+
// depend on part source AND params, so they must re-run on every apply. describe depends
|
|
6
|
+
// on NOTHING BUT THE MESH BYTES. So it keys on the import's content digest and an edit
|
|
7
|
+
// can never invalidate it: computed once per mesh per worker, reused for the entire
|
|
8
|
+
// session, across every turn. The memo Map is caller-owned rather than module-level so
|
|
9
|
+
// a worker can scope it to its own lifetime and a test can get a clean one — the same
|
|
10
|
+
// reasoning bvh.js's cachedBVH documents for its cache.
|
|
11
|
+
//
|
|
12
|
+
// Errors come from a CLOSED SET and are returned, never thrown, for anything short of a
|
|
13
|
+
// programming mistake. A mesh describe cannot read is a finding about the mesh, and the
|
|
14
|
+
// caller (a CLI, an agent) can act on `{error: "not-manifold"}` far better than on an
|
|
15
|
+
// exception. Every code has an ERROR-PATTERNS.md entry.
|
|
16
|
+
import { buildTopology } from "./describe/topology.js";
|
|
17
|
+
import { segment } from "./describe/segment.js";
|
|
18
|
+
import { surfaceGraph } from "./describe/surface-graph.js";
|
|
19
|
+
import { detectHoles } from "./describe/features/holes.js";
|
|
20
|
+
import { detectDressups } from "./describe/features/dressups.js";
|
|
21
|
+
import { detectPrismatic } from "./describe/features/prismatic.js";
|
|
22
|
+
import { detectSweeps } from "./describe/features/sweeps.js";
|
|
23
|
+
import { detectPatterns } from "./describe/patterns.js";
|
|
24
|
+
import { snapValue, snapHoleDiameter } from "./describe/snap.js";
|
|
25
|
+
import { acceptCandidates, DEFAULT_ATTEMPT_BUDGET } from "./describe/accept.js";
|
|
26
|
+
import { buildReport } from "./describe/report.js";
|
|
27
|
+
import { buildHints } from "./describe/hints.js";
|
|
28
|
+
import { bounds, meshArea } from "./mesh.js";
|
|
29
|
+
import { buildBVH } from "./bvh.js";
|
|
30
|
+
|
|
31
|
+
export const DESCRIBE_ERRORS = Object.freeze(
|
|
32
|
+
["not-manifold", "too-large", "empty", "budget-exceeded", "unreadable"]);
|
|
33
|
+
|
|
34
|
+
// Above this the segmentation cost stops being worth the wait in an interactive loop.
|
|
35
|
+
// Not a correctness limit — a responsiveness one, reported as `too-large` so the caller
|
|
36
|
+
// can decimate and retry rather than wonder why nothing happened.
|
|
37
|
+
const MAX_TRIANGLES = 400_000;
|
|
38
|
+
|
|
39
|
+
export const describeMemo = () => new Map();
|
|
40
|
+
|
|
41
|
+
// A closed-set error, shaped as the repo's structured diagnostic triple (spec §5, and
|
|
42
|
+
// the same (cause, location, correctiveAction) contract measure/verify emit). The
|
|
43
|
+
// research behind it is blunt about why: structured triples cut average agent retries
|
|
44
|
+
// 2.62 -> 1.86 against the same failures reported as prose. `error` stays a bare code so
|
|
45
|
+
// a caller can switch on it exhaustively.
|
|
46
|
+
const fail = (error, opts, source, cause, location, correctiveAction) => ({
|
|
47
|
+
error,
|
|
48
|
+
detail: cause,
|
|
49
|
+
diagnostic: { cause, location, correctiveAction },
|
|
50
|
+
source: { name: opts.name ?? null, digest: opts.digest ?? null, ...source },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// --- vec3 helpers for orienting acceptance candidates ------------------------
|
|
54
|
+
// toCandidate builds every solid in a canonical local frame (holes and cylinder
|
|
55
|
+
// bosses along local Z; boxes with local Z as depth and local X as one wall) and
|
|
56
|
+
// then rotates it onto the part's OWN frame, wherever a rigid rotation of the
|
|
57
|
+
// whole input mesh happened to put that frame. Kept together, and separate from
|
|
58
|
+
// the per-feature logic below, because every one of them is pure coordinate math
|
|
59
|
+
// with no feature-vocabulary knowledge of its own.
|
|
60
|
+
const dot3 = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
|
|
61
|
+
const cross3 = (a, b) => [a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]];
|
|
62
|
+
const unit3 = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0]/l, a[1]/l, a[2]/l]; };
|
|
63
|
+
const scale3 = (a, s) => [a[0]*s, a[1]*s, a[2]*s];
|
|
64
|
+
const add3 = (a, b) => [a[0]+b[0], a[1]+b[1], a[2]+b[2]];
|
|
65
|
+
// The component of `u` perpendicular to `w` (w assumed unit) — cleans up a wall
|
|
66
|
+
// normal that is perpendicular to the extrusion axis only up to fit residual.
|
|
67
|
+
const orthogonalize = (u, w) => unit3([u[0] - w[0]*dot3(u,w), u[1] - w[1]*dot3(u,w), u[2] - w[2]*dot3(u,w)]);
|
|
68
|
+
|
|
69
|
+
// Aligns a Z-based primitive (a kernel cylinder's own local axis) with an arbitrary
|
|
70
|
+
// unit `dir` — the axis/angle rotation `.rotate(deg, [0,0,0], axis)` needs. A hole's
|
|
71
|
+
// bore can point anywhere once the whole part is rotated (the orientation-invariance
|
|
72
|
+
// test below exercises exactly this), so a candidate cylinder built assuming a world-Z
|
|
73
|
+
// axis would miss a tilted bore entirely: it would still be built, still get measured
|
|
74
|
+
// by acceptCandidates' own xor-volume gain, and would simply score ~0 and never be
|
|
75
|
+
// accepted — a silent, hard-to-diagnose loss of the feature from `suggestion.steps`,
|
|
76
|
+
// not a crash. Returns null when `dir` already IS +Z (no rotation needed) and a
|
|
77
|
+
// 180°-about-X rotation for the -Z case, where the cross product degenerates to zero.
|
|
78
|
+
function alignZTo(dir) {
|
|
79
|
+
const d = unit3(dir);
|
|
80
|
+
const cosT = d[2]; // dot([0,0,1], d)
|
|
81
|
+
const axis = [-d[1], d[0], 0]; // cross([0,0,1], d)
|
|
82
|
+
const sinT = Math.hypot(axis[0], axis[1], axis[2]);
|
|
83
|
+
if (sinT < 1e-9) return cosT > 0 ? null : { axis: [1, 0, 0], deg: 180 };
|
|
84
|
+
return { axis, deg: (Math.atan2(sinT, cosT) * 180) / Math.PI };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Rotates a plain vector by the SAME axis-angle convention `.rotate()` itself uses
|
|
88
|
+
// (manifold-backend.js's own axisAngleMat4 — copied here, not imported, since this
|
|
89
|
+
// file stays kernel-agnostic pure math and the OCCT backend's `.rotate()` honours
|
|
90
|
+
// the identical (deg, center, axis) contract per KERNEL-CONTRACT.md). Used below to
|
|
91
|
+
// work out where a candidate's own local X axis has already landed after the first
|
|
92
|
+
// of two composed rotations, so the second can be computed exactly.
|
|
93
|
+
function rotateVector(v, axis, deg) {
|
|
94
|
+
const [x, y, z] = unit3(axis);
|
|
95
|
+
const t = (deg * Math.PI) / 180, c = Math.cos(t), s = Math.sin(t), C = 1 - c;
|
|
96
|
+
return [
|
|
97
|
+
(c + x*x*C)*v[0] + (x*y*C - z*s)*v[1] + (x*z*C + y*s)*v[2],
|
|
98
|
+
(y*x*C + z*s)*v[0] + (c + y*y*C)*v[1] + (y*z*C - x*s)*v[2],
|
|
99
|
+
(z*x*C - y*s)*v[0] + (z*y*C + x*s)*v[1] + (c + z*z*C)*v[2],
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Degrees to rotate unit vector `a` onto unit vector `b` about `axis`, where both
|
|
104
|
+
// a and b are already perpendicular to `axis` — the "roll" half of orienting a box
|
|
105
|
+
// candidate below. A signed angle (atan2, not acos) because the roll can go either way.
|
|
106
|
+
const angleAbout = (a, b, axis) => (Math.atan2(dot3(cross3(a, b), axis), dot3(a, b)) * 180) / Math.PI;
|
|
107
|
+
|
|
108
|
+
// Rotates `solid` (built with local Z along `direction` and local X along `uAxis`)
|
|
109
|
+
// onto the world orientation those two vectors actually have, as TWO single-axis
|
|
110
|
+
// rotations composed: tip local Z onto `direction` (alignZTo), then roll about
|
|
111
|
+
// `direction` so local X lands on `uAxis`. Two single-axis rotations rather than one
|
|
112
|
+
// general 3x3 basis change because `.rotate()`/`.rotateAbout()` only accept a single
|
|
113
|
+
// axis-angle pair, and Euler's rotation theorem guarantees this composition reaches
|
|
114
|
+
// every orientation a 3x3 change could (recovering axis/angle from an arbitrary
|
|
115
|
+
// rotation matrix directly is the harder, more error-prone equivalent this avoids).
|
|
116
|
+
function orientOnto(solid, direction, uAxis) {
|
|
117
|
+
const tip = alignZTo(direction);
|
|
118
|
+
const tipped = tip ? solid.rotate(tip.deg, [0, 0, 0], tip.axis) : solid;
|
|
119
|
+
const x1 = tip ? rotateVector([1, 0, 0], tip.axis, tip.deg) : [1, 0, 0];
|
|
120
|
+
const rollDeg = angleAbout(x1, uAxis, direction);
|
|
121
|
+
return Math.abs(rollDeg) < 1e-9 ? tipped : tipped.rotate(rollDeg, [0, 0, 0], direction);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Bounding extent of every vertex in `positions` (flat x,y,z triples — either
|
|
125
|
+
// backend's form; duplicated shared vertices don't skew a min/max) projected onto
|
|
126
|
+
// each of three (assumed orthonormal) `axes`. `bounds()` (mesh.js) is the same
|
|
127
|
+
// idea specialised to the world axes; this is its general-frame twin, needed
|
|
128
|
+
// because a rotated part's own extrusion axis is not world Z (segment.js/
|
|
129
|
+
// surface-graph.js already read every OTHER measurement this same way).
|
|
130
|
+
function projectedBounds(positions, axes) {
|
|
131
|
+
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
132
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
133
|
+
const p = [positions[i], positions[i+1], positions[i+2]];
|
|
134
|
+
for (let a = 0; a < 3; a++) {
|
|
135
|
+
const proj = dot3(p, axes[a]);
|
|
136
|
+
if (proj < lo[a]) lo[a] = proj;
|
|
137
|
+
if (proj > hi[a]) hi[a] = proj;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { min: lo, max: hi };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Vertex positions (flat x,y,z triples, duplicates and all — `projectedBounds` only
|
|
144
|
+
// needs a min/max) belonging to ONE feature's own surfaces, not the whole mesh.
|
|
145
|
+
// Round 2 review's CRITICAL finding: a two-box stepped part (two "extrusion"-family
|
|
146
|
+
// features) fed the WHOLE mesh into `projectedBounds` for both candidates, so both
|
|
147
|
+
// came out identically sized to the full-part bbox — 62500mm3 each against a true
|
|
148
|
+
// 38500mm3 combined — and `acceptCandidates` (which greedily takes the better of two
|
|
149
|
+
// near-duplicate candidates and never revisits) accepted one and silently dropped the
|
|
150
|
+
// other: no error, no warning, just missing from `volumeShare`/`suggestion.steps`. A
|
|
151
|
+
// feature's `wallFaces`/`floorFace` name exactly the surfaces that belong to IT, and
|
|
152
|
+
// each surface already carries its own triangle list (`surface-graph.js`'s `faces`),
|
|
153
|
+
// so this reads the actual footprint each feature bounds rather than the part's.
|
|
154
|
+
//
|
|
155
|
+
// `faceScope` (round 3 review, the SAME defect one level down): `mergeCoFamily`
|
|
156
|
+
// (surface-graph.js) folds two patches into one SURFACE whenever they share a fitted
|
|
157
|
+
// plane, whether or not they touch — so a named surface can itself span more than one
|
|
158
|
+
// physical feature (two same-height boss tops, or two different steps' walls that
|
|
159
|
+
// happen to share an x/y coordinate). Reading that surface's WHOLE `faces` list, as
|
|
160
|
+
// this function did before round 3, pulled in a neighbouring feature's own geometry
|
|
161
|
+
// right back into the candidate this fix in round 2 had just scoped per-feature.
|
|
162
|
+
// `prismatic.js`'s `detectPrismatic` now hands back a `faceScope` map (surface id ->
|
|
163
|
+
// JUST the triangles belonging to that specific feature's own island) precisely so
|
|
164
|
+
// this can read the right subset instead; a feature without one (holes, dressups, or
|
|
165
|
+
// a plain single-island cap with topo unavailable) falls back to the surface's whole
|
|
166
|
+
// list, unchanged.
|
|
167
|
+
function surfaceVertices(topo, surfById, ids, faceScope) {
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const id of ids) {
|
|
170
|
+
const surf = surfById.get(id);
|
|
171
|
+
if (!surf) continue;
|
|
172
|
+
const faces = faceScope?.[id] ?? surf.faces;
|
|
173
|
+
for (const t of faces) {
|
|
174
|
+
for (let k = 0; k < 3; k++) {
|
|
175
|
+
const v = topo.tris[3*t + k] * 3;
|
|
176
|
+
out.push(topo.verts[v], topo.verts[v+1], topo.verts[v+2]);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function describe(kernel, solid, opts = {}) {
|
|
184
|
+
// A live Solid in, not a mesh. The kernel exposes no public mesh->solid constructor —
|
|
185
|
+
// geometry only enters through `_registerImport` + `import(name)` — and acceptance needs
|
|
186
|
+
// a Solid to diff against. Both real callers (the worker job and the CLI) already hold
|
|
187
|
+
// one from `k.import(name)`, so taking the Solid and deriving the mesh here is both the
|
|
188
|
+
// honest signature and the shorter path.
|
|
189
|
+
let mesh;
|
|
190
|
+
try {
|
|
191
|
+
mesh = solid.toMesh();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return fail("unreadable", opts, { triangles: 0 },
|
|
194
|
+
`solid.toMesh() threw: ${err?.message ?? err}`,
|
|
195
|
+
`import "${opts.name ?? "?"}"`,
|
|
196
|
+
"the geometry could not be read back off the kernel; re-export the source file and retry");
|
|
197
|
+
}
|
|
198
|
+
// Both backends' toMesh() carries its own triangle count directly (kernel.js's
|
|
199
|
+
// toMesh JSDoc) — no need to re-derive it from positions/indices length.
|
|
200
|
+
const triangles = mesh?.triangles ?? 0;
|
|
201
|
+
if (!triangles) {
|
|
202
|
+
return fail("empty", opts, { triangles: 0 },
|
|
203
|
+
"the mesh has no triangles",
|
|
204
|
+
`import "${opts.name ?? "?"}"`,
|
|
205
|
+
"check that the `imports` source resolves to a real file; see ERROR-PATTERNS.md#describe-empty");
|
|
206
|
+
}
|
|
207
|
+
if (triangles > MAX_TRIANGLES) {
|
|
208
|
+
return fail("too-large", opts, { triangles },
|
|
209
|
+
`${triangles} triangles exceeds the ${MAX_TRIANGLES} describe limit`,
|
|
210
|
+
`import "${opts.name ?? "?"}"`,
|
|
211
|
+
"re-export or decimate at a coarser chord tolerance; the feature rules read surfaces, not facets");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const memo = opts.memo;
|
|
215
|
+
const key = opts.digest ? `${opts.digest}:${opts.budget ?? DEFAULT_ATTEMPT_BUDGET}` : null;
|
|
216
|
+
if (memo && key && memo.has(key)) return memo.get(key);
|
|
217
|
+
|
|
218
|
+
const topo = buildTopology(mesh);
|
|
219
|
+
|
|
220
|
+
// Every edge in a genuine solid is shared by exactly two triangles; a boundary edge
|
|
221
|
+
// (`triB < 0`, topology.js's own convention) means the mesh still has an open seam
|
|
222
|
+
// after vertex-merge and winding repair, so it does not bound a solid and acceptance
|
|
223
|
+
// has nothing to diff against. Checked here, before any of the expensive stages run.
|
|
224
|
+
const openEdges = topo.edges.reduce((n, e) => n + (e.triB < 0 ? 1 : 0), 0);
|
|
225
|
+
if (openEdges > 0) {
|
|
226
|
+
return fail("not-manifold", opts, { triangles, openEdges },
|
|
227
|
+
`${openEdges} open edge${openEdges === 1 ? "" : "s"} after vertex-merge and winding repair`,
|
|
228
|
+
`import "${opts.name ?? "?"}"`,
|
|
229
|
+
"repair the mesh before describing it; see ERROR-PATTERNS.md#describe-not-manifold");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { patches, unassigned } = segment(topo);
|
|
233
|
+
const graph = surfaceGraph(topo, patches);
|
|
234
|
+
|
|
235
|
+
// ONE BVH for this call, shared by every stage that needs one (controller ruling R39).
|
|
236
|
+
// `detectSweeps`'s shell rule raycasts inward from each plane, and `buildBVH` is
|
|
237
|
+
// O(n log n) over the WHOLE mesh regardless of how few rays are cast — measured at 9.8ms
|
|
238
|
+
// on 10.8k triangles and 48ms on 43k. Letting each stage build its own would pay that
|
|
239
|
+
// repeatedly for nothing. This is the same caller-owned-cache pattern `measure.js` uses
|
|
240
|
+
// to stop min-wall and meshGaps indexing the same mesh twice; see `cachedBVH`'s own
|
|
241
|
+
// comment for why a caller-owned Map rather than a module-level WeakMap.
|
|
242
|
+
const bvh = buildBVH({ positions: topo.verts, indices: topo.tris });
|
|
243
|
+
|
|
244
|
+
// Feature families run in a fixed order and their results are concatenated in that
|
|
245
|
+
// order, then sorted by each rule's own geometry-derived `key`. So f-numbering depends
|
|
246
|
+
// on the MESH, never on iteration order or on which family happened to run first.
|
|
247
|
+
const raw = [
|
|
248
|
+
...detectHoles(graph),
|
|
249
|
+
...detectDressups(graph),
|
|
250
|
+
...detectPrismatic(graph, topo),
|
|
251
|
+
...detectSweeps(graph, topo, { bvh }),
|
|
252
|
+
].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
253
|
+
|
|
254
|
+
const features = raw.map((f, i) => {
|
|
255
|
+
const snapped = {};
|
|
256
|
+
if (Number.isFinite(f.diameter)) { const s = snapHoleDiameter(f.diameter); if (s) snapped.diameter = s; }
|
|
257
|
+
for (const k of ["depth", "radius", "width", "thickness"]) {
|
|
258
|
+
if (Number.isFinite(f[k])) { const s = snapValue(f[k]); if (s) snapped[k] = s; }
|
|
259
|
+
}
|
|
260
|
+
return { ...f, id: `f${i}`, snapped };
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const b = bounds(mesh.positions);
|
|
264
|
+
const { patterns, symmetry } = detectPatterns(features, b);
|
|
265
|
+
|
|
266
|
+
// Candidates for acceptance, in the order the rules produced them. `featureKey` is what
|
|
267
|
+
// hints.js joins against to collapse a pattern's members into one step. `surfById` and
|
|
268
|
+
// `topo` let a prismatic candidate orient itself onto THAT FEATURE'S OWN frame and
|
|
269
|
+
// extent (see toCandidate's own comment) instead of assuming the extrusion runs along
|
|
270
|
+
// world Z, or — round 2 review's CRITICAL finding — reading the whole mesh's bounds
|
|
271
|
+
// for every feature and building every candidate the same full-part size.
|
|
272
|
+
const surfById = new Map(graph.surfaces.map((s) => [s.id, s]));
|
|
273
|
+
const candidates = features
|
|
274
|
+
.map((f) => toCandidate(kernel, f, b, { surfById, topo }))
|
|
275
|
+
.filter(Boolean);
|
|
276
|
+
|
|
277
|
+
const graded = acceptCandidates(kernel, solid, candidates, { budget: opts.budget });
|
|
278
|
+
// featureKey -> the candidate `toCandidate` proposed for it, if any — what tells a
|
|
279
|
+
// feature with `volumeShare: null` apart into WHY (fix round 2, IMPORTANT 2, below).
|
|
280
|
+
const candidateByFeatureKey = new Map(candidates.map((c) => [c.featureKey, c]));
|
|
281
|
+
|
|
282
|
+
const totalArea = meshArea(mesh.positions, mesh.indices);
|
|
283
|
+
const explainedArea = totalArea > 0
|
|
284
|
+
? graph.surfaces.reduce((a, s) => a + s.area, 0) / totalArea
|
|
285
|
+
: 0;
|
|
286
|
+
const residualArea = unassigned.reduce((a, t) => a + topo.faceArea[t], 0);
|
|
287
|
+
|
|
288
|
+
const report = buildReport({
|
|
289
|
+
source: { name: opts.name ?? null, digest: opts.digest ?? null, triangles, watertight: true },
|
|
290
|
+
bounds: b,
|
|
291
|
+
surfaces: graph.surfaces.map((s) => ({
|
|
292
|
+
id: s.id, type: s.type, area: s.area, triangles: s.faces.length,
|
|
293
|
+
rms: s.fit.rms, maxDev: s.fit.maxDev, fit: s.fit,
|
|
294
|
+
})),
|
|
295
|
+
arcs: graph.arcs,
|
|
296
|
+
// NAMED `volumeShare`, not `confidence` (round 2 review, IMPORTANT): the value
|
|
297
|
+
// is accept.js's own volume-normalised marginal xor-volume gain — how much of
|
|
298
|
+
// the PART'S VOLUME this feature accounts for, not how sure the description is.
|
|
299
|
+
// A "confidence" label reads backwards for exactly the features a rebuilder
|
|
300
|
+
// most needs to trust: a precise 3mm hole in a large plate is CERTAIN (its fit
|
|
301
|
+
// rms is tiny) but SMALL, so it legitimately reports a low share — see
|
|
302
|
+
// `score.note` for the same point stated for a reader who never gets this far.
|
|
303
|
+
// `faceScope` (prismatic.js) is internal plumbing for THIS file's own candidate
|
|
304
|
+
// builder — a feature's floor/wall surfaces reduced to per-triangle index arrays
|
|
305
|
+
// — and must never reach a model-facing report (round 4 review, IMPORTANT):
|
|
306
|
+
// measured at 31% of an entire compact report's bytes for one feature's own
|
|
307
|
+
// bookkeeping on a 476-triangle fixture, scaling with mesh complexity up to the
|
|
308
|
+
// 400k-triangle MAX_TRIANGLES ceiling. Stripped HERE, not left for buildReport/
|
|
309
|
+
// compactDescribe to remember to delete — those two are downstream of every
|
|
310
|
+
// detector, not just this one, so a future per-triangle field on a different
|
|
311
|
+
// detector would leak the same way unless every consumer had to opt in
|
|
312
|
+
// separately. Chose stripping over a side channel (a second detectPrismatic
|
|
313
|
+
// return value, or a Map keyed by feature key) because `detectPrismatic`'s
|
|
314
|
+
// return shape is a plain feature array read directly by a dozen existing call
|
|
315
|
+
// sites (this file's own tests, describe-features-prismatic.test.js,
|
|
316
|
+
// describe-features-key-stability.test.js) — changing that contract to thread a
|
|
317
|
+
// second value through is a real, unforced breaking change for every one of
|
|
318
|
+
// them, whereas destructuring one field out at its only egress point here is
|
|
319
|
+
// not. `candidates` (below) is built from the PRE-strip `features` array, so
|
|
320
|
+
// toCandidate still reads every feature's own `faceScope`.
|
|
321
|
+
// `volumeShare: null` alone does not say WHY (fix round 2, IMPORTANT 2): three
|
|
322
|
+
// genuinely different situations all used to collapse onto it indistinguishably —
|
|
323
|
+
// a feature type `toCandidate` never proposes at all (fillet/chamfer/revolve/
|
|
324
|
+
// shell — see its own trailing comment), one that WAS proposed but the search
|
|
325
|
+
// never reached before running out of `--budget`, and one that WAS reached and
|
|
326
|
+
// built but never won a round (accept.js's own MIN_GAIN_FRACTION gate, or simply
|
|
327
|
+
// never the best candidate that round). A rebuilder needs to tell these apart —
|
|
328
|
+
// "not modelled by this tool at all" vs. "try a bigger budget" vs. "this genuinely
|
|
329
|
+
// doesn't fit" are different next actions. `volumeShareReason` names which one, a
|
|
330
|
+
// closed set of `"not-proposed" | "budget" | "rejected"`, null only when
|
|
331
|
+
// `volumeShare` itself is non-null (see accept.js's `attempted` Set for exactly
|
|
332
|
+
// what "rejected" does and doesn't distinguish within itself).
|
|
333
|
+
features: features.map(({ faceScope: _faceScope, ...f }) => {
|
|
334
|
+
const accepted = graded.accepted.find((a) => a.candidate.featureKey === f.key);
|
|
335
|
+
if (accepted) return { ...f, volumeShare: accepted.gain, volumeShareReason: null };
|
|
336
|
+
const candidate = candidateByFeatureKey.get(f.key);
|
|
337
|
+
const reason = !candidate ? "not-proposed" : graded.attempted.has(candidate) ? "rejected" : "budget";
|
|
338
|
+
return { ...f, volumeShare: null, volumeShareReason: reason };
|
|
339
|
+
}),
|
|
340
|
+
patterns, symmetry,
|
|
341
|
+
residual: {
|
|
342
|
+
areaFraction: totalArea > 0 ? residualArea / totalArea : 0,
|
|
343
|
+
regions: residualRegions(topo, unassigned),
|
|
344
|
+
},
|
|
345
|
+
score: {
|
|
346
|
+
// TWO DIFFERENT MEASUREMENTS, and conflating them breaks the report's honesty
|
|
347
|
+
// property (controller ruling R45). `explainedArea` is how much of the mesh SURFACE
|
|
348
|
+
// segmentation fitted to some analytic primitive. `explainedVolumeFraction` is how
|
|
349
|
+
// much of the part's SHAPE the accepted features actually reconstruct. They diverge
|
|
350
|
+
// hard: a hemisphere dome segments to 1.0 area coverage and reconstructs 0.0 of its
|
|
351
|
+
// volume, because a sphere is not a candidate-eligible type. Both must be carried —
|
|
352
|
+
// the low-coverage banner gates on the WORSE of the two, since an agent rebuilding a
|
|
353
|
+
// part cares whether the shape is accounted for, not whether primitives were fitted.
|
|
354
|
+
explainedArea,
|
|
355
|
+
explainedVolumeFraction: graded.score.explainedVolumeFraction,
|
|
356
|
+
xorFraction: graded.score.xorFraction,
|
|
357
|
+
xorVolume: graded.score.xorVolume,
|
|
358
|
+
},
|
|
359
|
+
suggestion: buildHints(graded.accepted, patterns, b),
|
|
360
|
+
});
|
|
361
|
+
if (graded.budgetExceeded) report.warning = "budget-exceeded";
|
|
362
|
+
|
|
363
|
+
if (memo && key) memo.set(key, report);
|
|
364
|
+
return report;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Unassigned faces grouped into connected islands, each reported with its own extent.
|
|
368
|
+
// A count alone tells the agent nothing actionable; "290 triangles, here" does.
|
|
369
|
+
function residualRegions(topo, unassigned) {
|
|
370
|
+
const pool = new Set(unassigned), out = [];
|
|
371
|
+
for (const seed of unassigned) {
|
|
372
|
+
if (!pool.has(seed)) continue;
|
|
373
|
+
const stack = [seed], faces = [];
|
|
374
|
+
pool.delete(seed);
|
|
375
|
+
while (stack.length) {
|
|
376
|
+
const t = stack.pop();
|
|
377
|
+
faces.push(t);
|
|
378
|
+
for (const ei of topo.faceEdges[t]) {
|
|
379
|
+
const e = topo.edges[ei];
|
|
380
|
+
const nb = e.triA === t ? e.triB : e.triA;
|
|
381
|
+
if (nb >= 0 && pool.has(nb)) { pool.delete(nb); stack.push(nb); }
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
385
|
+
const c = [0, 0, 0];
|
|
386
|
+
for (const t of faces) for (let k = 0; k < 3; k++) {
|
|
387
|
+
const v = topo.tris[3*t + k] * 3;
|
|
388
|
+
for (let a = 0; a < 3; a++) {
|
|
389
|
+
const val = topo.verts[v + a];
|
|
390
|
+
if (val < lo[a]) lo[a] = val;
|
|
391
|
+
if (val > hi[a]) hi[a] = val;
|
|
392
|
+
c[a] += val / (faces.length * 3);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
out.push({ triangles: faces.length, centroid: c, bounds: { min: lo, max: hi } });
|
|
396
|
+
}
|
|
397
|
+
return out.sort((a, b) => b.triangles - a.triangles);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// One acceptance candidate per feature. `build` is a thunk so nothing is materialised
|
|
401
|
+
// for a candidate the greedy loop never reaches. `ctx.surfById` (a graph.surfaces
|
|
402
|
+
// lookup) and `ctx.topo` (the welded mesh topology, for reading a SPECIFIC feature's
|
|
403
|
+
// own surfaces' vertices via `surfaceVertices` — never the whole mesh's) are what let
|
|
404
|
+
// a candidate read the part's OWN frame and THAT FEATURE'S OWN extent, rather than
|
|
405
|
+
// assuming a frame or reading the whole-mesh bbox for every feature alike; see the
|
|
406
|
+
// extrusion/boss branch's own comment for both failure modes this avoids.
|
|
407
|
+
//
|
|
408
|
+
// kernel.box/kernel.cylinder take OPTIONS OBJECTS ({size:[...]}/{min,max} and {r,h}) —
|
|
409
|
+
// the positional legacy forms are silently accepted by the kernel front-end but resolve
|
|
410
|
+
// to a DIFFERENT signature (box(min,max); cylinder(rBottom,rTop,h)) and hand back a
|
|
411
|
+
// zero-volume solid rather than erroring. Verified directly against a live kernel:
|
|
412
|
+
// `kernel.box(60,40,12).volume()` and `kernel.cylinder(2.65,40).volume()` both read 0.
|
|
413
|
+
function toCandidate(kernel, f, b, ctx) {
|
|
414
|
+
const size = [0,1,2].map((i) => b.max[i] - b.min[i]);
|
|
415
|
+
if (f.type === "throughHole" || f.type === "blindHole") {
|
|
416
|
+
const depth = f.type === "throughHole" ? Math.max(...size) * 2 : f.depth;
|
|
417
|
+
// Oriented along the hole's OWN axis, not world Z — a bore can point anywhere once
|
|
418
|
+
// the part is rotated (alignZTo's own comment has the full reasoning).
|
|
419
|
+
const rot = alignZTo(f.axis.direction);
|
|
420
|
+
return {
|
|
421
|
+
key: f.key, featureKey: f.key, op: "cut", explains: [f.id],
|
|
422
|
+
dimension: f.diameter, paramName: "holeDia", hintOp: "cut",
|
|
423
|
+
hintArgs: { shape: "cylinder", diameter: f.diameter, depth },
|
|
424
|
+
build: () => {
|
|
425
|
+
const cyl = kernel.cylinder({ r: f.diameter / 2, h: depth });
|
|
426
|
+
const oriented = rot ? cyl.rotate(rot.deg, [0, 0, 0], rot.axis) : cyl;
|
|
427
|
+
return oriented.translate([
|
|
428
|
+
f.axis.origin[0] - f.axis.direction[0] * depth / 2,
|
|
429
|
+
f.axis.origin[1] - f.axis.direction[1] * depth / 2,
|
|
430
|
+
f.axis.origin[2] - f.axis.direction[2] * depth / 2,
|
|
431
|
+
]);
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
// Pocket candidates (round 3 review — a widening, not part of the island-merge fix
|
|
436
|
+
// itself): a pocket's own island (floor + walls) bounds its recessed geometry exactly
|
|
437
|
+
// the same way a boss's bounds its protruding geometry — `projectedBounds` reads
|
|
438
|
+
// actual vertex extent, agnostic to which side of the surrounding surface it falls
|
|
439
|
+
// on — so the SAME box/cylinder construction below works unchanged; only `op` (cut,
|
|
440
|
+
// not union) and `hintOp` differ. Previously pockets were described but never
|
|
441
|
+
// proposed as candidates at all (Task 12's original scope note, kept below on the
|
|
442
|
+
// fillet/chamfer/revolve/shell types that still are), which is why a part with a
|
|
443
|
+
// pocket used to always read as at least partially unexplained even when perfectly
|
|
444
|
+
// segmented — not a bug this file introduced, but a gap this fix's own "two
|
|
445
|
+
// same-height pockets" regression test would otherwise misreport.
|
|
446
|
+
if (f.type === "extrusion" || f.type === "boss" || f.type === "pocket") {
|
|
447
|
+
const op = f.type === "pocket" ? "cut" : "union";
|
|
448
|
+
// Oriented onto the part's OWN extrusion frame, not the WORLD-axis-aligned bbox —
|
|
449
|
+
// measured directly against a box+bore fixture rotated 29° about an oblique axis:
|
|
450
|
+
// a bbox-aligned candidate built from the rotated mesh's own (now-larger, tilted)
|
|
451
|
+
// AABB scored a NEGATIVE xor-volume gain (candidate 52796mm³ vs a 28535mm³ source,
|
|
452
|
+
// intersecting only 6632mm³) and was correctly rejected — leaving the base
|
|
453
|
+
// extrusion, and therefore the bore that can only be cut FROM it (acceptCandidates'
|
|
454
|
+
// loop never attempts a `cut` against a null base), unexplained: 0% of the part's
|
|
455
|
+
// volume, not merely a worse fit. Reading the true frame off one of this feature's
|
|
456
|
+
// own wall surfaces (below) reproduces a ~28800mm³ candidate containing ~99.999%
|
|
457
|
+
// of the 28535mm³ source and restores the same ~100% reconstruction the
|
|
458
|
+
// axis-aligned case already gets.
|
|
459
|
+
const direction = unit3(f.direction);
|
|
460
|
+
if (f.profile.kind === "circle") {
|
|
461
|
+
// Rotationally symmetric about its own axis, so — unlike the box branch below —
|
|
462
|
+
// no roll correction is needed, only the axis itself: the same alignZTo a hole
|
|
463
|
+
// uses. Reads the true axis (and its exact base point) off this boss's own
|
|
464
|
+
// cylindrical wall surface when the graph has one; falls back to the bbox-centre
|
|
465
|
+
// approximation only when it doesn't (e.g. a bare disc with no side wall fitted).
|
|
466
|
+
const wallCyl = ctx?.surfById && f.wallFaces
|
|
467
|
+
? f.wallFaces.map((id) => ctx.surfById.get(id)).find((s) => s?.type === "cylinder")
|
|
468
|
+
: null;
|
|
469
|
+
const axisDir = wallCyl ? unit3(wallCyl.fit.axis.direction) : direction;
|
|
470
|
+
// fit.js's `extent` is always [min, max] along the axis (fitCylinder sorts it),
|
|
471
|
+
// so index 0 is the wall's own lower/base end regardless of which way the
|
|
472
|
+
// fitted axis direction happens to point.
|
|
473
|
+
const base = wallCyl
|
|
474
|
+
? add3(wallCyl.fit.axis.origin, scale3(axisDir, wallCyl.fit.extent[0]))
|
|
475
|
+
: [b.min[0] + size[0]/2, b.min[1] + size[1]/2, b.min[2]];
|
|
476
|
+
const rot = alignZTo(axisDir);
|
|
477
|
+
return {
|
|
478
|
+
key: f.key, featureKey: f.key, op, explains: [f.id],
|
|
479
|
+
dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
|
|
480
|
+
hintArgs: { shape: "circle", depth: f.depth },
|
|
481
|
+
build: () => {
|
|
482
|
+
const cyl = kernel.cylinder({ r: f.profile.radius, h: f.depth });
|
|
483
|
+
const oriented = rot ? cyl.rotate(rot.deg, [0, 0, 0], rot.axis) : cyl;
|
|
484
|
+
return oriented.translate(base);
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
// Polygon/mixed profile: `profileOf` (prismatic.js) reports only a POINT COUNT for
|
|
489
|
+
// a polygon, never its vertices, so the footprint's own IN-PLANE rotation (its
|
|
490
|
+
// "roll" about `direction`) cannot be read from the profile fact. It CAN be read
|
|
491
|
+
// off one of this feature's own wall PLANES: `isSideWallOf` (prismatic.js)
|
|
492
|
+
// guarantees every wall normal is already perpendicular to `direction`, which is
|
|
493
|
+
// exactly one of this box's own true edge directions. Depth and footprint size
|
|
494
|
+
// then come from THIS FEATURE'S OWN vertices (its `floorFace` cap plus its
|
|
495
|
+
// `wallFaces`, via `surfaceVertices` — NOT the whole mesh; round 2 review's
|
|
496
|
+
// CRITICAL finding, see that function's own comment for the two-box repro)
|
|
497
|
+
// projected onto that exact (u, v, direction) frame — `projectedBounds`, this
|
|
498
|
+
// file's general-frame twin of mesh.js's `bounds()` — rather than the world-axis
|
|
499
|
+
// bbox `size`/`f.depth` used above for the (rotation-insensitive) circle case.
|
|
500
|
+
const wallPlane = ctx?.surfById && f.wallFaces
|
|
501
|
+
? f.wallFaces.map((id) => ctx.surfById.get(id)).find((s) => s?.type === "plane")
|
|
502
|
+
: null;
|
|
503
|
+
if (!wallPlane || !ctx?.topo) {
|
|
504
|
+
// No wall plane to read a true in-plane axis from (or no topology handed in)
|
|
505
|
+
// — fall back to the axis-aligned bbox approximation, honest about being one:
|
|
506
|
+
// still worth proposing, since a low-gain candidate is simply never accepted
|
|
507
|
+
// (acceptCandidates' own MIN_GAIN_FRACTION gate), never a false positive.
|
|
508
|
+
return {
|
|
509
|
+
key: f.key, featureKey: f.key, op, explains: [f.id],
|
|
510
|
+
dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
|
|
511
|
+
hintArgs: { shape: f.profile.kind, depth: f.depth },
|
|
512
|
+
build: () => kernel.box({ min: b.min, max: [b.min[0] + size[0], b.min[1] + size[1], b.min[2] + f.depth] }),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const u = orthogonalize(wallPlane.fit.normal, direction);
|
|
516
|
+
const v = cross3(direction, u);
|
|
517
|
+
const ownSurfaces = [f.floorFace, ...(f.wallFaces ?? [])].filter(Boolean);
|
|
518
|
+
return {
|
|
519
|
+
key: f.key, featureKey: f.key, op, explains: [f.id],
|
|
520
|
+
dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
|
|
521
|
+
hintArgs: { shape: f.profile.kind, depth: f.depth },
|
|
522
|
+
build: () => {
|
|
523
|
+
const verts = surfaceVertices(ctx.topo, ctx.surfById, ownSurfaces, f.faceScope);
|
|
524
|
+
const bnd = projectedBounds(verts, [u, v, direction]);
|
|
525
|
+
const [uSize, vSize, depth] = [0, 1, 2].map((i) => bnd.max[i] - bnd.min[i]);
|
|
526
|
+
const local = kernel.box({ min: [0, 0, 0], max: [uSize, vSize, depth] });
|
|
527
|
+
const oriented = orientOnto(local, direction, u);
|
|
528
|
+
const origin = add3(add3(scale3(u, bnd.min[0]), scale3(v, bnd.min[1])), scale3(direction, bnd.min[2]));
|
|
529
|
+
return oriented.translate(origin);
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
// Fillets, chamfers, revolves and shells are described but not yet proposed as
|
|
534
|
+
// acceptance candidates: each needs an edge or profile selector the facts layer does
|
|
535
|
+
// not yet carry, and a candidate that cannot be built is worse than none. They still
|
|
536
|
+
// appear in `features` with a null volumeShare, which is the honest report.
|
|
537
|
+
return null;
|
|
538
|
+
}
|
package/src/lint.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
// Public entry for `partforge/lint`. Deliberately separate from `partforge/testing`,
|
|
2
2
|
// whose entry pulls in the WASM kernels and cannot load in a browser sandbox.
|
|
3
3
|
export { lintPart, RULES } from "./framework/lint/index.js";
|
|
4
|
+
// The ids of the rules that read SOURCE rather than the evaluated part. Hosts
|
|
5
|
+
// that gate rendering on lint errors (partforge-cloud's sandbox loader) use
|
|
6
|
+
// this to keep source findings REPORTED but non-blocking: a persistence
|
|
7
|
+
// defect must not stop a legacy part from rendering.
|
|
8
|
+
export { SOURCE_RULE_IDS } from "./framework/lint/rules-source.js";
|
package/src/testing.js
CHANGED
|
@@ -28,3 +28,8 @@ export { minWall } from "./framework/oracle/min-wall.js";
|
|
|
28
28
|
// the same masks and reproduce a score outside the job loop.
|
|
29
29
|
export { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./framework/oracle/silhouette.js";
|
|
30
30
|
export { matchMasks, matchViews } from "./framework/oracle/match.js";
|
|
31
|
+
// The semantic mesh oracle. Worker-reachable like the rest of the oracle (the
|
|
32
|
+
// `describe` job runs it); re-exported so a downstream harness can run it directly.
|
|
33
|
+
export { describe, describeMemo, DESCRIBE_ERRORS } from "./framework/oracle/describe.js";
|
|
34
|
+
export { compactDescribe, LOW_COVERAGE } from "./framework/oracle/describe/report.js";
|
|
35
|
+
export { DESCRIBE_LIMITS } from "./framework/oracle/describe/limits.js";
|
package/types/lint.d.ts
CHANGED
|
@@ -29,6 +29,16 @@ export interface Finding {
|
|
|
29
29
|
* `""` for findings about the definition as a whole. For navigation only.
|
|
30
30
|
*/
|
|
31
31
|
path: string;
|
|
32
|
+
/**
|
|
33
|
+
* Present on source-rule findings: the tree path of the file the finding was
|
|
34
|
+
* read from, as keyed in `sources.files`.
|
|
35
|
+
*/
|
|
36
|
+
file?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Present on source-rule findings: the 1-indexed line of the offending source
|
|
39
|
+
* within `file`.
|
|
40
|
+
*/
|
|
41
|
+
line?: number;
|
|
32
42
|
/** A stable ERROR-PATTERNS.md entry id, when one applies. */
|
|
33
43
|
pattern?: string;
|
|
34
44
|
}
|
|
@@ -42,15 +52,32 @@ export interface LintReport {
|
|
|
42
52
|
notes: Finding[];
|
|
43
53
|
}
|
|
44
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The part's own source text, keyed by tree path — `entrypoint` names the file
|
|
57
|
+
* holding the `PartDefinition` (the first key when omitted). Handing this over
|
|
58
|
+
* unlocks the source rules, which read the text the evaluated definition has
|
|
59
|
+
* already erased.
|
|
60
|
+
*/
|
|
61
|
+
export interface LintSources {
|
|
62
|
+
files: Record<string, string>;
|
|
63
|
+
entrypoint?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
/**
|
|
46
67
|
* Lint a PartDefinition. NEVER throws — a rule that throws yields an
|
|
47
68
|
* `internal-rule-error` warning and the run continues.
|
|
48
69
|
*
|
|
49
70
|
* @param part - the default-exported PartDefinition (deliberately `unknown`:
|
|
50
71
|
* lint's whole job is to be handed something that may not be one).
|
|
51
|
-
* @param opts - `params` are layered over `part.defaults` for the probe pass
|
|
72
|
+
* @param opts - `params` are layered over `part.defaults` for the probe pass;
|
|
73
|
+
* `sources` is the part's own source text. Omitting `sources` (or handing
|
|
74
|
+
* over a malformed one) makes the source rules a silent no-op — they are
|
|
75
|
+
* never a reason for lint to fail.
|
|
52
76
|
*/
|
|
53
|
-
export function lintPart(
|
|
77
|
+
export function lintPart(
|
|
78
|
+
part: unknown,
|
|
79
|
+
opts?: { params?: ResolvedParams; sources?: LintSources } | null,
|
|
80
|
+
): LintReport;
|
|
54
81
|
|
|
55
82
|
/** The shared context a rule reads. */
|
|
56
83
|
export interface LintContext {
|
|
@@ -69,6 +96,13 @@ export interface LintContext {
|
|
|
69
96
|
probeAgain(): unknown;
|
|
70
97
|
/** `verify.expect` resolved once per lint pass. */
|
|
71
98
|
resolveExpectOnce(): unknown;
|
|
99
|
+
/**
|
|
100
|
+
* The normalized `opts.sources`, or `null` when none was handed over (or none
|
|
101
|
+
* survived normalization). The source rules return no findings when it is
|
|
102
|
+
* `null`. Optional: `lintContext` builds the context without it, and the
|
|
103
|
+
* field is assigned separately by `lintPart`.
|
|
104
|
+
*/
|
|
105
|
+
sources?: LintSources | null;
|
|
72
106
|
}
|
|
73
107
|
|
|
74
108
|
export interface LintRule {
|
|
@@ -82,4 +116,12 @@ export interface LintRule {
|
|
|
82
116
|
*/
|
|
83
117
|
export const RULES: LintRule[];
|
|
84
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The ids of the rules that read SOURCE rather than the evaluated part. A host
|
|
121
|
+
* that gates rendering on lint errors uses this to keep source findings
|
|
122
|
+
* REPORTED but non-blocking: a persistence defect is not a reason to refuse to
|
|
123
|
+
* render a part that builds.
|
|
124
|
+
*/
|
|
125
|
+
export const SOURCE_RULE_IDS: ReadonlySet<string>;
|
|
126
|
+
|
|
85
127
|
export type { PartDefinition };
|