partforge 0.79.0 → 0.81.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 +22 -4
- package/docs/AUTHORING-PARTS.md +22 -158
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +1 -1
- package/src/framework/jobs.js +35 -12
- package/src/framework/worker.js +6 -3
- package/src/oracle.js +18 -14
- package/types/oracle.d.ts +6 -11
- package/types/testing.d.ts +15 -177
- package/types/worker.d.ts +10 -2
- package/src/framework/oracle/describe/accept.js +0 -188
- package/src/framework/oracle/describe/features/dressups.js +0 -173
- package/src/framework/oracle/describe/features/holes.js +0 -129
- package/src/framework/oracle/describe/features/prismatic.js +0 -454
- package/src/framework/oracle/describe/features/sweeps.js +0 -233
- package/src/framework/oracle/describe/fit.js +0 -535
- package/src/framework/oracle/describe/hints.js +0 -91
- package/src/framework/oracle/describe/limits.js +0 -19
- package/src/framework/oracle/describe/patterns.js +0 -494
- package/src/framework/oracle/describe/ransac.js +0 -391
- package/src/framework/oracle/describe/report.js +0 -217
- package/src/framework/oracle/describe/segment.js +0 -498
- package/src/framework/oracle/describe/snap.js +0 -83
- package/src/framework/oracle/describe/surface-graph.js +0 -396
- package/src/framework/oracle/describe/topology.js +0 -121
- package/src/framework/oracle/describe.js +0 -538
package/types/testing.d.ts
CHANGED
|
@@ -186,6 +186,21 @@ export function meshVolume(positions: ArrayLike<number>, indices?: ArrayLike<num
|
|
|
186
186
|
/** `[dx, dy, dz]` extent of a flat position array. */
|
|
187
187
|
export function bboxSize(positions: ArrayLike<number>): [number, number, number];
|
|
188
188
|
|
|
189
|
+
/** World-frame axis-aligned bounds of a flat position array. */
|
|
190
|
+
export function bounds(positions: ArrayLike<number>): { min: [number, number, number]; max: [number, number, number] };
|
|
191
|
+
|
|
192
|
+
/** Total surface area of an indexed (or soup, when `indices` is omitted) triangle mesh, mm². */
|
|
193
|
+
export function meshArea(positions: ArrayLike<number>, indices?: ArrayLike<number>): number;
|
|
194
|
+
|
|
195
|
+
/** Triangles as `[v0, v1, v2]` coordinate triples, from an indexed mesh or a soup. */
|
|
196
|
+
export function meshTriangles(mesh: Mesh): [number, number, number][][];
|
|
197
|
+
|
|
198
|
+
/** Parse a binary or ASCII STL into a welded, indexed mesh. */
|
|
199
|
+
export function parseStl(bytes: Uint8Array | ArrayBuffer): { positions: Float32Array; indices: Uint32Array };
|
|
200
|
+
|
|
201
|
+
/** Parse a 3MF archive's first mesh object into a welded, indexed mesh. */
|
|
202
|
+
export function parse3MF(bytes: Uint8Array | ArrayBuffer): { positions: Float32Array; indices: Uint32Array };
|
|
203
|
+
|
|
189
204
|
// --- the BVH ----------------------------------------------------------------
|
|
190
205
|
|
|
191
206
|
/** A triangle BVH over one mesh — nearest ray hit, nearest point, exact mesh distance. */
|
|
@@ -454,183 +469,6 @@ export function matchViews(
|
|
|
454
469
|
opts?: { scaleAware?: boolean },
|
|
455
470
|
): { best: ({ view: string } & MatchScores) | null; views: Record<string, number> };
|
|
456
471
|
|
|
457
|
-
// --- describe (the semantic mesh oracle) ------------------------------------
|
|
458
|
-
|
|
459
|
-
/** A raw measurement snapped to intent — never destroys the measurement. */
|
|
460
|
-
export interface Snapped {
|
|
461
|
-
raw: number;
|
|
462
|
-
to: number;
|
|
463
|
-
note: string | null;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** One fitted surface patch. `fit` is the raw per-type fit record (fit.js). */
|
|
467
|
-
export interface DescribeSurface {
|
|
468
|
-
id: string;
|
|
469
|
-
type: string;
|
|
470
|
-
area: number;
|
|
471
|
-
triangles: number;
|
|
472
|
-
rms: number;
|
|
473
|
-
maxDev: number;
|
|
474
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- shape varies by surface type (plane/cylinder/cone/torus/sphere)
|
|
475
|
-
fit: Record<string, any>;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/** One fitted edge between two surfaces. */
|
|
479
|
-
export interface DescribeArc {
|
|
480
|
-
between: [string, string];
|
|
481
|
-
convexity: "convex" | "concave" | "flat";
|
|
482
|
-
kind: string;
|
|
483
|
-
radius: number | null;
|
|
484
|
-
axis: unknown;
|
|
485
|
-
length: number;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* One recognised feature. Shape varies by `type` (a hole carries `diameter`/
|
|
490
|
-
* `axis`, a fillet carries `radius`/`between`, …) — the fields every family
|
|
491
|
-
* shares are pulled out here; the rest is read by `type`. Every field here
|
|
492
|
-
* must be something a rebuilding agent would want (round 4 review) —
|
|
493
|
-
* `surfaces`/`evidence` are; nothing per-triangle belongs here. describe.js
|
|
494
|
-
* strips any such internal-plumbing field (`faceScope`, a prismatic
|
|
495
|
-
* candidate builder's own per-triangle index map) before a feature reaches
|
|
496
|
-
* this shape, so this catch-all is not a substitute for that discipline.
|
|
497
|
-
*/
|
|
498
|
-
export interface DescribeFeature {
|
|
499
|
-
id: string;
|
|
500
|
-
key: string;
|
|
501
|
-
type: string;
|
|
502
|
-
/** The marginal xor-volume reduction that admitted this feature, normalised
|
|
503
|
-
* to the source volume — the fraction of the PART'S VOLUME this feature
|
|
504
|
-
* accounts for, not a certainty rating (see `DescribeScore`'s own note): a
|
|
505
|
-
* small-but-certain feature legitimately reports a small share. `null` for
|
|
506
|
-
* a type acceptCandidates never proposes (fillet, chamfer, revolve, shell). */
|
|
507
|
-
volumeShare: number | null;
|
|
508
|
-
/** Snapped values for whichever of diameter/depth/radius/width/thickness this feature carries. */
|
|
509
|
-
snapped: Record<string, Snapped>;
|
|
510
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-type facts (axis, profile, evidence, …)
|
|
511
|
-
[key: string]: any;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/** A repetition (grid/linear/circular) or a detected mirror plane over the feature list. */
|
|
515
|
-
export interface DescribePattern {
|
|
516
|
-
id: string;
|
|
517
|
-
type: "grid" | "linear" | "circular";
|
|
518
|
-
members: string[];
|
|
519
|
-
axis: number[] | null;
|
|
520
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-type spacing/count facts
|
|
521
|
-
[key: string]: any;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/** One connected island of mesh area no surface fit claimed. */
|
|
525
|
-
export interface DescribeResidualRegion {
|
|
526
|
-
triangles: number;
|
|
527
|
-
centroid: number[];
|
|
528
|
-
bounds: { min: number[]; max: number[] };
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/** One proposed rebuild step, in the order acceptCandidates actually admitted it. */
|
|
532
|
-
export interface DescribeSuggestionStep {
|
|
533
|
-
op: string;
|
|
534
|
-
explains: string[];
|
|
535
|
-
pattern: string | null;
|
|
536
|
-
score: number;
|
|
537
|
-
args: Record<string, unknown>;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/** A proposed reconstruction — an interpretation, not a measurement (see `disclaimer`). */
|
|
541
|
-
export interface DescribeSuggestion {
|
|
542
|
-
disclaimer: string;
|
|
543
|
-
params: Array<{ name: string; value: number; from: string }>;
|
|
544
|
-
steps: DescribeSuggestionStep[];
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/** Which of the report's capped arrays actually hit their ceiling (`DESCRIBE_LIMITS`). */
|
|
548
|
-
export interface DescribeTruncated {
|
|
549
|
-
surfaces: boolean;
|
|
550
|
-
edges: boolean;
|
|
551
|
-
features: boolean;
|
|
552
|
-
patterns: boolean;
|
|
553
|
-
residualRegions: boolean;
|
|
554
|
-
suggestionSteps: boolean;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
export interface DescribeScore {
|
|
558
|
-
/** Surface coverage from segmentation: fraction of the mesh's area fitted to some surface type. */
|
|
559
|
-
explainedArea: number;
|
|
560
|
-
/** Shape coverage from reconstruction: fraction of the part's volume the accepted features rebuild. */
|
|
561
|
-
explainedVolumeFraction: number;
|
|
562
|
-
xorFraction: number;
|
|
563
|
-
xorVolume: number;
|
|
564
|
-
note: string;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
/** A full `describe()` report — everything measured, meant for archival/`--json`. */
|
|
568
|
-
export interface DescribeReport {
|
|
569
|
-
source: { name: string | null; digest: string | null; triangles: number; watertight: boolean | null; units: "mm" };
|
|
570
|
-
frame: { up: "+Z"; note: string };
|
|
571
|
-
bounds: { min: number[]; max: number[]; size: number[] };
|
|
572
|
-
counts: { surfaces: number; edges: number };
|
|
573
|
-
surfaces: DescribeSurface[];
|
|
574
|
-
edges: DescribeArc[];
|
|
575
|
-
features: DescribeFeature[];
|
|
576
|
-
patterns: DescribePattern[];
|
|
577
|
-
symmetry: unknown[];
|
|
578
|
-
residual: { areaFraction: number; regions: DescribeResidualRegion[] };
|
|
579
|
-
score: DescribeScore;
|
|
580
|
-
suggestion: DescribeSuggestion | null;
|
|
581
|
-
truncated: DescribeTruncated;
|
|
582
|
-
/** Present only when the acceptance loop hit its boolean budget before converging. */
|
|
583
|
-
warning?: "budget-exceeded";
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/** The model-facing view: capped arrays elided to counts, a coverage banner first when low. */
|
|
587
|
-
export type DescribeCompactReport = Omit<DescribeReport, "surfaces" | "edges"> & {
|
|
588
|
-
/** Present, and serialized FIRST, only when coverage is below `LOW_COVERAGE`. */
|
|
589
|
-
warning?: string;
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
/** A closed-set failure, returned rather than thrown (spec §5's diagnostic triple). */
|
|
593
|
-
export interface DescribeFailure {
|
|
594
|
-
error: "not-manifold" | "too-large" | "empty" | "budget-exceeded" | "unreadable";
|
|
595
|
-
detail: string;
|
|
596
|
-
diagnostic: { cause: string; location: string; correctiveAction: string };
|
|
597
|
-
source: { name: string | null; digest: string | null; [key: string]: unknown };
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/** The closed set of error codes `describe()` can return. */
|
|
601
|
-
export const DESCRIBE_ERRORS: readonly string[];
|
|
602
|
-
|
|
603
|
-
/** Report array ceilings (`MAX_SURFACES`, `MAX_FEATURES`, …) — a plain-data module, no imports. */
|
|
604
|
-
export const DESCRIBE_LIMITS: {
|
|
605
|
-
MAX_SURFACES: number;
|
|
606
|
-
MAX_EDGES: number;
|
|
607
|
-
MAX_FEATURES: number;
|
|
608
|
-
MAX_PATTERNS: number;
|
|
609
|
-
MAX_RESIDUAL_REGIONS: number;
|
|
610
|
-
MAX_SUGGESTION_STEPS: number;
|
|
611
|
-
};
|
|
612
|
-
|
|
613
|
-
/** Below this fraction (the worse of `explainedArea`/`explainedVolumeFraction`) `compactDescribe` prepends a warning. */
|
|
614
|
-
export const LOW_COVERAGE: number;
|
|
615
|
-
|
|
616
|
-
/** A fresh, caller-owned digest memo — scope one per worker, or per test. */
|
|
617
|
-
export function describeMemo(): Map<string, DescribeReport>;
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Mesh in, semantic feature report out. `solid` is a LIVE kernel `Solid` — the
|
|
621
|
-
* kernel has no public mesh->solid constructor, so a caller reads one back via
|
|
622
|
-
* `kernel.import(name)`. Keyed by `opts.digest` in `opts.memo` (when both are
|
|
623
|
-
* given): the report depends on nothing but the mesh bytes, so an edit to the
|
|
624
|
-
* part that produced the mesh can never invalidate it.
|
|
625
|
-
*/
|
|
626
|
-
export function describe(
|
|
627
|
-
kernel: GeometryKernel,
|
|
628
|
-
solid: Solid,
|
|
629
|
-
opts?: { name?: string; digest?: string; budget?: number; memo?: Map<string, DescribeReport> },
|
|
630
|
-
): DescribeReport | DescribeFailure;
|
|
631
|
-
|
|
632
|
-
/** The full report, reduced to what a model should read: capped arrays elided to counts, low-coverage banner first. */
|
|
633
|
-
export function compactDescribe(full: DescribeReport): DescribeCompactReport;
|
|
634
472
|
|
|
635
473
|
// --- rendering --------------------------------------------------------------
|
|
636
474
|
|
package/types/worker.d.ts
CHANGED
|
@@ -17,5 +17,13 @@ export interface WorkerHandle {
|
|
|
17
17
|
setPart(newPart: PartDefinition): void;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Run the worker job loop for `part`. Call once, at worker module top level.
|
|
22
|
+
* `opts.loadOracle` injects the closed semantic-mesh-oracle package (a thunk
|
|
23
|
+
* resolving its barrel: `describe`, `describeMemo`, `compactDescribe`); omitted,
|
|
24
|
+
* describe jobs answer with a structured `oracle-unavailable` report.
|
|
25
|
+
*/
|
|
26
|
+
export function runWorker(
|
|
27
|
+
part: PartDefinition,
|
|
28
|
+
opts?: { loadOracle?: () => Promise<{ describe: Function; describeMemo: () => Map<string, unknown>; compactDescribe: Function }> },
|
|
29
|
+
): WorkerHandle;
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
// The confirm half of propose-then-confirm (spec §2.8). Segmentation and the feature
|
|
2
|
-
// rules produce CANDIDATES; this decides which are real, in what order, and how sure
|
|
3
|
-
// we are — by building each one and measuring it against the source mesh.
|
|
4
|
-
//
|
|
5
|
-
// Three properties are load-bearing.
|
|
6
|
-
//
|
|
7
|
-
// ONE CACHE BRACKET. geometry/solid-cache.js scopes retention to the current build's
|
|
8
|
-
// graph: each begin()/end() pair rebuilds the retained set and DISPOSES anything not
|
|
9
|
-
// re-used that round. A search loop that opened a bracket per candidate would evict its
|
|
10
|
-
// own shared subtrees on every iteration — quadratic rebuilds and WASM churn on a part
|
|
11
|
-
// that should be nearly free. So the whole loop runs inside exactly one bracket, and
|
|
12
|
-
// every candidate's geometry stays warm and shared for its duration.
|
|
13
|
-
//
|
|
14
|
-
// HARD BUDGET. Booleans are the cost centre and the candidate list is attacker-shaped
|
|
15
|
-
// (it grows with mesh complexity, not with anything we control). The budget counts
|
|
16
|
-
// CANDIDATE ATTEMPTS, not raw boolean calls — see the loop's own comment below for
|
|
17
|
-
// exactly what one attempt costs in real booleans, which varies by case — and running
|
|
18
|
-
// out DEGRADES INTO RESIDUAL rather than throwing: an over-budget describe returns a
|
|
19
|
-
// partial, honestly-scored report, which is exactly what a caller can act on.
|
|
20
|
-
//
|
|
21
|
-
// CONFIDENCE IS THE GAIN. A feature's confidence is the marginal xor reduction that
|
|
22
|
-
// admitted it, not a separate estimate invented afterwards. That is what makes the
|
|
23
|
-
// number falsifiable — it is a measurement of how much of the part that feature
|
|
24
|
-
// explains.
|
|
25
|
-
//
|
|
26
|
-
// The ONLY kernel-touching file in describe/.
|
|
27
|
-
|
|
28
|
-
// Named for what it actually counts (see the loop's own comment): CANDIDATE ATTEMPTS,
|
|
29
|
-
// not boolean operations. One attempt costs 0-2 real booleans depending on the
|
|
30
|
-
// candidate's op and whether a base body exists yet, so this is a bound on search
|
|
31
|
-
// WORK, not a boolean-op budget a caller could size against a WASM-call cost model.
|
|
32
|
-
export const DEFAULT_ATTEMPT_BUDGET = 48;
|
|
33
|
-
// A candidate must explain at least this fraction of the source volume to be worth a
|
|
34
|
-
// line in the report. Below it, the "feature" is tessellation noise.
|
|
35
|
-
const MIN_GAIN_FRACTION = 1e-4;
|
|
36
|
-
|
|
37
|
-
// Symmetric-difference volume — the same measure measure.js uses for the `reference`
|
|
38
|
-
// deviation fact, so a describe score and a verify ref-gate are directly comparable.
|
|
39
|
-
// One boolean and two volume reads; no meshing, no rasterisation. `cut`/`union`/
|
|
40
|
-
// `intersect` are binary methods ON A SOLID (`a.intersect(b)`), not kernel-level free
|
|
41
|
-
// functions — kernel.js's own JSDoc has the full Solid method table; there is no
|
|
42
|
-
// `kernel.intersect(a, b)`. Neither operand needs `.clone()` first: unlike the OCCT
|
|
43
|
-
// backend (whose replicad shapes ARE consumed by a transform — see AGENTS.md),
|
|
44
|
-
// Manifold's boolean methods return a new solid and leave both operands live and
|
|
45
|
-
// reusable, exactly as measure.js's own `solid.intersect(ref).volume()` and
|
|
46
|
-
// assembly.js's pairwise overlap check already rely on.
|
|
47
|
-
function xorVolume(a, b) {
|
|
48
|
-
const inter = a.intersect(b).volume();
|
|
49
|
-
return a.volume() + b.volume() - 2 * inter;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function acceptCandidates(kernel, source, candidates, opts = {}) {
|
|
53
|
-
const budget = opts.budget ?? DEFAULT_ATTEMPT_BUDGET;
|
|
54
|
-
const sourceVolume = source.volume();
|
|
55
|
-
const accepted = [];
|
|
56
|
-
// Counts CANDIDATE ATTEMPTS (one per pass through the `for` loop below), not real
|
|
57
|
-
// boolean calls — see that loop's own comment for the exact per-attempt cost, which
|
|
58
|
-
// is 0, 1, or 2 real booleans depending on the candidate's op and whether `current`
|
|
59
|
-
// is null. Reported back as `budgetSpent` (name kept as-is — see that field's own
|
|
60
|
-
// comment on why) rather than renamed to `attemptsSpent`.
|
|
61
|
-
let attempts = 0;
|
|
62
|
-
// Every candidate object that reached the loop body at least once — keyed by
|
|
63
|
-
// reference, not by `cand.key`/`cand.featureKey`, since this file never assumes a
|
|
64
|
-
// candidate carries either (test/describe-accept.test.js's own fixtures only give
|
|
65
|
-
// theirs a bare `key`, and other callers may give none at all). This is what lets a
|
|
66
|
-
// caller (describe.js) tell "budget ran out before this candidate ever got a turn"
|
|
67
|
-
// apart from "this candidate got a turn — every round it was in — and never won
|
|
68
|
-
// one" for whatever's left in `pending` at the end (fix round 2, IMPORTANT 2): a
|
|
69
|
-
// rejected feature and a budget-starved one both report `volumeShare: null` and
|
|
70
|
-
// are otherwise indistinguishable, which matters to a rebuilder deciding whether to
|
|
71
|
-
// retry with a bigger `--budget` or accept that a feature genuinely doesn't fit.
|
|
72
|
-
// NOTE on the one case this deliberately does NOT collapse (round 3 CRITICAL fix): a
|
|
73
|
-
// `cut` candidate that gets a turn while `current === null` (`"nothing to cut from
|
|
74
|
-
// yet"`, below) never has a gain computed for it, so it is NOT added here — only a
|
|
75
|
-
// candidate that actually ran a boolean (a real gain measurement) or whose `.build()`
|
|
76
|
-
// genuinely threw counts as attempted. Earlier this Set included the no-base-yet case
|
|
77
|
-
// too, which made a starved `--budget` report `"rejected"` for a feature the search
|
|
78
|
-
// simply never reached with a base to cut from — provably wrong, since raising the
|
|
79
|
-
// budget alone (no code change) turned that same feature into a real, positive share.
|
|
80
|
-
const attempted = new Set();
|
|
81
|
-
|
|
82
|
-
// The single bracket. `describe:accept` is deliberately its own partition name, not a
|
|
83
|
-
// display sub-part's: the cross-partition hash index still lets it ADOPT geometry the
|
|
84
|
-
// viewer already built, while its own eviction at end() cannot throw away what the
|
|
85
|
-
// viewer is showing. Same reasoning as oracle/build.js's `oracle:view:` naming.
|
|
86
|
-
kernel.beginSubPart?.("describe:accept");
|
|
87
|
-
try {
|
|
88
|
-
let current = null; // the reconstruction so far
|
|
89
|
-
let currentXor = sourceVolume; // an empty reconstruction differs by the whole part
|
|
90
|
-
const pending = [...candidates];
|
|
91
|
-
|
|
92
|
-
while (pending.length && attempts < budget) {
|
|
93
|
-
let best = null;
|
|
94
|
-
for (const cand of pending) {
|
|
95
|
-
if (attempts >= budget) break;
|
|
96
|
-
// Real boolean cost of THIS attempt, not the `attempts` counter below (that
|
|
97
|
-
// counts the attempt itself, always by 1, regardless of how many WASM
|
|
98
|
-
// booleans it took) — spelled out here because it is not uniform and a
|
|
99
|
-
// reader sizing the budget against boolean-call cost needs the real number:
|
|
100
|
-
// • op "cut", current === null → 0 booleans (trial is set to null with
|
|
101
|
-
// no kernel call at all — "nothing to cut from yet" — and skipped below)
|
|
102
|
-
// • op "union", current === null → 1 boolean (no union call needed either,
|
|
103
|
-
// trial IS piece; the only boolean is xorVolume's own intersect below)
|
|
104
|
-
// • either op, current !== null → 2 booleans (the cut/union that builds
|
|
105
|
-
// `trial`, plus xorVolume's intersect)
|
|
106
|
-
// So budget=N bounds attempts, and — once any candidate has been accepted,
|
|
107
|
-
// which is the common case for a multi-feature part — real boolean work at
|
|
108
|
-
// roughly 2N, not N. Verified directly: a 4-candidate, 2-op-type search
|
|
109
|
-
// (1 accepted union then 1 accepted cut) reports `budgetSpent: 9` against
|
|
110
|
-
// 12 real boolean calls counted by wrapping the kernel.
|
|
111
|
-
let trial;
|
|
112
|
-
let noBaseYet = false; // "nothing to cut from yet" — no gain measured
|
|
113
|
-
try {
|
|
114
|
-
const piece = cand.build();
|
|
115
|
-
if (current === null && cand.op === "cut") {
|
|
116
|
-
trial = null;
|
|
117
|
-
noBaseYet = true;
|
|
118
|
-
} else {
|
|
119
|
-
trial = current === null ? piece
|
|
120
|
-
: cand.op === "cut" ? current.cut(piece)
|
|
121
|
-
: current.union(piece);
|
|
122
|
-
}
|
|
123
|
-
} catch {
|
|
124
|
-
// A candidate whose geometry will not build is not an error — it is simply
|
|
125
|
-
// not a description of this mesh. Drop it and keep going.
|
|
126
|
-
trial = null;
|
|
127
|
-
}
|
|
128
|
-
attempts++;
|
|
129
|
-
// Only count this as a real attempt (accept.js's own contract with describe.js
|
|
130
|
-
// — see the Set's declaration comment) when a gain was actually measured or the
|
|
131
|
-
// candidate's own geometry genuinely failed to build. `noBaseYet` is neither: no
|
|
132
|
-
// boolean ever ran and no verdict was reached, so leaving it out of `attempted`
|
|
133
|
-
// is what lets describe.js report `"budget"` instead of `"rejected"` for a cut
|
|
134
|
-
// candidate that only ever got a turn before any base body existed — round 3's
|
|
135
|
-
// CRITICAL finding: with the old blanket `attempted.add(cand)` above, THIS is
|
|
136
|
-
// exactly the case that reported `"rejected"` (`--budget 2`, the washer fixture)
|
|
137
|
-
// for a feature that becomes a real 19% share at `--budget 3` — the search never
|
|
138
|
-
// rejected it, it just never got there.
|
|
139
|
-
if (!noBaseYet) attempted.add(cand);
|
|
140
|
-
if (!trial) continue;
|
|
141
|
-
const xor = xorVolume(trial, source);
|
|
142
|
-
const gain = currentXor - xor;
|
|
143
|
-
if (gain > sourceVolume * MIN_GAIN_FRACTION && (!best || gain > best.gain)) {
|
|
144
|
-
best = { cand, trial, xor, gain };
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
if (!best) break; // nothing left improves the reconstruction
|
|
148
|
-
|
|
149
|
-
current = best.trial;
|
|
150
|
-
currentXor = best.xor;
|
|
151
|
-
accepted.push({
|
|
152
|
-
candidate: best.cand,
|
|
153
|
-
gain: best.gain / sourceVolume, // normalised: comparable across parts
|
|
154
|
-
cumulativeXor: currentXor,
|
|
155
|
-
order: accepted.length,
|
|
156
|
-
});
|
|
157
|
-
pending.splice(pending.indexOf(best.cand), 1);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const xorFraction = sourceVolume > 0 ? currentXor / sourceVolume : 1;
|
|
161
|
-
return {
|
|
162
|
-
accepted,
|
|
163
|
-
residual: { xorVolume: currentXor, xorFraction },
|
|
164
|
-
score: {
|
|
165
|
-
explainedVolumeFraction: Math.max(0, 1 - xorFraction),
|
|
166
|
-
xorFraction,
|
|
167
|
-
xorVolume: currentXor,
|
|
168
|
-
},
|
|
169
|
-
// Candidate attempts, not real boolean calls — see `attempts`'s own comment
|
|
170
|
-
// above and the per-attempt cost breakdown in the loop. Kept as `budgetSpent`
|
|
171
|
-
// (not renamed to `attemptsSpent`) because it is a documented cross-task
|
|
172
|
-
// interface field T12's orchestrator consumes by this exact name (SDD
|
|
173
|
-
// progress ledger, T10→T12 interface row); the field's MEANING is what moved,
|
|
174
|
-
// not its shape, so a rename here would be a breaking, undocumented surprise
|
|
175
|
-
// for that consumer rather than a fix.
|
|
176
|
-
budgetSpent: attempts,
|
|
177
|
-
budgetExceeded: attempts >= budget && pending.length > 0,
|
|
178
|
-
// Candidate OBJECTS (not keys) that reached at least one build+evaluate attempt
|
|
179
|
-
// — see this Set's own declaration comment above for why by-reference and what
|
|
180
|
-
// it does and doesn't distinguish. Every accepted candidate is trivially a
|
|
181
|
-
// member too (it can't have been accepted without at least one attempt); the
|
|
182
|
-
// caller only needs to consult this for candidates NOT in `accepted`.
|
|
183
|
-
attempted,
|
|
184
|
-
};
|
|
185
|
-
} finally {
|
|
186
|
-
kernel.endSubPart?.();
|
|
187
|
-
}
|
|
188
|
-
}
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
// Fillet and chamfer rules.
|
|
2
|
-
//
|
|
3
|
-
// Both are TRANSITION surfaces: narrow strips whose job is to soften the meeting of
|
|
4
|
-
// two larger neighbours. That is what distinguishes them from a small functional
|
|
5
|
-
// face, and it is why the rules test the strip's relationship to its neighbours
|
|
6
|
-
// rather than its size alone. A 2mm-wide plane between two walls is a chamfer; a
|
|
7
|
-
// 2mm-wide plane bounded by four other 2mm planes is just a small face.
|
|
8
|
-
//
|
|
9
|
-
// Fillet: cylinder or torus, tangent to both of two PRIMARY neighbours (the arc
|
|
10
|
-
// convexity gives inside vs outside rounding).
|
|
11
|
-
// Chamfer: plane OR cone (a countersink is a revolved chamfer), meeting each
|
|
12
|
-
// primary neighbour at a consistent angle that is neither ~0 nor ~90
|
|
13
|
-
// degrees.
|
|
14
|
-
//
|
|
15
|
-
// PRIMARY neighbours are the two LONGEST-shared-boundary arcs, not "however many
|
|
16
|
-
// arcs this surface has total" (round 1 review: the original premise required
|
|
17
|
-
// `arcs.length === 2`, which no finite straight chamfer/fillet can ever satisfy —
|
|
18
|
-
// a chamfer cut along a box edge of finite length necessarily terminates against
|
|
19
|
-
// two more end faces besides the two walls it actually blends, giving FOUR arcs,
|
|
20
|
-
// not two; verified directly against a chamfered box, whose bevel plane has arcs
|
|
21
|
-
// of length ~30mm to the two walls it blends and ~4.2mm to the two end caps it
|
|
22
|
-
// merely runs into). Selecting by length picks out the two it actually blends and
|
|
23
|
-
// treats the incidental end walls as exactly that: incidental, not disqualifying.
|
|
24
|
-
//
|
|
25
|
-
// TANGENCY, for a cylinder candidate, means its two PRIMARY arcs are STRAIGHT
|
|
26
|
-
// (`kind === "line"`): a constant-radius fillet run along a straight edge is
|
|
27
|
-
// tangent to its neighbours along a line parallel to its own axis. A CIRCULAR
|
|
28
|
-
// primary arc between a cylinder and a plane means the opposite: the cylinder's
|
|
29
|
-
// axis runs perpendicular to that plane, punching straight through it — a bore's
|
|
30
|
-
// or a boss's mouth, not a tangent blend (verified directly against
|
|
31
|
-
// annulusPlate(10,4,3,48): its bore is a plain cylinder with exactly two circular
|
|
32
|
-
// arcs to the two larger cap planes, and without this check it satisfies every
|
|
33
|
-
// other test here and gets reported as a fillet). A doubly-curved torus fillet's
|
|
34
|
-
// own tangent arcs ARE genuinely circular (it blends a curved edge), so this
|
|
35
|
-
// guard is cylinder-only.
|
|
36
|
-
//
|
|
37
|
-
// WIDTH is compared against the primary neighbours' own EXTENT as a length, not
|
|
38
|
-
// an area (round 1 review: the original area-ratio test — this surface's area
|
|
39
|
-
// against each neighbour's — silently drops an ordinary torus fillet as it grows,
|
|
40
|
-
// because the neighbouring end cap shrinks by 2x the fillet radius as the fillet
|
|
41
|
-
// widens, which erodes the AREA ratio far faster than the actual geometry
|
|
42
|
-
// justifies; measured directly: r/R = 0.125 and 0.25 fillets, both completely
|
|
43
|
-
// normal roundovers, missed entirely under the old area test). A dress-up is
|
|
44
|
-
// narrow in ONE dimension, so the comparison is a length against a length: this
|
|
45
|
-
// surface's own width (a fillet's radius; a chamfer's or countersink's area
|
|
46
|
-
// divided by its longest primary arc) against the LARGER of the two primary
|
|
47
|
-
// arcs' own measured radius (circular arcs — the natural "how big is the thing
|
|
48
|
-
// this blends" reference; e.g. a fillet's tangent seam to its shaft lands almost
|
|
49
|
-
// exactly on the shaft's own radius) or length (straight arcs, which have no
|
|
50
|
-
// radius). Reusing the larger of the two, rather than requiring both
|
|
51
|
-
// individually, matters because a fillet's two neighbours differ in this exact
|
|
52
|
-
// reference by very close to the fillet's own radius (a torus fillet's tangent
|
|
53
|
-
// circle on the flat-cap side is smaller than on the shaft side by ~r) — testing
|
|
54
|
-
// against the smaller one would make the threshold tighter than the geometry
|
|
55
|
-
// warrants.
|
|
56
|
-
//
|
|
57
|
-
// Pure leaf. See spec §2.5.
|
|
58
|
-
import { arcsOf } from "../surface-graph.js";
|
|
59
|
-
|
|
60
|
-
// A dress-up must be materially narrower than what it joins, or it is a face in
|
|
61
|
-
// its own right. Ratio, not an absolute size, so it scales with the part.
|
|
62
|
-
// Verified against a torus fillet sweep at r/R = 0.0625, 0.125 and 0.25 (all
|
|
63
|
-
// pass with this threshold; the largest, r=2 against a shaft radius R=8, checks
|
|
64
|
-
// 2 <= 8*0.34 = 2.72) while the washer bore (rejected by the tangency guard
|
|
65
|
-
// above, not by this ratio) stays rejected regardless.
|
|
66
|
-
const MAX_WIDTH_RATIO = 0.34;
|
|
67
|
-
// Chamfer angle band: outside this it is a tangent continuation or a square corner.
|
|
68
|
-
const MIN_CHAMFER_RAD = 0.15, MAX_CHAMFER_RAD = Math.PI / 2 - 0.15;
|
|
69
|
-
const round3 = (v) => Math.round(v * 1000) / 1000;
|
|
70
|
-
|
|
71
|
-
const byId = (graph) => new Map(graph.surfaces.map((s) => [s.id, s]));
|
|
72
|
-
const other = (arc, id) => (arc.between[0] === id ? arc.between[1] : arc.between[0]);
|
|
73
|
-
const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
|
|
74
|
-
// A primary arc's own reference length: its measured circle radius when curved,
|
|
75
|
-
// or its own run length when straight (a straight arc has no radius to offer).
|
|
76
|
-
const arcExtent = (arc) => arc.radius ?? arc.length;
|
|
77
|
-
|
|
78
|
-
// A neighbour's own fitted geometry, not its segmentation surface id (round 2
|
|
79
|
-
// review: `s0`/`s1` are assigned in triangle-DISCOVERY order — the same defect
|
|
80
|
-
// prismatic.js's key had — so they renumber under a triangle-order permutation
|
|
81
|
-
// of the same geometry, which must never change what a fillet/chamfer's own
|
|
82
|
-
// key is). Covers every surface type a dress-up's primary neighbour can be;
|
|
83
|
-
// `round3` absorbs the float-associativity noise a permuted summation/fit
|
|
84
|
-
// order introduces (Task 4's own ruling, R29) — real geometry never differs at
|
|
85
|
-
// 3-decimal precision, only float dust does.
|
|
86
|
-
const vec3 = (a) => a.map(round3).join(",");
|
|
87
|
-
function surfaceSignature(s) {
|
|
88
|
-
if (s.type === "plane") return `plane:${round3(s.fit.offset)}:${vec3(s.fit.normal)}`;
|
|
89
|
-
if (s.type === "cylinder") return `cylinder:${round3(s.fit.radius)}:${vec3(s.fit.axis.direction)}:${vec3(s.fit.axis.origin)}`;
|
|
90
|
-
if (s.type === "cone") return `cone:${round3(s.fit.halfAngle)}:${vec3(s.fit.apex)}:${vec3(s.fit.direction)}`;
|
|
91
|
-
if (s.type === "torus") return `torus:${round3(s.fit.majorRadius)}:${round3(s.fit.minorRadius)}:${vec3(s.fit.center)}`;
|
|
92
|
-
return `${s.type}:${round3(s.area)}`; // any future surface type
|
|
93
|
-
}
|
|
94
|
-
// "|", not "-": a signature can itself contain "-" (a negative coordinate), and
|
|
95
|
-
// this only ever needs to be a stable SORT key, never parsed back apart.
|
|
96
|
-
const sortSignatures = (nbrs) => nbrs.map(surfaceSignature).sort().join("|");
|
|
97
|
-
|
|
98
|
-
export function detectDressups(graph) {
|
|
99
|
-
const surfaces = byId(graph);
|
|
100
|
-
const out = [];
|
|
101
|
-
|
|
102
|
-
for (const s of graph.surfaces) {
|
|
103
|
-
const arcs = arcsOf(graph, s.id);
|
|
104
|
-
if (arcs.length < 2) continue;
|
|
105
|
-
|
|
106
|
-
// The two PRIMARY neighbours: the longest-shared-boundary arcs. Any other
|
|
107
|
-
// arcs (a finite chamfer/fillet's incidental end walls) are ignored below.
|
|
108
|
-
const primary = [...arcs].sort((a, b) => b.length - a.length).slice(0, 2);
|
|
109
|
-
const nbrs = primary.map((a) => surfaces.get(other(a, s.id)));
|
|
110
|
-
if (nbrs.some((n) => !n)) continue;
|
|
111
|
-
|
|
112
|
-
if (s.type === "cylinder" && !primary.every((a) => a.kind === "line")) continue;
|
|
113
|
-
|
|
114
|
-
const extent = Math.max(...primary.map(arcExtent));
|
|
115
|
-
const fitsAsStrip = (width) => width <= extent * MAX_WIDTH_RATIO;
|
|
116
|
-
|
|
117
|
-
if (s.type === "cylinder" || s.type === "torus") {
|
|
118
|
-
const radius = s.type === "cylinder" ? s.fit.radius : s.fit.minorRadius;
|
|
119
|
-
if (!fitsAsStrip(radius)) continue;
|
|
120
|
-
out.push({
|
|
121
|
-
id: null,
|
|
122
|
-
key: `fillet:${round3(radius)}:${sortSignatures(nbrs)}`,
|
|
123
|
-
type: "fillet", radius,
|
|
124
|
-
between: nbrs.map((n) => n.id),
|
|
125
|
-
convexity: primary[0].convexity,
|
|
126
|
-
surfaces: [s.id],
|
|
127
|
-
evidence: { arcs: arcs.length, widthRatio: round3(radius / extent), fitRms: s.fit.rms },
|
|
128
|
-
});
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (s.type === "plane" && nbrs.every((n) => n.type === "plane")) {
|
|
133
|
-
const angles = nbrs.map((n) => Math.acos(Math.max(-1, Math.min(1, Math.abs(dot(s.fit.normal, n.fit.normal))))));
|
|
134
|
-
if (!angles.every((a) => a > MIN_CHAMFER_RAD && a < MAX_CHAMFER_RAD)) continue;
|
|
135
|
-
// Strip width from the area and the longer of the two arcs — a chamfer is a
|
|
136
|
-
// ribbon, so area/length is its width.
|
|
137
|
-
const width = s.area / Math.max(primary[0].length, primary[1].length, 1e-9);
|
|
138
|
-
if (!fitsAsStrip(width)) continue;
|
|
139
|
-
out.push({
|
|
140
|
-
id: null,
|
|
141
|
-
key: `chamfer:${round3(width)}:${sortSignatures(nbrs)}`,
|
|
142
|
-
type: "chamfer", width, angle: (angles[0] + angles[1]) / 2,
|
|
143
|
-
between: nbrs.map((n) => n.id),
|
|
144
|
-
convexity: primary[0].convexity,
|
|
145
|
-
surfaces: [s.id],
|
|
146
|
-
evidence: { arcs: arcs.length, angles: angles.map(round3), fitRms: s.fit.rms },
|
|
147
|
-
});
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// A countersink is a REVOLVED chamfer: a cone blending a bore into a face
|
|
152
|
-
// (or another cone) instead of a plane blending two walls. Its own conical
|
|
153
|
-
// half-angle already IS its blend angle (fit.js's fitCone recovers it
|
|
154
|
-
// directly), so unlike the plane branch above there's no pair of face
|
|
155
|
-
// normals to average — the cone's `halfAngle` cashes out to the same
|
|
156
|
-
// "angle from the material" the plane branch measures.
|
|
157
|
-
if (s.type === "cone") {
|
|
158
|
-
const width = s.area / Math.max(primary[0].length, primary[1].length, 1e-9);
|
|
159
|
-
if (!fitsAsStrip(width)) continue;
|
|
160
|
-
out.push({
|
|
161
|
-
id: null,
|
|
162
|
-
key: `chamfer:${round3(width)}:${sortSignatures(nbrs)}`,
|
|
163
|
-
type: "chamfer", width, angle: s.fit.halfAngle,
|
|
164
|
-
between: nbrs.map((n) => n.id),
|
|
165
|
-
convexity: primary[0].convexity,
|
|
166
|
-
surfaces: [s.id],
|
|
167
|
-
evidence: { arcs: arcs.length, halfAngle: round3(s.fit.halfAngle), fitRms: s.fit.rms },
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
173
|
-
}
|