@rig-c/parts 0.0.0-stage → 1.5.4
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/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- package/workflows/seethrough.json +68 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The automatic mode's acceptance loop with a replay (issue #126, rigc#1266
|
|
3
|
+
* mechanism 2, rig-c 2.24.0): what the rig stage does with an `auto` part
|
|
4
|
+
* whose full reduction is refused by the motion gate (`src/automotion.ts`).
|
|
5
|
+
*
|
|
6
|
+
* rig-c's `reduceMesh` reports `changes.acceptedAt`, one entry per accepted
|
|
7
|
+
* operation — `{ step, kind, count, sourceVertices }`, `step` the attempt
|
|
8
|
+
* number it was taken at and `kind` a refinement `insertion`, a single
|
|
9
|
+
* `removal` or a `boundary-run` (from rig-c 2.25.0, rigc#1279; before it the
|
|
10
|
+
* entries were the bare attempt numbers) — and takes `stopAfterAccepted: n`,
|
|
11
|
+
* which returns byte for byte the mesh the same call held after its n-th
|
|
12
|
+
* accepted operation, terminated `replayed-to-accepted-step` (the contract:
|
|
13
|
+
* rig-c's docs/MESH_REDUCTION.md §2 and §7, *Mechanism 2 — implemented*).
|
|
14
|
+
* Every step below is an operation, not a vertex: a boundary run removes two
|
|
15
|
+
* or more vertices in one step. The choosing is this package's (mechanism 3,
|
|
16
|
+
* agreed on issue #126 as Q8), and it is done this way:
|
|
17
|
+
*
|
|
18
|
+
* 1. **The domain.** N = `acceptedAt.length` operations; the first I of them
|
|
19
|
+
* are the refinement's insertions ({@link refinementSteps}: the entries up
|
|
20
|
+
* to the last of kind `insertion` — rig-c refines inside the declared
|
|
21
|
+
* regions, then removes). A step at or below I is the
|
|
22
|
+
* source, refined or not, and is never written as an automatic result —
|
|
23
|
+
* writing the source would silently defeat the mode, so a search that finds
|
|
24
|
+
* no removal step passing is refused, "no reduction passes the motion
|
|
25
|
+
* bound" ({@link noReductionProblem}). Step N is the full result, which the
|
|
26
|
+
* gate already refused. So only the removal steps I+1 .. N−1 are probed, and
|
|
27
|
+
* a replay inside the refinement — which rig-c returns unaccepted, a
|
|
28
|
+
* refinement step not being required to meet the targets — is never asked
|
|
29
|
+
* for.
|
|
30
|
+
* 2. **The search** ({@link bisectAccepted}): a bisection on n over (I, N),
|
|
31
|
+
* lo = I taken as passing and hi = N as failing; each probe replays step
|
|
32
|
+
* n, holds it to the same acceptance as the full result ({@link
|
|
33
|
+
* replayVerdict}: the termination rig-c promised, every declared geometry
|
|
34
|
+
* row passing), compiles it and compares it with the source on the idle's
|
|
35
|
+
* **grid** frames only ({@link selectionSchedule}: the schedule walks no
|
|
36
|
+
* irr frame at all, so none can inform the choice). It keeps the largest n
|
|
37
|
+
* it saw pass. Validity along the order is not monotone (the contract's M3
|
|
38
|
+
* measured it), so the search finds *a* passing prefix by bisection, not
|
|
39
|
+
* necessarily the last, and the row says so ({@link REPLAY_RULE}). Its
|
|
40
|
+
* length is at most ⌈log₂(N − I)⌉ replays ({@link maxReplays}) — derived
|
|
41
|
+
* from the domain, not an author's number; no config knob exists.
|
|
42
|
+
* 3. **The acceptance.** The chosen replay is written into the rig, which goes
|
|
43
|
+
* through rigc's gate again as written, and is compared with the source on
|
|
44
|
+
* the whole idle — grid and irr — with the grid frames declared
|
|
45
|
+
* `selection` and the irr frames held out ({@link splitSchedule};
|
|
46
|
+
* `heldOutClaim` true, by rig-c's walk). It is accepted only when rig-c's
|
|
47
|
+
* verdict is `accepted` on every frame, the held-out ones included;
|
|
48
|
+
* otherwise {@link heldOutProblem} names the selection value and the held-out
|
|
49
|
+
* value with the worst frame of each. Nothing is written otherwise.
|
|
50
|
+
*
|
|
51
|
+
* The probe — a replay, a build, a comparison — runs in the rig stage
|
|
52
|
+
* (`src/build.ts`), the one place this package runs rigc. Pure: no clock, no
|
|
53
|
+
* randomness, nothing read or written.
|
|
54
|
+
*/
|
|
55
|
+
import type { AcceptedOperation, MeasureRow, MeshQualityReport, MotionSchedule, ReducedMesh } from 'rig-c/mesh';
|
|
56
|
+
import { autoVerdict, terminationText } from './automesh.ts';
|
|
57
|
+
import { idleSchedule, localRow, motionRowText } from './automotion.ts';
|
|
58
|
+
import type { Problem } from './errors.ts';
|
|
59
|
+
|
|
60
|
+
/** What the row and the build line say the search is, in so many words. */
|
|
61
|
+
export const REPLAY_RULE =
|
|
62
|
+
'bisection over acceptedAt by stopAfterAccepted (rigc#1266 mechanism 2): removal steps only, selected on the idle grid frames with the irr frames held out, accepted on the whole idle; it finds a passing prefix, not necessarily the last (validity along the order is not monotone)';
|
|
63
|
+
|
|
64
|
+
/** The idle's `grid` frame ids, in walk order, from the frames rig-c walked in `report` — never typed, never the irr ones. */
|
|
65
|
+
export function gridFrameIds(report: MeshQualityReport): string[] {
|
|
66
|
+
return (report.candidates[0]?.motion?.schedule.walked ?? []).filter((f) => f.phase === 'grid').map((f) => f.id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The search's schedule: {@link idleSchedule} walking the `grid` phase alone, every frame of it `selection`. */
|
|
70
|
+
export function selectionSchedule(grid: readonly string[]): MotionSchedule {
|
|
71
|
+
const s = idleSchedule();
|
|
72
|
+
return { ...s, phases: ['grid'], selection: [...grid] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The final acceptance's schedule: {@link idleSchedule} whole — grid and irr — with the grid frames `selection`, so every irr frame is held out. */
|
|
76
|
+
export function splitSchedule(grid: readonly string[]): MotionSchedule {
|
|
77
|
+
return { ...idleSchedule(), selection: [...grid] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* I: the operations of `acceptedAt` that are the refinement's insertions — the
|
|
82
|
+
* entries up to and including the last of kind `insertion`, found by `kind`
|
|
83
|
+
* and not by a count of vertices (rigc#1279: an operation may remove several).
|
|
84
|
+
* rig-c refines first and then removes, so they are a prefix; 0 when there is
|
|
85
|
+
* none.
|
|
86
|
+
*/
|
|
87
|
+
export function refinementSteps(acceptedAt: readonly AcceptedOperation[]): number {
|
|
88
|
+
for (let i = acceptedAt.length - 1; i >= 0; i--) if (acceptedAt[i].kind === 'insertion') return i + 1;
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Two accepted operations are the same operation: the same attempt number, kind, count and source vertices in order. */
|
|
93
|
+
export function sameOperation(a: AcceptedOperation, b: AcceptedOperation): boolean {
|
|
94
|
+
return a.step === b.step && a.kind === b.kind && a.count === b.count && a.sourceVertices.length === b.sourceVertices.length && a.sourceVertices.every((v, i) => v === b.sourceVertices[i]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** An operation in a phrase: `12 removal [7]`, `40 boundary-run x3 [3, 4, 5]`. */
|
|
98
|
+
export function operationText(o: AcceptedOperation | undefined): string {
|
|
99
|
+
if (o === undefined) return 'none';
|
|
100
|
+
return `${o.step} ${o.kind}${o.count === 1 ? '' : ` x${o.count}`}${o.sourceVertices.length === 0 ? '' : ` [${o.sourceVertices.join(', ')}]`}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The most replays a bisection over the removal steps (I, N) takes: ⌈log₂(N − I)⌉, 0 when there is no step strictly between. */
|
|
104
|
+
export function maxReplays(accepted: number, inserted: number): number {
|
|
105
|
+
const span = accepted - inserted;
|
|
106
|
+
return span <= 1 ? 0 : Math.ceil(Math.log2(span));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One probe of the search: the step replayed and what it read on the selection frames. */
|
|
110
|
+
export interface ReplayProbe {
|
|
111
|
+
step: number;
|
|
112
|
+
/** `pass`: accepted on selection; `fail`: compared and not accepted; `refused`: the replay or its build was refused before a comparison. */
|
|
113
|
+
verdict: 'pass' | 'fail' | 'refused';
|
|
114
|
+
/** The attachment-level `MQ_LOCAL_DEFORMATION` on the selection frames, or null when nothing was compared. */
|
|
115
|
+
value: number | null;
|
|
116
|
+
frame: string | null;
|
|
117
|
+
/** Why a `refused` probe was refused; null otherwise. */
|
|
118
|
+
reason: string | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The bisection (module header, step 2): lo = `lo` taken as passing, hi = `hi`
|
|
123
|
+
* as failing; while a step lies strictly between, probe the middle one
|
|
124
|
+
* (rounded down) and move lo up to it when it passes, hi down to it when it does
|
|
125
|
+
* not. Returns the largest step seen to pass — `lo` itself when none did — and
|
|
126
|
+
* every probe in the order taken. Deterministic: the order depends only on
|
|
127
|
+
* the answers.
|
|
128
|
+
*/
|
|
129
|
+
export function bisectAccepted<P extends ReplayProbe>(lo: number, hi: number, probe: (step: number) => P): { chosen: number; probes: P[] } {
|
|
130
|
+
const probes: P[] = [];
|
|
131
|
+
let a = lo;
|
|
132
|
+
let b = hi;
|
|
133
|
+
while (b - a > 1) {
|
|
134
|
+
const mid = Math.floor((a + b) / 2);
|
|
135
|
+
const p = probe(mid);
|
|
136
|
+
probes.push(p);
|
|
137
|
+
if (p.verdict === 'pass') a = mid;
|
|
138
|
+
else b = mid;
|
|
139
|
+
}
|
|
140
|
+
return { chosen: a, probes };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The acceptance of one replay (module header, steps 1–2): the termination the
|
|
145
|
+
* contract promises for `stopAfterAccepted: step` — `replayed-to-accepted-step`
|
|
146
|
+
* with `acceptedSteps` the step and `candidatesTried` the full run's
|
|
147
|
+
* `acceptedAt[step − 1].step`, its own `acceptedAt` the full run's first `step`
|
|
148
|
+
* operations, each the same operation ({@link sameOperation}) —
|
|
149
|
+
* and then {@link autoVerdict}'s rule, the full result's: the candidate
|
|
150
|
+
* `accepted` with every declared geometry row passing. A replay that breaks the
|
|
151
|
+
* promise is `AUTO_MESH_TERMINATION` naming the step and the difference; one
|
|
152
|
+
* that keeps it but does not meet the targets is `AUTO_MESH_ACCEPTED`, as the
|
|
153
|
+
* full result would be.
|
|
154
|
+
*/
|
|
155
|
+
export function replayVerdict(
|
|
156
|
+
object: string,
|
|
157
|
+
step: number,
|
|
158
|
+
acceptedAt: readonly AcceptedOperation[],
|
|
159
|
+
result: { mesh: ReducedMesh | null; report: MeshQualityReport },
|
|
160
|
+
): { accepted: true; mesh: ReducedMesh; candidatesTried: number } | { accepted: false; problem: Problem } {
|
|
161
|
+
const t = result.report.termination;
|
|
162
|
+
const own = result.report.candidates[0]?.changes?.acceptedAt ?? null;
|
|
163
|
+
const want = acceptedAt[step - 1]?.step;
|
|
164
|
+
const broken =
|
|
165
|
+
want === undefined
|
|
166
|
+
? `was asked for a step the full run never took (it accepted ${acceptedAt.length} operation(s))`
|
|
167
|
+
: t === null || t.reason !== 'replayed-to-accepted-step'
|
|
168
|
+
? `ended ${terminationText(t)}`
|
|
169
|
+
: t.acceptedSteps !== step
|
|
170
|
+
? `reports acceptedSteps ${t.acceptedSteps}`
|
|
171
|
+
: t.candidatesTried !== want
|
|
172
|
+
? `reports candidatesTried ${t.candidatesTried} where the full run's acceptedAt[${step - 1}].step is ${want}`
|
|
173
|
+
: own === null || own.length !== step || own.some((o, i) => !sameOperation(o, acceptedAt[i]))
|
|
174
|
+
? `reports acceptedAt of ${(own ?? []).length} operation(s) (first differing: ${operationText((own ?? []).find((o, i) => acceptedAt[i] === undefined || !sameOperation(o, acceptedAt[i])))}), not the full run's first ${step}`
|
|
175
|
+
: null;
|
|
176
|
+
if (broken !== null || want === undefined) {
|
|
177
|
+
return {
|
|
178
|
+
accepted: false,
|
|
179
|
+
problem: {
|
|
180
|
+
code: 'AUTO_MESH_TERMINATION',
|
|
181
|
+
object,
|
|
182
|
+
detail: `rig-c's reduceMesh with stopAfterAccepted ${step} ${broken}; the contract (rigc#1268) promises replayed-to-accepted-step with acceptedSteps ${step} and candidatesTried ${want ?? 'the full run\'s acceptedAt[n - 1].step'}, and a replay that breaks it is not a candidate`,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const v = autoVerdict(object, result);
|
|
187
|
+
if (!v.accepted) return v;
|
|
188
|
+
return { accepted: true, mesh: v.mesh, candidatesTried: want };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** One role's reading of the attachment-level local-deformation row: value, state and worst frame, or null when that role walked no frame. */
|
|
192
|
+
export interface RoleReading {
|
|
193
|
+
value: number | null;
|
|
194
|
+
state: MeasureRow['state'];
|
|
195
|
+
frame: string | null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The local-deformation row's `selection` and `held-out` readings (rig-c's `byRole`). */
|
|
199
|
+
export function roleReadings(report: MeshQualityReport): { selection: RoleReading | null; held_out: RoleReading | null; bound: { op: '<=' | '>='; value: number } | null } {
|
|
200
|
+
const r = localRow(report);
|
|
201
|
+
const by = r?.motion?.byRole;
|
|
202
|
+
const of = (x: { value: number | null; state: MeasureRow['state']; frame: string | null } | null | undefined): RoleReading | null => (x === null || x === undefined ? null : { value: x.value, state: x.state, frame: x.frame });
|
|
203
|
+
return { selection: of(by?.selection), held_out: of(by?.heldOut), bound: r?.bound ?? null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A role's reading in a phrase: `0.93 <= 1 at idle@grid@1`, the operator turned when it fails. */
|
|
207
|
+
export function readingText(r: RoleReading | null, bound: { op: '<=' | '>='; value: number } | null): string {
|
|
208
|
+
if (r === null) return 'not walked';
|
|
209
|
+
if (r.value === null || bound === null) return `${r.state}`;
|
|
210
|
+
const op = r.state === 'pass' ? bound.op : bound.op === '<=' ? '>' : '<';
|
|
211
|
+
return `${r.value} ${op} ${bound.value} at ${r.frame ?? 'every frame'}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The probes in one phrase: `step 12 pass 0.4 at …, step 18 fail 1.2 at …`. */
|
|
215
|
+
export function probesText(probes: readonly ReplayProbe[]): string {
|
|
216
|
+
if (probes.length === 0) return 'no step lies strictly between the source and the full result';
|
|
217
|
+
return probes.map((p) => `step ${p.step} ${p.verdict}${p.value === null ? '' : ` ${p.value} at ${p.frame ?? 'every frame'}`}${p.reason === null ? '' : ` (${p.reason})`}`).join(', ');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The refusal when the search keeps no removal step (module header, step 1):
|
|
222
|
+
* the source is not written as an automatic result. Names the full result's
|
|
223
|
+
* reading, the domain and every probe.
|
|
224
|
+
*/
|
|
225
|
+
export function noReductionProblem(object: string, full: string, accepted: number, inserted: number, probes: readonly ReplayProbe[]): Problem {
|
|
226
|
+
return {
|
|
227
|
+
code: 'AUTO_MESH_MOTION',
|
|
228
|
+
object,
|
|
229
|
+
detail:
|
|
230
|
+
`no reduction passes the motion bound: the full result (accepted step ${accepted} of ${accepted}) ${full}; a bisection over the removal steps ${inserted + 1}..${accepted - 1} by stopAfterAccepted, ` +
|
|
231
|
+
`selected on the idle grid frames with the irr frames held out, kept none (${probesText(probes)}); the source${inserted > 0 ? `, refined or not (steps 0..${inserted}),` : ' (step 0)'} is not an automatic result, and nothing is built in its place — raise the motion bound, protect the vertices that carry the motion (protect.weightJump), or put the part in contour mode`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The refusal of a chosen replay that the whole idle does not accept: the selection and held-out values, the worst frame of each, and every failing row. */
|
|
236
|
+
export function heldOutProblem(object: string, step: number, accepted: number, report: MeshQualityReport, failing: string): Problem {
|
|
237
|
+
const r = roleReadings(report);
|
|
238
|
+
return {
|
|
239
|
+
code: 'AUTO_MESH_MOTION',
|
|
240
|
+
object,
|
|
241
|
+
detail:
|
|
242
|
+
`the replay to accepted step ${step} of ${accepted}, chosen on the idle grid frames, is not accepted on the whole idle: selection ${readingText(r.selection, r.bound)}; held out ${readingText(r.held_out, r.bound)} (${failing}); ` +
|
|
243
|
+
'a candidate chosen on some frames must pass on the frames it was not chosen by, and nothing is built in its place',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The final acceptance of a chosen replay (module header, step 3): null when
|
|
249
|
+
* rig-c accepts it on the whole schedule — every frame, the held-out ones
|
|
250
|
+
* included — else {@link heldOutProblem} with every gated row that did not pass.
|
|
251
|
+
*/
|
|
252
|
+
export function finalVerdict(object: string, step: number, accepted: number, report: MeshQualityReport): Problem | null {
|
|
253
|
+
const c = report.candidates[0];
|
|
254
|
+
if (c !== undefined && c.accepted) return null;
|
|
255
|
+
const failing = [...(c?.geometry?.rows ?? []), ...(c?.motion?.rows ?? [])].filter((r) => r.bound !== null && r.state !== 'pass');
|
|
256
|
+
return heldOutProblem(object, step, accepted, report, failing.length > 0 ? failing.map(motionRowText).join('; ') : 'a required motion row was not measured');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** What `mesh_report.json`'s row carries about a search that chose a replay (`replay`, before `deformation`). */
|
|
260
|
+
export interface ReplayRow {
|
|
261
|
+
rule: string;
|
|
262
|
+
/** The full run's `acceptedAt` length: N operations. */
|
|
263
|
+
accepted_steps: number;
|
|
264
|
+
/** The refinement's insertions among them: I (operations 1..I, {@link refinementSteps}). */
|
|
265
|
+
refinement_steps: number;
|
|
266
|
+
/** The operation written: I < n < N. */
|
|
267
|
+
chosen_step: number;
|
|
268
|
+
/** `reduceMesh` calls with `stopAfterAccepted`: one per probe. */
|
|
269
|
+
replays: number;
|
|
270
|
+
/** ⌈log₂(N − I)⌉ ({@link maxReplays}). */
|
|
271
|
+
max_replays: number;
|
|
272
|
+
/** The candidates tried across the replays (each replay's `candidatesTried` — `acceptedAt[n − 1].step` — summed); the full run's own is the `quality_report`'s. */
|
|
273
|
+
candidates_tried: number;
|
|
274
|
+
/** The full result's reading on the whole idle, as the gate refused it. */
|
|
275
|
+
full: RoleReading | null;
|
|
276
|
+
probes: ReplayProbe[];
|
|
277
|
+
/** The chosen replay on the whole idle, per role, from the final comparison. */
|
|
278
|
+
selection: RoleReading | null;
|
|
279
|
+
held_out: RoleReading | null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// The multi-interval selection (issue #148), opt-in: `meshes.<part>.auto.motion.selection`.
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/** What the row and the build line say the multi-interval search is, in so many words. */
|
|
287
|
+
export const MULTI_INTERVAL_RULE =
|
|
288
|
+
'multi-interval selection over acceptedAt by stopAfterAccepted (issue #148): removal steps only; the bisection first, whole, then the midpoint (rounded down) of the longest untested run of removal steps, the lower run on a tie, each step replayed at most once, up to maxProbes replays in all; ' +
|
|
289
|
+
'every probe selected on the idle grid frames; the fewest vertices among the tested steps that passed, the lower step on a tie; accepted on the whole idle with the irr frames held out, and no second candidate taken on a held-out failure; the best among tested candidates, not the last, the minimal or a complete walk';
|
|
290
|
+
|
|
291
|
+
/** One probe of the multi-interval search: {@link ReplayProbe} with the replay's own vertex count, null when the replay was refused before a mesh. */
|
|
292
|
+
export interface CountedProbe extends ReplayProbe {
|
|
293
|
+
vertices: number | null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** How the multi-interval search ended. */
|
|
297
|
+
export type MultiIntervalTermination =
|
|
298
|
+
/** Every removal step strictly between the source and the full result was replayed: the tested set is the whole domain. */
|
|
299
|
+
| 'every-removal-step-tested'
|
|
300
|
+
/** `maxProbes` replays were made and some removal step was never replayed: the tested set is partial. */
|
|
301
|
+
| 'budget-exhausted'
|
|
302
|
+
/** The probe order proposed a step already tested or outside the domain; it was not replayed, and the search stopped there. */
|
|
303
|
+
| 'order-proposed-a-tested-step'
|
|
304
|
+
/** The search was refused (a replay that broke rig-c's promise, a red build, a refused comparison input): it stopped there and the part is refused. */
|
|
305
|
+
| 'refused';
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The untested runs of removal steps: the maximal runs of consecutive steps
|
|
309
|
+
* strictly between `lo` and `hi` that are not in `tested`, as `[first, last]`
|
|
310
|
+
* in ascending order.
|
|
311
|
+
*/
|
|
312
|
+
export function untestedIntervals(lo: number, hi: number, tested: ReadonlySet<number>): Array<[number, number]> {
|
|
313
|
+
const out: Array<[number, number]> = [];
|
|
314
|
+
let start: number | null = null;
|
|
315
|
+
for (let s = lo + 1; s < hi; s++) {
|
|
316
|
+
if (!tested.has(s)) {
|
|
317
|
+
if (start === null) start = s;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (start !== null) out.push([start, s - 1]);
|
|
321
|
+
start = null;
|
|
322
|
+
}
|
|
323
|
+
if (start !== null) out.push([start, hi - 1]);
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The next step the multi-interval order proposes after the bisection: the
|
|
329
|
+
* midpoint, rounded down, of the longest untested run ({@link
|
|
330
|
+
* untestedIntervals}), the lower run when two are equally long; null when
|
|
331
|
+
* every removal step has been tested. Depends only on the tested set.
|
|
332
|
+
*/
|
|
333
|
+
export function nextIntervalProbe(lo: number, hi: number, tested: ReadonlySet<number>): number | null {
|
|
334
|
+
let best: [number, number] | null = null;
|
|
335
|
+
for (const run of untestedIntervals(lo, hi, tested)) if (best === null || run[1] - run[0] > best[1] - best[0]) best = run;
|
|
336
|
+
return best === null ? null : Math.floor((best[0] + best[1]) / 2);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The passing intervals observed: walking the tested steps in ascending
|
|
341
|
+
* order, each maximal run of tested steps that passed with no tested step
|
|
342
|
+
* that did not pass between them, as `[first, last]`. An untested step inside
|
|
343
|
+
* a run is not claimed to pass — it was not replayed.
|
|
344
|
+
*/
|
|
345
|
+
export function passingIntervals(probes: readonly ReplayProbe[]): Array<[number, number]> {
|
|
346
|
+
const sorted = [...probes].sort((a, b) => a.step - b.step);
|
|
347
|
+
const out: Array<[number, number]> = [];
|
|
348
|
+
let run: [number, number] | null = null;
|
|
349
|
+
for (const p of sorted) {
|
|
350
|
+
if (p.verdict === 'pass') {
|
|
351
|
+
if (run === null) run = [p.step, p.step];
|
|
352
|
+
else run[1] = p.step;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (run !== null) out.push(run);
|
|
356
|
+
run = null;
|
|
357
|
+
}
|
|
358
|
+
if (run !== null) out.push(run);
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The selection rule: among the tested probes that passed with a vertex
|
|
364
|
+
* count, the fewest vertices, then the lower step. Null when none passed.
|
|
365
|
+
* The count is the replay's own, never one derived from the step number: an
|
|
366
|
+
* operation may remove several vertices (a boundary run), and one may insert.
|
|
367
|
+
*/
|
|
368
|
+
export function fewestVertices(probes: readonly CountedProbe[]): CountedProbe | null {
|
|
369
|
+
let best: CountedProbe | null = null;
|
|
370
|
+
for (const p of probes) {
|
|
371
|
+
if (p.verdict !== 'pass' || p.vertices === null) continue;
|
|
372
|
+
if (best === null || best.vertices === null || p.vertices < best.vertices || (p.vertices === best.vertices && p.step < best.step)) best = p;
|
|
373
|
+
}
|
|
374
|
+
return best;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The multi-interval search (issue #148) over the removal steps strictly
|
|
379
|
+
* between `lo` (the source, refined or not — taken as passing and never
|
|
380
|
+
* chosen) and `hi` (the full result, which the gate refused): the bisection
|
|
381
|
+
* ({@link bisectAccepted}) first, whole, so its result is always among the
|
|
382
|
+
* tested; then, while fewer than `maxProbes` distinct steps have been
|
|
383
|
+
* replayed, the step `order` proposes ({@link nextIntervalProbe}). `probe` is
|
|
384
|
+
* called once per distinct step: a proposal of a tested step (or one outside
|
|
385
|
+
* the domain) is not replayed, not counted, and ends the search
|
|
386
|
+
* (`order-proposed-a-tested-step` — the shipped order never proposes one).
|
|
387
|
+
* `halted()` true — the caller's search was refused — ends it too
|
|
388
|
+
* (`refused`); a replay refused on geometry alone (`AUTO_MESH_ACCEPTED`) is a
|
|
389
|
+
* probe that did not pass, as in the bisection. Returns the probes in the order taken,
|
|
390
|
+
* the bisection's own result, the chosen probe ({@link fewestVertices}) and
|
|
391
|
+
* how it ended. Deterministic: the order depends only on the answers.
|
|
392
|
+
*
|
|
393
|
+
* The caller holds `maxProbes` at or above the bisection's worst length
|
|
394
|
+
* ({@link maxReplays}, refused otherwise by {@link budgetProblem}), so the
|
|
395
|
+
* bisection always completes inside the budget.
|
|
396
|
+
*/
|
|
397
|
+
export function multiIntervalSearch(
|
|
398
|
+
lo: number,
|
|
399
|
+
hi: number,
|
|
400
|
+
maxProbes: number,
|
|
401
|
+
probe: (step: number) => CountedProbe,
|
|
402
|
+
halted: () => boolean = () => false,
|
|
403
|
+
order: (lo: number, hi: number, tested: ReadonlySet<number>) => number | null = nextIntervalProbe,
|
|
404
|
+
): { probes: CountedProbe[]; bisection: number; chosen: CountedProbe | null; termination: MultiIntervalTermination } {
|
|
405
|
+
const probes: CountedProbe[] = [];
|
|
406
|
+
const tested = new Set<number>();
|
|
407
|
+
const ask = (step: number): CountedProbe => {
|
|
408
|
+
const p = probe(step);
|
|
409
|
+
probes.push(p);
|
|
410
|
+
tested.add(step);
|
|
411
|
+
return p;
|
|
412
|
+
};
|
|
413
|
+
const bis = bisectAccepted(lo, hi, ask);
|
|
414
|
+
let termination: MultiIntervalTermination | null = null;
|
|
415
|
+
// Each turn either replays a new step of the domain or ends the search, so hi − lo turns always suffice; the cap
|
|
416
|
+
// makes that a fact of the loop rather than of the order handed in.
|
|
417
|
+
for (let turn = 0; termination === null; turn++) {
|
|
418
|
+
const s = halted() ? null : order(lo, hi, tested);
|
|
419
|
+
if (halted()) termination = 'refused';
|
|
420
|
+
else if (s === null) termination = 'every-removal-step-tested';
|
|
421
|
+
else if (tested.has(s) || s <= lo || s >= hi || turn > hi - lo) termination = 'order-proposed-a-tested-step';
|
|
422
|
+
else if (tested.size >= maxProbes) termination = 'budget-exhausted';
|
|
423
|
+
else ask(s);
|
|
424
|
+
}
|
|
425
|
+
return { probes, bisection: bis.chosen, chosen: fewestVertices(probes), termination };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The refusal of a budget below the bisection it must contain: the bisection over (I, N) may take ⌈log₂(N − I)⌉ replays. */
|
|
429
|
+
export function budgetProblem(object: string, maxProbes: number, accepted: number, inserted: number): Problem {
|
|
430
|
+
const need = maxReplays(accepted, inserted);
|
|
431
|
+
return {
|
|
432
|
+
code: 'AUTO_MESH_SELECTION_BUDGET',
|
|
433
|
+
object: `${object}.motion.selection.maxProbes`,
|
|
434
|
+
detail:
|
|
435
|
+
`is ${maxProbes}; the multi-interval selection runs the bisection over the removal steps ${inserted + 1}..${accepted - 1} of ${accepted} accepted step(s) whole first, which may take ${need} replay(s) (⌈log₂(N − I)⌉ with N ${accepted}, I ${inserted}), so ${need} or more is required — ` +
|
|
436
|
+
"the budget is the author's and is never raised here; raise it, or leave selection out for the bisection alone",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** The refusal when the multi-interval search keeps no removal step: the source is not written, as under the bisection. */
|
|
441
|
+
export function noMultiReductionProblem(
|
|
442
|
+
object: string,
|
|
443
|
+
full: string,
|
|
444
|
+
accepted: number,
|
|
445
|
+
inserted: number,
|
|
446
|
+
maxProbes: number,
|
|
447
|
+
termination: MultiIntervalTermination,
|
|
448
|
+
probes: readonly ReplayProbe[],
|
|
449
|
+
): Problem {
|
|
450
|
+
return {
|
|
451
|
+
code: 'AUTO_MESH_MOTION',
|
|
452
|
+
object,
|
|
453
|
+
detail:
|
|
454
|
+
`no reduction passes the motion bound among the tested candidates: the full result (accepted step ${accepted} of ${accepted}) ${full}; a multi-interval selection over the removal steps ${inserted + 1}..${accepted - 1} by stopAfterAccepted (maxProbes ${maxProbes}, ended ${termination}), ` +
|
|
455
|
+
`selected on the idle grid frames with the irr frames held out, kept none (${probesText(probes)}); a step never replayed is not claimed to fail; the source${inserted > 0 ? `, refined or not (steps 0..${inserted}),` : ' (step 0)'} is not an automatic result, and nothing is built in its place — raise maxProbes, raise the motion bound, protect the vertices that carry the motion (protect.weightJump), or put the part in contour mode`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Schedule-role contamination (issue #148, item 4): every frame a probe's
|
|
461
|
+
* comparison walked must be one the final comparison declares `selection`,
|
|
462
|
+
* and no frame the final holds out may have been read by a probe. Null when
|
|
463
|
+
* that holds; otherwise the problem names the frames and withdraws the
|
|
464
|
+
* held-out claim — the candidate is refused, never re-chosen.
|
|
465
|
+
*/
|
|
466
|
+
export function rolesProblem(object: string, step: number, probeFrames: readonly string[], finalReport: MeshQualityReport): Problem | null {
|
|
467
|
+
const walked = finalReport.candidates[0]?.motion?.schedule.walked ?? [];
|
|
468
|
+
const heldOut = new Set(walked.filter((f) => f.role === 'held-out').map((f) => f.id));
|
|
469
|
+
const selection = new Set(walked.filter((f) => f.role === 'selection').map((f) => f.id));
|
|
470
|
+
const read = [...new Set(probeFrames)].sort();
|
|
471
|
+
const leaked = read.filter((id) => heldOut.has(id));
|
|
472
|
+
const stray = read.filter((id) => !selection.has(id) && !heldOut.has(id));
|
|
473
|
+
if (leaked.length === 0 && stray.length === 0) return null;
|
|
474
|
+
const named = [leaked.length > 0 ? `the held-out frame(s) [${leaked.join(', ')}]` : '', stray.length > 0 ? `frame(s) the final comparison does not declare selection [${stray.join(', ')}]` : ''].filter((x) => x !== '').join(' and ');
|
|
475
|
+
return {
|
|
476
|
+
code: 'AUTO_MESH_SELECTION_ROLES',
|
|
477
|
+
object,
|
|
478
|
+
detail: `the replay to accepted step ${step} was chosen by probes that read ${named}; a frame read to choose is selection, so the held-out claim is withdrawn, the candidate is not accepted on it, and nothing is built in its place`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** What `mesh_report.json`'s row carries about a multi-interval search that chose a replay (`replay`, before `deformation`). */
|
|
483
|
+
export interface MultiIntervalRow {
|
|
484
|
+
rule: string;
|
|
485
|
+
policy: 'multi-interval';
|
|
486
|
+
max_probes: number;
|
|
487
|
+
/** The full run's `acceptedAt` length: N operations. */
|
|
488
|
+
accepted_steps: number;
|
|
489
|
+
/** The refinement's insertions among them: I. */
|
|
490
|
+
refinement_steps: number;
|
|
491
|
+
/** The operation written: I < n < N. */
|
|
492
|
+
chosen_step: number;
|
|
493
|
+
/** The bisection's own result inside the search (I when it kept none). */
|
|
494
|
+
bisection_step: number;
|
|
495
|
+
/** `reduceMesh` calls with `stopAfterAccepted`: one per distinct probe, at most `max_probes`. */
|
|
496
|
+
replays: number;
|
|
497
|
+
/** The candidates tried across the replays (each replay's `candidatesTried` summed). */
|
|
498
|
+
candidates_tried: number;
|
|
499
|
+
termination: MultiIntervalTermination;
|
|
500
|
+
/** The tested removal steps, ascending. */
|
|
501
|
+
tested: number[];
|
|
502
|
+
/** Maximal runs of tested passing steps with no tested failure between ({@link passingIntervals}). */
|
|
503
|
+
passing_intervals: Array<[number, number]>;
|
|
504
|
+
/** Maximal runs of removal steps never replayed ({@link untestedIntervals}); empty only when every step was tested. */
|
|
505
|
+
untested_intervals: Array<[number, number]>;
|
|
506
|
+
/** The chosen replay's counts, read off its mesh. */
|
|
507
|
+
chosen: { vertices: number; boundary: number; interior: number; triangles: number; bindings: number };
|
|
508
|
+
/** The full result's reading on the whole idle, as the gate refused it. */
|
|
509
|
+
full: RoleReading | null;
|
|
510
|
+
probes: CountedProbe[];
|
|
511
|
+
/** Which frames played which role: every probe read the grid frames alone (selection); the irr frames were read once, by the final comparison (held out). */
|
|
512
|
+
roles: { selection: 'grid'; held_out: 'irr'; probe_frames: number };
|
|
513
|
+
/** The chosen replay on the whole idle, per role, from the final comparison. */
|
|
514
|
+
selection: RoleReading | null;
|
|
515
|
+
held_out: RoleReading | null;
|
|
516
|
+
}
|