spine-rigc 1.5.3 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/cli.ts +131 -403
- package/docs/AUTHORING.md +63 -14
- package/docs/INGEST.md +1 -0
- package/docs/MOTION.md +1 -0
- package/package.json +1 -1
- package/src/atlas.ts +25 -0
- package/src/check.ts +113 -24
- package/src/checkpics.ts +2 -0
- package/src/compile.ts +641 -506
- package/src/core/animation.ts +982 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1721 -0
- package/src/core/constraints_path.ts +942 -0
- package/src/core/constraints_physics.ts +830 -0
- package/src/core/constraints_slider.ts +505 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1103 -0
- package/src/core/raw.ts +347 -0
- package/src/core/skins.ts +104 -0
- package/src/core/uvs.ts +448 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/world.ts +233 -0
- package/src/deformmeasure.ts +337 -73
- package/src/deformreport.ts +370 -0
- package/src/depth.ts +101 -2
- package/src/emit_spine.ts +860 -0
- package/src/mesh.ts +24 -15
- package/src/model.ts +958 -0
- package/src/render.ts +649 -143
- package/src/render_core.ts +582 -0
- package/src/rig.ts +34 -6
- package/src/transform.ts +26 -4
- package/src/types.ts +18 -0
- package/src/validate.ts +30 -2
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `DEFORM` report block `rigc explain` prints (issue #316), as a function of
|
|
3
|
+
* the deform survey alone (issue #969): moved out of `cli.ts` whole, so that a
|
|
4
|
+
* control can render it off a survey taken through either poser — spine-core
|
|
5
|
+
* over the Spine skeleton, rigc's own core over the model document — and hold
|
|
6
|
+
* the two blocks to each other. `cli.ts`'s `deformReportLines` states why the
|
|
7
|
+
* block is a report and not an assertion, and what it deliberately leaves out.
|
|
8
|
+
*/
|
|
9
|
+
import type { CompileResult } from './types.ts';
|
|
10
|
+
import type { DeformExtreme, DeformKeyMeasure, DeformSpan, DeformSurvey } from './deformmeasure.ts';
|
|
11
|
+
import { unreachableWhy } from './deformmeasure.ts';
|
|
12
|
+
import { float32Step } from './timelines.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One extreme, as `x0.637306 tri 0`, or an em dash when no triangle on the key
|
|
16
|
+
* could carry the quantity.
|
|
17
|
+
*
|
|
18
|
+
* A dash rather than `x1.000000`: a key over a mesh whose every triangle is a
|
|
19
|
+
* hair has no ratio and no map, and printing the identity there would report a
|
|
20
|
+
* measurement that was never taken — this repository's favourite false green.
|
|
21
|
+
*/
|
|
22
|
+
function deformExtreme(extreme: DeformExtreme | null): string {
|
|
23
|
+
return extreme === null ? '—'.padEnd(9) : `x${extreme.value.toFixed(6)} tri ${extreme.triangle}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `head/head key 1`, which is how A39's own message names a key. */
|
|
27
|
+
function deformKeyName(key: DeformKeyMeasure): string {
|
|
28
|
+
return `${key.slot}/${key.attachment} key ${key.key}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Does this compiled `transform` report belong to this loaded key?
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ The two times are not the same number and cannot be compared with `===`.
|
|
35
|
+
* The report's is the spec's own `t`; the survey's came back through
|
|
36
|
+
* `Float32Array`, because that is what `spine-core` reads a timeline's frames
|
|
37
|
+
* into — a key written `0.62` arrives as `0.6200000047683716`. So the tolerance
|
|
38
|
+
* is one float32 step at this magnitude (`float32Step`): the compiler emits a
|
|
39
|
+
* key time as a float's name — the spec's own time when it names one, the float
|
|
40
|
+
* below it when it does not (issue #716) — so the loaded float is within one
|
|
41
|
+
* step of the spec's `t` either way, which is narrower than any key spacing the
|
|
42
|
+
* format can hold.
|
|
43
|
+
*/
|
|
44
|
+
function sameKeyTime(specTime: number, loaded: number): boolean {
|
|
45
|
+
return Math.abs(specTime - loaded) <= float32Step(loaded);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The block, off `survey` — `cli.ts`'s `deformReportLines` says what each line is and why. */
|
|
49
|
+
export function deformReportBlock(survey: DeformSurvey, deformTransforms: CompileResult['deformTransforms'], exempt: ReadonlySet<string>): string[] {
|
|
50
|
+
if (survey.timelines === 0) return [];
|
|
51
|
+
const out: string[] = ['', 'deform (what each key does to the geometry — figures with names, never a bar; issue #316)'];
|
|
52
|
+
// The legend costs six lines and is worth them exactly once — on a report that
|
|
53
|
+
// has figures in it. A bounding box or a clipping polygon deformed and nothing
|
|
54
|
+
// else gets the reason it has no figures and no essay about them.
|
|
55
|
+
if (survey.keys.length) {
|
|
56
|
+
out.push(
|
|
57
|
+
' .. every key measured at its OWN time against the same pose with the deform CLEARED, so the',
|
|
58
|
+
' .. denominator is 1.000 by definition and a NEGATIVE area ratio IS a reversed triangle',
|
|
59
|
+
' .. the FRAME is on each key line: on a track, or the slider that applies the animation with the',
|
|
60
|
+
' .. dial value its mapping was inverted to — a slider picks the time, so the key\'s time IS it',
|
|
61
|
+
' .. stretch is the two singular values of the map from the cleared triangle to the deformed one —',
|
|
62
|
+
' .. the worst stretch and the worst squash the drawing takes there; their product is |area ratio|',
|
|
63
|
+
' .. coverage is NOT here: it is rasterised from the uvs, which no deform moves, so the figure on',
|
|
64
|
+
' .. the `meshes` line below is already the deformed one',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (survey.notAMesh.length) {
|
|
68
|
+
out.push(` .. ${survey.notAMesh.join(', ')} deform an attachment with no triangles — nothing to measure`);
|
|
69
|
+
}
|
|
70
|
+
for (const key of survey.keys) {
|
|
71
|
+
const model = deformTransforms.find(
|
|
72
|
+
(g) =>
|
|
73
|
+
g.animation === key.animation &&
|
|
74
|
+
g.skin === key.skin &&
|
|
75
|
+
g.slot === key.slot &&
|
|
76
|
+
g.attachment === key.placeholder &&
|
|
77
|
+
sameKeyTime(g.time, key.time),
|
|
78
|
+
);
|
|
79
|
+
// A stated model is quoted rather than reduced to its results: `yaw
|
|
80
|
+
// radius=170 degrees=12` is what a reviewer checks the ratios against, and an
|
|
81
|
+
// authored table says so instead of saying nothing, because "no model here"
|
|
82
|
+
// is itself the thing a reader of a wrong ratio needs to know.
|
|
83
|
+
const states = model === undefined ? 'authored table' : `transform ${model.kind} ${model.stated}`;
|
|
84
|
+
out.push(
|
|
85
|
+
` DEFORM ${key.animation} ${key.skin}/${key.slot}/${key.placeholder} key ${key.key} ` +
|
|
86
|
+
`t=${key.time.toFixed(6)} ${states}`,
|
|
87
|
+
);
|
|
88
|
+
// 🔒 The frame, on every key, because the derivation is shared with A39 and
|
|
89
|
+
// this block is where a reader finds out which one it was (issue #407). A
|
|
90
|
+
// track frame says so in three words; a slider frame names the dial value
|
|
91
|
+
// its own mapping inverts this time to, which is the number an author sets.
|
|
92
|
+
out.push(
|
|
93
|
+
` frame ${key.reach.label}` +
|
|
94
|
+
(key.dial === null
|
|
95
|
+
? ''
|
|
96
|
+
: `, dial ${key.dial.value.toFixed(6)}` +
|
|
97
|
+
// ⚠️ `reach.drive` names the field when it is NOT the one `property`
|
|
98
|
+
// names, which happens under `local: false` on a rotated parent — a
|
|
99
|
+
// bare figure there would read as a value of the wrong field (#419).
|
|
100
|
+
(key.reach.local || key.dial.driven === key.dial.value
|
|
101
|
+
? ''
|
|
102
|
+
: ` (bone local ${key.reach.drive === null ? '' : `${key.reach.drive} `}${key.dial.driven.toFixed(6)})`) +
|
|
103
|
+
` -> t=${key.dial.applied.toFixed(6)}`),
|
|
104
|
+
);
|
|
105
|
+
// A key at a time no dial selects: the figures below are the frame the
|
|
106
|
+
// runtime DOES land on, which is some other time's geometry, so the line
|
|
107
|
+
// that says so comes before them and the gate reads none of them.
|
|
108
|
+
if (key.dial?.unreachable === true) {
|
|
109
|
+
out.push(
|
|
110
|
+
` unreachable A39 gates nothing here: ${unreachableWhy(key)}. Every figure below is that other ` +
|
|
111
|
+
"frame's, not this key's",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
// ⚠️ An exemption nobody can see is how a gate comes to look kept while
|
|
115
|
+
// checking nothing (issue #401). A key the gate passed over because the mesh
|
|
116
|
+
// draws no pixels there says so on its own line, in the survey's own words,
|
|
117
|
+
// whether or not it folds.
|
|
118
|
+
if (key.draw.blank !== null) {
|
|
119
|
+
out.push(
|
|
120
|
+
` skipped A39 reads no winding off this key: ${key.draw.blank} — a triangle that draws no ` +
|
|
121
|
+
'pixels cannot draw them backwards',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
// And when the slot shows something else, the figures below would be a
|
|
125
|
+
// second falsehood rather than a caveat: the runtime applies no deform to a
|
|
126
|
+
// slot that is not showing the mesh (`DeformTimeline.applyToSlot`), so every
|
|
127
|
+
// figure would be the identity and `moved 0` would read as "this key is the
|
|
128
|
+
// setup pose" — which is exactly what the key is NOT.
|
|
129
|
+
if (!key.draw.showsThisMesh) {
|
|
130
|
+
out.push(
|
|
131
|
+
` .. the slot shows ${key.draw.shown === null ? 'no attachment' : `"${key.draw.shown}"`} ` +
|
|
132
|
+
'here, so the runtime applied no deform and there is no posed geometry to measure' +
|
|
133
|
+
(key.draw.blank === null
|
|
134
|
+
? ' — but the mesh IS drawn in another slot this deform reaches (timelineSlots), so nothing here is exempt'
|
|
135
|
+
: ''),
|
|
136
|
+
);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
// A key that moves nothing gets one line and no figures. `{ "t": 2.2 }` with
|
|
140
|
+
// no run is the format's own way of writing "back to the setup pose" (§4.11),
|
|
141
|
+
// and its geometry is bit-identical to the cleared pose it would be measured
|
|
142
|
+
// against — so `x1.000000` there is the definition and not a measurement, and
|
|
143
|
+
// four lines of it on every loop's opening and closing key is the noise that
|
|
144
|
+
// stops the block being read. It is still counted in the rollup below,
|
|
145
|
+
// because A39 measures it too.
|
|
146
|
+
if (key.moved === 0) {
|
|
147
|
+
out.push(
|
|
148
|
+
` moved 0 of ${key.vertices} vertices — this key IS the setup pose, so every ` +
|
|
149
|
+
`figure is the identity (${key.triangles} triangles, all kept)`,
|
|
150
|
+
);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
out.push(
|
|
154
|
+
` moved ${key.moved} of ${key.vertices} vertices, ` +
|
|
155
|
+
`worst ${key.maxDisplacement.toFixed(4)}px at v${key.maxDisplacementVertex}`,
|
|
156
|
+
);
|
|
157
|
+
out.push(
|
|
158
|
+
` area min ${deformExtreme(key.areaRatioMin)} max ${deformExtreme(key.areaRatioMax)} ` +
|
|
159
|
+
`(${key.triangles} triangles, ${key.degenerate} with no area at the cleared pose, band ${key.band.toFixed(6)}px²)`,
|
|
160
|
+
);
|
|
161
|
+
out.push(
|
|
162
|
+
` stretch max ${deformExtreme(key.stretchMax)} min ${deformExtreme(key.stretchMin)}`,
|
|
163
|
+
);
|
|
164
|
+
// The marker has to know about the exemption, or it says the false half of
|
|
165
|
+
// the truth on the one build where it matters: a declared fold IS a fold and
|
|
166
|
+
// A39 does not refuse it — it SKIPs the slot entirely.
|
|
167
|
+
const exempted = exempt.has(key.slot);
|
|
168
|
+
const fold = key.reversed.length
|
|
169
|
+
? key.draw.blank !== null
|
|
170
|
+
? ' <- a fold, and nothing gates it: this key draws no pixels (see above)'
|
|
171
|
+
: exempted
|
|
172
|
+
? ' <- a fold, and A39 does not gate it — see below'
|
|
173
|
+
: ' <- a fold: A39 refuses this key by name'
|
|
174
|
+
: '';
|
|
175
|
+
out.push(
|
|
176
|
+
` winding ${key.triangles - key.reversed.length} of ${key.triangles} kept, ` +
|
|
177
|
+
`${key.collapsed} collapsed${fold}`,
|
|
178
|
+
);
|
|
179
|
+
if (exempted) {
|
|
180
|
+
out.push(
|
|
181
|
+
` .. A39 is exempt on "${key.slot}" (invariants.deformMayFold), so nothing here is gated`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// The folds at times no key lands on (issue #403), printed after the keys they
|
|
186
|
+
// lie between rather than interleaved: they are a different measurement — the
|
|
187
|
+
// closed form named the time and the runtime was posed there — and a reader
|
|
188
|
+
// needs to be able to tell the two apart at a glance.
|
|
189
|
+
for (const span of survey.spans) {
|
|
190
|
+
if (span.fold === null) continue;
|
|
191
|
+
const at = span.fold;
|
|
192
|
+
out.push(
|
|
193
|
+
` BETWEEN ${span.animation}${span.reach.kind === 'slider' ? ` via ${span.reach.slider}` : ''} ` +
|
|
194
|
+
`${span.skin}/${span.slot}/${span.placeholder} key ${span.fromKey} -> ` +
|
|
195
|
+
`${span.toKey} t=${at.time.toFixed(6)} ${span.curve}` +
|
|
196
|
+
(span.curve === 'stepped' ? ' (held, not interpolated)' : ` ${(at.percent * 100).toFixed(1)}% of the way`),
|
|
197
|
+
);
|
|
198
|
+
out.push(
|
|
199
|
+
` winding ${at.measure.triangles - at.measure.reversed.length} of ${at.measure.triangles} kept, ` +
|
|
200
|
+
`${at.measure.collapsed} collapsed <- a fold at a time no key lands on` +
|
|
201
|
+
(at.measure.draw.blank !== null
|
|
202
|
+
? ', and nothing gates it: nothing is drawn there'
|
|
203
|
+
: exempt.has(span.slot)
|
|
204
|
+
? ', and A39 does not gate it (invariants.deformMayFold)'
|
|
205
|
+
: `: A39 refuses this span by name, at alpha ${at.measure.draw.alpha.toFixed(4)}`),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
// The rollup, per animation: the worst key by each quantity. A timeline's own
|
|
209
|
+
// eight keys are eight blocks above, and "which of them is the one to look at"
|
|
210
|
+
// is the question the sweep in issue #313's landing comment answered by hand.
|
|
211
|
+
//
|
|
212
|
+
// ⚠️ Per animation AND per frame (issue #407). Two sliders applying one
|
|
213
|
+
// animation are two frames and two rollups: merging them would average a fold
|
|
214
|
+
// one dial reaches into a run of keys another one is clean over, which is the
|
|
215
|
+
// hiding the two frames exist to prevent.
|
|
216
|
+
// 🔒 ONE derivation of a rollup's identity, read by every filter below.
|
|
217
|
+
//
|
|
218
|
+
// ⚠️ It was spelled three times and one of them drifted. The two key filters
|
|
219
|
+
// separate animation from slider with a NUL; the span filter used a SPACE, so
|
|
220
|
+
// its `=== id` never matched on any rig and `spans` was always empty — the
|
|
221
|
+
// "N span(s) … scanned" line silently stopped printing everywhere, taking with
|
|
222
|
+
// it the one thing issue #403 added it to say: that the scan RAN and found
|
|
223
|
+
// nothing, as opposed to never having run. A dead branch is the same silence
|
|
224
|
+
// this tool exists to convert into a named failure, and it survived because
|
|
225
|
+
// the identity was a literal at each site rather than a derivation (#440).
|
|
226
|
+
const rollupId = (animation: string, slider: string | null): string => `${animation}\u0000${slider ?? ''}`;
|
|
227
|
+
/**
|
|
228
|
+
* The readings A39 puts on its stats line for the three things this rollup
|
|
229
|
+
* reports — each spelled ONCE here and nowhere else in this file.
|
|
230
|
+
*
|
|
231
|
+
* 🔒 Checked rather than derived, and the difference is forced: `src/validate.ts`
|
|
232
|
+
* sets these on a `Record<string, number | string>`, so there is no type to take
|
|
233
|
+
* a name off and no constant to import. So they are spelled here and a control
|
|
234
|
+
* compiles a rig that triggers each one, then asserts the breadcrumb names a
|
|
235
|
+
* reading A39 really printed — two independent derivations compared, which is
|
|
236
|
+
* the shape `CUR07` uses for the same reason.
|
|
237
|
+
*/
|
|
238
|
+
const A39_COUNTS = {
|
|
239
|
+
notDrawn: 'deformKeysNotDrawn',
|
|
240
|
+
unreachable: 'deformKeysUnreachable',
|
|
241
|
+
dialsDisagreed: 'deformDialsDisagreed',
|
|
242
|
+
dialDisagreed: 'deformDialDisagreed',
|
|
243
|
+
} as const;
|
|
244
|
+
const rollups = new Map<string, { animation: string; label: string }>();
|
|
245
|
+
for (const key of survey.keys) {
|
|
246
|
+
rollups.set(rollupId(key.animation, key.reach.slider), {
|
|
247
|
+
animation: key.animation,
|
|
248
|
+
label: key.reach.kind === 'slider' ? `${key.animation} via ${key.reach.slider}` : key.animation,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
for (const [id, { animation, label }] of rollups) {
|
|
252
|
+
// Only the keys the gate ran on, because the line ends by claiming A39 reads
|
|
253
|
+
// the same two counts and A39 reads none of a key that draws nothing, nor of
|
|
254
|
+
// one at a time no dial selects. The ones it left out get their own line
|
|
255
|
+
// rather than a silence (issues #401, #407).
|
|
256
|
+
const mine = survey.keys.filter((k) => rollupId(k.animation, k.reach.slider) === id);
|
|
257
|
+
const unreachable = mine.filter((k) => k.dial?.unreachable === true);
|
|
258
|
+
const keys = mine.filter((k) => k.dial?.unreachable !== true && k.draw.blank === null);
|
|
259
|
+
const blank = mine.filter((k) => k.dial?.unreachable !== true && k.draw.blank !== null);
|
|
260
|
+
const worst = (
|
|
261
|
+
pick: (key: DeformKeyMeasure) => DeformExtreme | null,
|
|
262
|
+
better: (a: number, b: number) => boolean,
|
|
263
|
+
): string => {
|
|
264
|
+
let best: { key: DeformKeyMeasure; extreme: DeformExtreme } | null = null;
|
|
265
|
+
for (const key of keys) {
|
|
266
|
+
const extreme = pick(key);
|
|
267
|
+
if (extreme === null) continue;
|
|
268
|
+
if (best === null || better(extreme.value, best.extreme.value)) best = { key, extreme };
|
|
269
|
+
}
|
|
270
|
+
return best === null ? '—' : `x${best.extreme.value.toFixed(6)} (${deformKeyName(best.key)} tri ${best.extreme.triangle})`;
|
|
271
|
+
};
|
|
272
|
+
const reversed = keys.reduce((n, k) => n + k.reversed.length, 0);
|
|
273
|
+
const collapsed = keys.reduce((n, k) => n + k.collapsed, 0);
|
|
274
|
+
const samples = keys.reduce((n, k) => n + k.triangles, 0);
|
|
275
|
+
if (keys.length) {
|
|
276
|
+
out.push(
|
|
277
|
+
` WORST ${label} area ${worst((k) => k.areaRatioMin, (a, b) => a < b)} ` +
|
|
278
|
+
`stretch ${worst((k) => k.stretchMax, (a, b) => a > b)} ` +
|
|
279
|
+
`squash ${worst((k) => k.stretchMin, (a, b) => a < b)}`,
|
|
280
|
+
);
|
|
281
|
+
out.push(
|
|
282
|
+
` .. ${''.padEnd(label.length)} reversed ${reversed}, collapsed ${collapsed}, over ` +
|
|
283
|
+
`${keys.length} key(s) and ${samples} triangle sample(s) <- A39 reads the same two counts`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (blank.length) {
|
|
287
|
+
out.push(
|
|
288
|
+
` .. ${keys.length ? ''.padEnd(label.length) : label} ${blank.length} key(s) draw no pixels ` +
|
|
289
|
+
`at their own time and are read for no winding, carrying ` +
|
|
290
|
+
`${blank.reduce((n, k) => n + k.reversed.length, 0)} reversed triangle(s) nothing gates <- A39 counts ` +
|
|
291
|
+
`them as ${A39_COUNTS.notDrawn}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (unreachable.length) {
|
|
295
|
+
out.push(
|
|
296
|
+
` .. ${keys.length || blank.length ? ''.padEnd(label.length) : label} ${unreachable.length} key(s) ` +
|
|
297
|
+
'at a time no dial selects, measured in the frame the runtime lands on instead and read for no winding, ' +
|
|
298
|
+
`carrying ${unreachable.reduce((n, k) => n + k.reversed.length, 0)} reversed triangle(s) nothing gates ` +
|
|
299
|
+
` <- A39 counts them as ${A39_COUNTS.unreachable}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
// ⚠️ The dial rigc's two halves disagree about (issues #427, #440). It is a
|
|
303
|
+
// property of the SLIDER and not of any one key, so it is placed by the
|
|
304
|
+
// rollup's own identity rather than by a key filter — and it is REPORTED,
|
|
305
|
+
// never gated: A39 refuses nothing for it, because a disagreement can pose
|
|
306
|
+
// every frame correctly. Both readings are named because they differ by one
|
|
307
|
+
// letter, and a breadcrumb that named only one of `deformDialsDisagreed` /
|
|
308
|
+
// `deformDialDisagreed` would send a reader to grep for the other.
|
|
309
|
+
const disputes = survey.dialDisputes.filter((dispute) => rollupId(animation, dispute.slider) === id);
|
|
310
|
+
if (disputes.length) {
|
|
311
|
+
out.push(
|
|
312
|
+
` .. ${keys.length || blank.length || unreachable.length ? ''.padEnd(label.length) : label} ` +
|
|
313
|
+
`${disputes.length} dial(s) the skeleton and the probe disagree about: ` +
|
|
314
|
+
disputes
|
|
315
|
+
.map(
|
|
316
|
+
(dispute) =>
|
|
317
|
+
`the skeleton reads ${dispute.bone}.${dispute.stated} and the probe drives ` +
|
|
318
|
+
`${dispute.bone}.${dispute.drive}, ` +
|
|
319
|
+
(dispute.outside.length === 0
|
|
320
|
+
? 'and both answers pose the same frames'
|
|
321
|
+
: `${dispute.outside.length} key time(s) outside what the skeleton's own field reaches`),
|
|
322
|
+
)
|
|
323
|
+
.join('; ') +
|
|
324
|
+
` <- A39 counts them as ${A39_COUNTS.dialsDisagreed} and spells them out as ${A39_COUNTS.dialDisagreed}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
// ⚠️ Printed on a clean animation too. "The scan ran and found nothing" and
|
|
328
|
+
// "the scan never ran" are the two things a gate must never say the same
|
|
329
|
+
// way, and this line is the only place an author can tell them apart
|
|
330
|
+
// (issue #403).
|
|
331
|
+
const spans = survey.spans.filter((s) => rollupId(s.animation, s.reach.slider) === id);
|
|
332
|
+
if (spans.length) {
|
|
333
|
+
out.push(
|
|
334
|
+
` .. ${keys.length || blank.length || unreachable.length || disputes.length ? ''.padEnd(label.length) : label} ` +
|
|
335
|
+
`${spans.length} span(s) between consecutive keys scanned for a fold no key lands on: ` +
|
|
336
|
+
`${spanTally(spans)} <- A39 reads the same scan`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// ⚠️ And the spans that were NOT scanned, once, because a scan that did not
|
|
341
|
+
// run has to be distinguishable from one that ran and found nothing — the same
|
|
342
|
+
// rule the line above keeps, on the other side of it (issue #407).
|
|
343
|
+
if (survey.spansNotScanned) {
|
|
344
|
+
out.push(
|
|
345
|
+
` .. ${survey.spansNotScanned} span(s) NOT scanned: one of the two keys bounding each is at a time no ` +
|
|
346
|
+
'dial selects, so the interpolation between them is between two poses of some other time',
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** What the between-keys scan found, in one clause (issue #403). */
|
|
353
|
+
function spanTally(spans: readonly DeformSpan[]): string {
|
|
354
|
+
const folds = spans.filter((s) => s.fold !== null).length;
|
|
355
|
+
const notDrawn = spans.filter((s) => s.notDrawn > 0).length;
|
|
356
|
+
const unconfirmed = spans.filter((s) => s.unconfirmed).length;
|
|
357
|
+
const probes = spans.reduce((n, s) => n + s.probed.length, 0);
|
|
358
|
+
if (folds === 0 && notDrawn === 0 && unconfirmed === 0) {
|
|
359
|
+
return `none folds (the closed form flagged nothing, so no span cost a posed measurement)`;
|
|
360
|
+
}
|
|
361
|
+
return (
|
|
362
|
+
[
|
|
363
|
+
folds ? `${folds} fold(s)` : '',
|
|
364
|
+
notDrawn ? `${notDrawn} folding only where nothing is drawn` : '',
|
|
365
|
+
unconfirmed ? `${unconfirmed} predicted a fold no probe reproduced` : '',
|
|
366
|
+
]
|
|
367
|
+
.filter(Boolean)
|
|
368
|
+
.join(', ') + `, at a cost of ${probes} posed measurement(s)`
|
|
369
|
+
);
|
|
370
|
+
}
|
package/src/depth.ts
CHANGED
|
@@ -370,6 +370,81 @@ export function compareTurnFields(input: {
|
|
|
370
370
|
*/
|
|
371
371
|
const CEILING_AREA_FLOOR = 1e-6;
|
|
372
372
|
|
|
373
|
+
/**
|
|
374
|
+
* A measured figure of the fold report on the tree's six-decimal grid — the
|
|
375
|
+
* rule `src/mesh.ts`'s `r6` states for the generator's measured figures, and
|
|
376
|
+
* never "-0".
|
|
377
|
+
*
|
|
378
|
+
* ⭐ **Why the report is rounded at all (issue #942).** Every other number the
|
|
379
|
+
* model document spells is on the float32 grid the Spine file is written on,
|
|
380
|
+
* or on this one; these four were the only full doubles, and the one place a
|
|
381
|
+
* platform's libm reached the document. `gallery/look`'s document differed
|
|
382
|
+
* between macOS and the Linux runner by exactly two leaves,
|
|
383
|
+
* `/meshes/0/depth/ceiling/pitch/negative/{degrees,p1}`, `26.935130523311`
|
|
384
|
+
* against `26.935130523311003`: macOS's `Math.atan` returned 0.512 ulp below
|
|
385
|
+
* the exact arctangent and Linux the correctly rounded double above it.
|
|
386
|
+
* Measured on that rig, the nearest six-decimal boundary is at least 2.33e-8°
|
|
387
|
+
* from any of its ceiling angles against a one-ulp step of about 3.6e-15°, so
|
|
388
|
+
* a one-ulp difference no longer reaches a byte. What a grid cannot absorb is
|
|
389
|
+
* stated rather than hidden: a value within one ulp of a rounding boundary
|
|
390
|
+
* still moves. Which triangle is the minimum no longer rides on the last ulp
|
|
391
|
+
* (issue #949): the choice is made on this grid too, by `foldPrecedes`.
|
|
392
|
+
*
|
|
393
|
+
* 🔒 Applied to the figures after the minimum is chosen and the percentile
|
|
394
|
+
* ranked; `TC01` still requires the named triangle, at ±0.01°, to be one `A39`
|
|
395
|
+
* fires on, and `TB03` that it is the FIRST one A39 names. A share or
|
|
396
|
+
* a step is a positive number and r6 only returns 0 for one under 5e-7; `TC07`
|
|
397
|
+
* reads every share it builds as `> 0`.
|
|
398
|
+
*/
|
|
399
|
+
function r6(n: number): number {
|
|
400
|
+
const v = Math.round(n * 1e6) / 1e6;
|
|
401
|
+
return v === 0 ? 0 : v;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Whether fold `a` is the tighter of two on one axis and side: its angle is
|
|
406
|
+
* smaller ON THE SIX-DECIMAL GRID THE REPORT SPELLS IT ON, or equal there and
|
|
407
|
+
* `a` is the lower triangle ordinal. `degrees` are the full doubles; the grid
|
|
408
|
+
* is applied here, so a caller cannot compare the doubles by accident.
|
|
409
|
+
*
|
|
410
|
+
* ⭐ Why the grid and not the doubles (issue #949). A minimum chosen on full
|
|
411
|
+
* doubles is chosen by the platform's libm whenever two triangles fold within
|
|
412
|
+
* a few ulps of each other, and a fold names its triangle with that
|
|
413
|
+
* triangle's own `ids`, `depthStep` and `stepShare`, so the document moves by
|
|
414
|
+
* whole fields while the angle it prints does not. Measured on
|
|
415
|
+
* `gallery/look`, with every libm-backed `Math` result moved one ulp through a
|
|
416
|
+
* `--preload`: mesh 0's `pitch.negative` minimum is triangles 49 and 50, both
|
|
417
|
+
* at exactly `26.935130523311` unperturbed (the doubles are EQUAL, and the
|
|
418
|
+
* first-found rule named 49); `Math.pow` +1 ulp made 50 the smaller by one ulp
|
|
419
|
+
* (`26.935130523310995`) and −1 ulp made 49 the larger (`26.935130523311003`),
|
|
420
|
+
* and the document named 50, `ids` `[60,…,80]`, `depthStep` 90.533309 for
|
|
421
|
+
* 95.668608. `pow` −1 ulp also swapped `yaw.positive`'s triangles 174 and 215
|
|
422
|
+
* (`19.316350434748518`, both; 7.1e-15° apart once perturbed). 5 and 10 leaves
|
|
423
|
+
* moved; with this rule, 0 under `atan`, `pow` or all sixteen functions
|
|
424
|
+
* together, either direction, on all seven gallery rows.
|
|
425
|
+
*
|
|
426
|
+
* ⭐ Why the LOWEST ordinal, and not some other tie-break: it is the order
|
|
427
|
+
* `A39` names triangles in. The gate lists the triangles a key reverses by
|
|
428
|
+
* ascending ordinal (`deformmeasure.ts`, `reversed`), so turned just past a
|
|
429
|
+
* tied ceiling every tied triangle reverses and the first one the refusal
|
|
430
|
+
* names is the lowest — the one this reports. `TB03` holds the two to it on a
|
|
431
|
+
* planted tie; `TB01` holds the choice unmoved by which of two tied triangles
|
|
432
|
+
* carries the larger double.
|
|
433
|
+
*
|
|
434
|
+
* ⚠️ What a grid cannot absorb, stated rather than hidden: two angles one ulp
|
|
435
|
+
* apart that straddle a six-decimal rounding boundary are two different
|
|
436
|
+
* reported values, and a one-ulp change can still pick the other one. That is
|
|
437
|
+
* the value moving, not the choice, and it is the residual `r6` already names.
|
|
438
|
+
*/
|
|
439
|
+
export function foldPrecedes(
|
|
440
|
+
a: { readonly degrees: number; readonly triangle: number },
|
|
441
|
+
b: { readonly degrees: number; readonly triangle: number },
|
|
442
|
+
): boolean {
|
|
443
|
+
const ra = r6(a.degrees);
|
|
444
|
+
const rb = r6(b.degrees);
|
|
445
|
+
return ra < rb || (ra === rb && a.triangle < b.triangle);
|
|
446
|
+
}
|
|
447
|
+
|
|
373
448
|
/**
|
|
374
449
|
* Where one triangle turns inside out, which triangle that is — and, beside it,
|
|
375
450
|
* what the REST of this axis and side's triangles do.
|
|
@@ -393,6 +468,11 @@ const CEILING_AREA_FLOOR = 1e-6;
|
|
|
393
468
|
* smooths or rejects a sample — the ceiling stays the raw sheet read through
|
|
394
469
|
* the mesh, which is the only thing `A39` will agree with. What to read off the
|
|
395
470
|
* two numbers is stated in `docs/AUTHORING.md` §3.4, not decided here.
|
|
471
|
+
*
|
|
472
|
+
* 🔸 `degrees`, `depthStep`, `stepShare` and `p1` are reported on the
|
|
473
|
+
* six-decimal grid (`r6` above, issue #942), and the choice among triangles is
|
|
474
|
+
* made on that grid with the lowest ordinal winning a tie (`foldPrecedes`,
|
|
475
|
+
* issue #949).
|
|
396
476
|
*/
|
|
397
477
|
export interface FoldLimit {
|
|
398
478
|
/** Degrees from setup, in (0, 90). */
|
|
@@ -572,6 +652,19 @@ export interface TurnCeiling {
|
|
|
572
652
|
* key is not built from, and would part company with the gate that reads the
|
|
573
653
|
* raw one.
|
|
574
654
|
*
|
|
655
|
+
* ## Which triangle, when two fold at the same angle (issue #949)
|
|
656
|
+
*
|
|
657
|
+
* The minimum is chosen on the six-decimal grid the angle is reported on, and
|
|
658
|
+
* a tie there goes to the LOWEST triangle ordinal — the triangle `A39` names
|
|
659
|
+
* first when a key turns just past the ceiling (`foldPrecedes`). Chosen on the
|
|
660
|
+
* full doubles, it was the platform's libm that broke a tie: on
|
|
661
|
+
* `gallery/look`, triangles 49 and 50 fold at exactly `26.935130523311` and a
|
|
662
|
+
* one-ulp `Math.pow` perturbation (through the depth tone, into every `z`)
|
|
663
|
+
* named 50 instead of 49, moving 5 leaves of the model document in one
|
|
664
|
+
* direction and 10 in the other while no printed angle moved. With the rule,
|
|
665
|
+
* a ±1 ulp perturbation of `atan`, `pow`, or sixteen libm functions at once
|
|
666
|
+
* moves no leaf of any gallery row's document (`TB02` re-runs it in process).
|
|
667
|
+
*
|
|
575
668
|
* @param points Vertices in the BIND space the deform offsets are authored in.
|
|
576
669
|
* Areas are translation-invariant, so the origin does not matter; the scale
|
|
577
670
|
* and the axis directions do. A y flip alone leaves a `yaw` answer alone and
|
|
@@ -661,7 +754,10 @@ export function turnCeiling(
|
|
|
661
754
|
const held = out[axis][side];
|
|
662
755
|
// `count` and `p1` are filled once the whole population is in; a minimum
|
|
663
756
|
// cannot know its own percentile while it is still being found.
|
|
664
|
-
|
|
757
|
+
// On the reported grid, lowest ordinal first — `foldPrecedes` above. The
|
|
758
|
+
// walk is in ordinal order, so the ordinal clause never decides here; it is
|
|
759
|
+
// stated so the rule does not depend on the walk.
|
|
760
|
+
if (held === null || foldPrecedes({ degrees, triangle: n }, held)) {
|
|
665
761
|
// `zSpan` cannot be zero here: `aAxis !== 0` needs two of this
|
|
666
762
|
// triangle's vertices at different depths, and the span over the whole
|
|
667
763
|
// mesh is at least that difference. A guard would be an unreachable
|
|
@@ -678,7 +774,10 @@ export function turnCeiling(
|
|
|
678
774
|
const sorted = angles[axis][side].slice().sort((a, b) => a - b);
|
|
679
775
|
const rank = nearestRankIndex(sorted.length, 0.01);
|
|
680
776
|
held.count = sorted.length;
|
|
681
|
-
held.p1 = rank === 0 ? null : sorted[rank];
|
|
777
|
+
held.p1 = rank === 0 ? null : r6(sorted[rank]);
|
|
778
|
+
held.degrees = r6(held.degrees);
|
|
779
|
+
held.depthStep = r6(held.depthStep);
|
|
780
|
+
held.stepShare = r6(held.stepShare);
|
|
682
781
|
}
|
|
683
782
|
}
|
|
684
783
|
return out;
|