rig-c 0.0.0-stage → 2.20.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -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 +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
package/src/depth.ts
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A **depth map** as a mesh input: a greyscale sheet, in the part's own pixel
|
|
3
|
+
* grid, that says how far in front of the turn axis each pixel sits.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is an input and not a measurement
|
|
6
|
+
*
|
|
7
|
+
* `yaw` and `pitch` (`src/deformgen.ts`) turn a part by treating it as painted
|
|
8
|
+
* on a cylinder: a vertex at `u` off the axis gets `z = √(radius² − u²)`, and
|
|
9
|
+
* the key is that rotation projected back to the screen. One radius per
|
|
10
|
+
* attachment is a whole model of a shape, and it is the right model for a
|
|
11
|
+
* fringe or a plate that really does bend like a barrel.
|
|
12
|
+
*
|
|
13
|
+
* It is the wrong model for a face. A nose is not on the skull's cylinder, an
|
|
14
|
+
* ear is behind it, and no single radius puts both where they are — the
|
|
15
|
+
* cylinder answers "how far off the axis is this column" when the question is
|
|
16
|
+
* "how far forward is this pixel". A depth map answers the second question
|
|
17
|
+
* directly, per vertex, and the arithmetic downstream does not otherwise
|
|
18
|
+
* change: the same closed form runs, with `z` read instead of derived.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ **The map is relative and rigc does not make it absolute.** 8 bits of
|
|
21
|
+
* level say nothing about world units, so `zScale` — how far apart level 0 and
|
|
22
|
+
* level 255 are — is an authored number, exactly like `radius` was. rigc
|
|
23
|
+
* refuses to guess it, records the map's digest so a claim can name which sheet
|
|
24
|
+
* produced it, and reports the range it actually sampled. What it will not do
|
|
25
|
+
* is measure a plate and invent a depth from it.
|
|
26
|
+
*
|
|
27
|
+
* ## The tone curve, and why it lives here
|
|
28
|
+
*
|
|
29
|
+
* A consumer that renders the same sheet in a shader applies a tone curve
|
|
30
|
+
* before displacing anything — gamma, contrast, bias. If rigc sampled the raw
|
|
31
|
+
* level and the shader sampled a curved one, the mesh and the shader would be
|
|
32
|
+
* two different surfaces and the cross-check between them (#382's positive
|
|
33
|
+
* control) would compare nothing. So the curve is stated in the spec, applied
|
|
34
|
+
* here, and written into the report in the form the consumer can compare
|
|
35
|
+
* against.
|
|
36
|
+
*
|
|
37
|
+
* Order of operations, fixed and stated because every one of them is a place
|
|
38
|
+
* two implementations can silently disagree:
|
|
39
|
+
*
|
|
40
|
+
* 1. **bilinear sample of the RAW level**, at pixel centres — this is what a
|
|
41
|
+
* GPU's linear filter does, and doing it after the curve would filter a
|
|
42
|
+
* different function;
|
|
43
|
+
* 2. **`near`**, which turns a level into a nearness in 0..1;
|
|
44
|
+
* 3. **the tone curve**, clamped back into 0..1;
|
|
45
|
+
* 4. **`zScale`**, which is the only step carrying units.
|
|
46
|
+
*/
|
|
47
|
+
import { createHash } from 'node:crypto';
|
|
48
|
+
|
|
49
|
+
/** Which end of the range is closest to the viewer. */
|
|
50
|
+
export type DepthNear = 'white' | 'black';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The curve applied between "nearness in 0..1" and the value `zScale` multiplies.
|
|
54
|
+
*
|
|
55
|
+
* Stated in full rather than left partly defaulted, so the report can print the
|
|
56
|
+
* curve a consumer has to match without a reader having to know which fields
|
|
57
|
+
* were written and which were filled in.
|
|
58
|
+
*/
|
|
59
|
+
export interface DepthTone {
|
|
60
|
+
/** Applied to the 0..1 nearness. 1 is a straight line. */
|
|
61
|
+
gamma: number;
|
|
62
|
+
/** Fanned about 0.5. 1 leaves the range alone. */
|
|
63
|
+
contrast: number;
|
|
64
|
+
/** Added after the fan. 0 leaves the midpoint alone. */
|
|
65
|
+
bias: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The curve that changes nothing — what an unstated tone block means. */
|
|
69
|
+
export const DEPTH_TONE_IDENTITY: DepthTone = { gamma: 1, contrast: 1, bias: 0 };
|
|
70
|
+
|
|
71
|
+
export interface DepthMap {
|
|
72
|
+
width: number;
|
|
73
|
+
height: number;
|
|
74
|
+
/** One level per pixel, row major, y down, 0..255. */
|
|
75
|
+
level: Uint8Array;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class DepthError extends Error {}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Refuse a tone block that cannot describe a curve.
|
|
82
|
+
*
|
|
83
|
+
* `gamma` and `contrast` at or below 0 are the two that matter: a gamma of 0
|
|
84
|
+
* maps every level to 1 and a contrast of 0 maps every level to the midpoint,
|
|
85
|
+
* so both turn a depth map into a constant — a flat part with a file behind it,
|
|
86
|
+
* which is precisely the silence this feature exists to remove.
|
|
87
|
+
*/
|
|
88
|
+
export function checkTone(tone: DepthTone, where: string): void {
|
|
89
|
+
const finite = (n: number, field: string): void => {
|
|
90
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
91
|
+
throw new DepthError(`${where}: depth tone "${field}" is ${JSON.stringify(n)}; it is a finite number`);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
finite(tone.gamma, 'gamma');
|
|
95
|
+
finite(tone.contrast, 'contrast');
|
|
96
|
+
finite(tone.bias, 'bias');
|
|
97
|
+
if (tone.gamma <= 0) {
|
|
98
|
+
throw new DepthError(
|
|
99
|
+
`${where}: depth tone "gamma" is ${tone.gamma}; a gamma at or below 0 maps every level to the same nearness, ` +
|
|
100
|
+
'so the map would describe a flat part. It is a positive number, and 1 is the straight line.',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (tone.contrast <= 0) {
|
|
104
|
+
throw new DepthError(
|
|
105
|
+
`${where}: depth tone "contrast" is ${tone.contrast}; a contrast at or below 0 collapses the range onto the ` +
|
|
106
|
+
'midpoint (or turns it inside out), so the map would describe a flat part. It is a positive number, and 1 ' +
|
|
107
|
+
'leaves the range alone.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Refuse a scale that carries no units. */
|
|
113
|
+
export function checkZScale(zScale: number, where: string): void {
|
|
114
|
+
if (typeof zScale !== 'number' || !Number.isFinite(zScale)) {
|
|
115
|
+
throw new DepthError(`${where}: "zScale" is ${JSON.stringify(zScale)}; it is a finite number of world units`);
|
|
116
|
+
}
|
|
117
|
+
if (zScale <= 0) {
|
|
118
|
+
throw new DepthError(
|
|
119
|
+
`${where}: "zScale" is ${zScale}; it is how many units the map's full range spans, so a positive number. ` +
|
|
120
|
+
'To put the near end at the back, say "near": "black" — a negative scale states the same thing twice and ' +
|
|
121
|
+
'the two can then disagree.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Bilinear sample of the raw level at a part-local pixel position, y down.
|
|
128
|
+
*
|
|
129
|
+
* Pixel `i` covers `[i, i+1)` and its centre is at `i + 0.5`, so a position is
|
|
130
|
+
* converted to centre space before interpolating — sampling at `i` without that
|
|
131
|
+
* shift reads a value half a pixel off, which is invisible on a smooth sheet
|
|
132
|
+
* and wrong at every edge. Positions outside the map clamp to the edge texel,
|
|
133
|
+
* the same as a GPU's clamp-to-edge; a vertex out there is a separate refusal
|
|
134
|
+
* the caller makes, and clamping here keeps this function total.
|
|
135
|
+
*/
|
|
136
|
+
export function sampleLevel(map: DepthMap, x: number, y: number): number {
|
|
137
|
+
const { width: w, height: h, level } = map;
|
|
138
|
+
const u = x - 0.5;
|
|
139
|
+
const v = y - 0.5;
|
|
140
|
+
const x0 = Math.floor(u);
|
|
141
|
+
const y0 = Math.floor(v);
|
|
142
|
+
const fx = u - x0;
|
|
143
|
+
const fy = v - y0;
|
|
144
|
+
const cx = (i: number): number => (i < 0 ? 0 : i > w - 1 ? w - 1 : i);
|
|
145
|
+
const cy = (j: number): number => (j < 0 ? 0 : j > h - 1 ? h - 1 : j);
|
|
146
|
+
const x0c = cx(x0);
|
|
147
|
+
const x1c = cx(x0 + 1);
|
|
148
|
+
const y0c = cy(y0);
|
|
149
|
+
const y1c = cy(y0 + 1);
|
|
150
|
+
const l00 = level[y0c * w + x0c];
|
|
151
|
+
const l10 = level[y0c * w + x1c];
|
|
152
|
+
const l01 = level[y1c * w + x0c];
|
|
153
|
+
const l11 = level[y1c * w + x1c];
|
|
154
|
+
const top = l00 + (l10 - l00) * fx;
|
|
155
|
+
const bottom = l01 + (l11 - l01) * fx;
|
|
156
|
+
return top + (bottom - top) * fy;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A raw level in 0..255 to a nearness in 0..1, `near` applied and the curve run. */
|
|
160
|
+
export function toneLevel(rawLevel: number, near: DepthNear, tone: DepthTone): number {
|
|
161
|
+
const nearness = near === 'white' ? rawLevel / 255 : 1 - rawLevel / 255;
|
|
162
|
+
const curved = (Math.pow(nearness, tone.gamma) - 0.5) * tone.contrast + 0.5 + tone.bias;
|
|
163
|
+
return curved < 0 ? 0 : curved > 1 ? 1 : curved;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The whole chain at one position: sample, `near`, curve, scale.
|
|
168
|
+
*
|
|
169
|
+
* The four steps are the four places two implementations of one model can drift
|
|
170
|
+
* apart, which is why the module header fixes their order and this function is
|
|
171
|
+
* the only thing that runs them.
|
|
172
|
+
*/
|
|
173
|
+
export function sampleDepth(
|
|
174
|
+
map: DepthMap,
|
|
175
|
+
x: number,
|
|
176
|
+
y: number,
|
|
177
|
+
near: DepthNear,
|
|
178
|
+
tone: DepthTone,
|
|
179
|
+
zScale: number,
|
|
180
|
+
): number {
|
|
181
|
+
return toneLevel(sampleLevel(map, x, y), near, tone) * zScale;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A digest of the map's pixels, so a claim can name the sheet it was made from.
|
|
186
|
+
*
|
|
187
|
+
* Over the levels alone, not the PNG file: the same depth re-encoded at a
|
|
188
|
+
* different compression level is the same map, and a digest that changed with
|
|
189
|
+
* the encoder would make provenance unfalsifiable in the direction that
|
|
190
|
+
* matters — two runs the reader believes differ when they do not.
|
|
191
|
+
*/
|
|
192
|
+
export function depthDigest(map: DepthMap): string {
|
|
193
|
+
const h = createHash('sha256');
|
|
194
|
+
const header = new Uint8Array(8);
|
|
195
|
+
new DataView(header.buffer).setUint32(0, map.width);
|
|
196
|
+
new DataView(header.buffer).setUint32(4, map.height);
|
|
197
|
+
h.update(header);
|
|
198
|
+
h.update(map.level);
|
|
199
|
+
return h.digest('hex').slice(0, 16);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Two evaluations of one turn, compared
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The 2.5D turn's displacement along the driving axis, for one point.
|
|
208
|
+
*
|
|
209
|
+
* The same closed form `evaluateDeformTransform` runs, written once more here
|
|
210
|
+
* because this file compares two ways of EVALUATING it and neither may quietly
|
|
211
|
+
* be a different model. `u` is the point's offset from the axis; `z` is how far
|
|
212
|
+
* in front of that axis it sits.
|
|
213
|
+
*/
|
|
214
|
+
export function turnDisplacement(u: number, z: number, radians: number): number {
|
|
215
|
+
return u * (Math.cos(radians) - 1) - z * Math.sin(radians);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** What a field comparison measured. Pixels, in the part's own grid. */
|
|
219
|
+
export interface FieldAgreement {
|
|
220
|
+
/** Pixels compared: inside the art and inside some triangle. */
|
|
221
|
+
samples: number;
|
|
222
|
+
/** Pixels the mesh covers that the art does not reach, or vice versa. */
|
|
223
|
+
skipped: number;
|
|
224
|
+
/** Mean |mesh − continuous| displacement, in part pixels. */
|
|
225
|
+
mean: number;
|
|
226
|
+
/** Worst |mesh − continuous| displacement, in part pixels. */
|
|
227
|
+
worst: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* How closely a mesh's piecewise-linear turn reproduces the continuous one.
|
|
232
|
+
*
|
|
233
|
+
* ## What this measures, and what it does not
|
|
234
|
+
*
|
|
235
|
+
* ⚠️ It is **not** a check of the sampler. Both sides read the same sheet
|
|
236
|
+
* through `sampleDepth`, deliberately — a comparison where the two sides
|
|
237
|
+
* disagreed about the depth would be measuring the wrong thing. `DP01`–`DP03`
|
|
238
|
+
* in `selftest.ts` are what hold the sampler honest.
|
|
239
|
+
*
|
|
240
|
+
* What differs is the **evaluation**. The continuous side gives every pixel its
|
|
241
|
+
* own depth and displaces it by that; the mesh side gives depth to its vertices
|
|
242
|
+
* only and interpolates linearly across each triangle. That is the whole
|
|
243
|
+
* approximation a mesh IS, and this puts a number on it: the two agree where
|
|
244
|
+
* the depth field is locally flat across a cell, and part where it curves.
|
|
245
|
+
*
|
|
246
|
+
* ⭐ So the figure to read is not the absolute error but **how it falls as the
|
|
247
|
+
* lattice refines**. A mesh that is evaluating the same model converges on it;
|
|
248
|
+
* one that is evaluating something else does not, however dense it gets.
|
|
249
|
+
*
|
|
250
|
+
* 🚨 And it is a different quantity from FACE §4.2's fold angle, which gets
|
|
251
|
+
* WORSE as the columns refine. Both are true and they are not in tension:
|
|
252
|
+
* refining the lattice buys fidelity to the model and costs the angle at which
|
|
253
|
+
* a column pair inverts. This measures the first; `A39` refuses the second.
|
|
254
|
+
*/
|
|
255
|
+
export function compareTurnFields(input: {
|
|
256
|
+
map: DepthMap;
|
|
257
|
+
near: DepthNear;
|
|
258
|
+
tone: DepthTone;
|
|
259
|
+
zScale: number;
|
|
260
|
+
/** One byte per pixel over the same grid; a pixel under `threshold` is not art. */
|
|
261
|
+
alpha: Uint8Array;
|
|
262
|
+
threshold: number;
|
|
263
|
+
/** Mesh vertices in part-local pixels, y down. */
|
|
264
|
+
points: ReadonlyArray<readonly [number, number]>;
|
|
265
|
+
triangles: ReadonlyArray<number>;
|
|
266
|
+
degrees: number;
|
|
267
|
+
/** Where the axis crosses the driving coordinate, in part pixels. Default 0. */
|
|
268
|
+
about?: number;
|
|
269
|
+
/** 'yaw' reads x and displaces x; 'pitch' reads y and displaces y. */
|
|
270
|
+
kind?: 'yaw' | 'pitch';
|
|
271
|
+
/**
|
|
272
|
+
* The mesh side's per-vertex `z`, when it should NOT come from the map.
|
|
273
|
+
*
|
|
274
|
+
* Two uses, and the second is the important one. A caller that has the
|
|
275
|
+
* compiler's own sampled depths can pass them, so the comparison is against
|
|
276
|
+
* what was actually emitted rather than against a re-sampling. And a NEGATIVE
|
|
277
|
+
* control can pass depths from a different model entirely — a cylinder's, say
|
|
278
|
+
* — which is what makes the convergence claim mean anything: a mesh
|
|
279
|
+
* evaluating the same model converges on it, and one evaluating another does
|
|
280
|
+
* not, however dense it gets.
|
|
281
|
+
*/
|
|
282
|
+
vertexDepths?: readonly number[];
|
|
283
|
+
}): FieldAgreement {
|
|
284
|
+
const { map, near, tone, zScale, alpha, threshold, points, triangles, degrees } = input;
|
|
285
|
+
const about = input.about ?? 0;
|
|
286
|
+
const along = (input.kind ?? 'yaw') === 'yaw' ? 0 : 1;
|
|
287
|
+
const rad = (degrees * Math.PI) / 180;
|
|
288
|
+
|
|
289
|
+
if (input.vertexDepths !== undefined && input.vertexDepths.length !== points.length) {
|
|
290
|
+
throw new DepthError(
|
|
291
|
+
`the mesh has ${points.length} vertices and ${input.vertexDepths.length} depths were supplied for it`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
// The mesh side, per vertex, once.
|
|
295
|
+
const vertexShift = points.map((p, v) =>
|
|
296
|
+
turnDisplacement(
|
|
297
|
+
p[along] - about,
|
|
298
|
+
input.vertexDepths === undefined ? sampleDepth(map, p[0], p[1], near, tone, zScale) : input.vertexDepths[v],
|
|
299
|
+
rad,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const { width: w, height: h } = map;
|
|
304
|
+
// Which pixels a triangle covered, so a pixel in two triangles is counted
|
|
305
|
+
// once and the untouched remainder can be reported rather than ignored.
|
|
306
|
+
const seen = new Uint8Array(w * h);
|
|
307
|
+
let samples = 0;
|
|
308
|
+
let total = 0;
|
|
309
|
+
let worst = 0;
|
|
310
|
+
|
|
311
|
+
for (let t = 0; t < triangles.length; t += 3) {
|
|
312
|
+
const [ia, ib, ic] = [triangles[t], triangles[t + 1], triangles[t + 2]];
|
|
313
|
+
const [ax, ay] = points[ia];
|
|
314
|
+
const [bx, by] = points[ib];
|
|
315
|
+
const [cx, cy] = points[ic];
|
|
316
|
+
const den = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy);
|
|
317
|
+
if (den === 0) continue; // a degenerate triangle covers nothing
|
|
318
|
+
const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx)));
|
|
319
|
+
const x1 = Math.min(w - 1, Math.ceil(Math.max(ax, bx, cx)));
|
|
320
|
+
const y0 = Math.max(0, Math.floor(Math.min(ay, by, cy)));
|
|
321
|
+
const y1 = Math.min(h - 1, Math.ceil(Math.max(ay, by, cy)));
|
|
322
|
+
for (let py = y0; py <= y1; py++) {
|
|
323
|
+
for (let px = x0; px <= x1; px++) {
|
|
324
|
+
const i = py * w + px;
|
|
325
|
+
if (seen[i] || alpha[i] < threshold) continue;
|
|
326
|
+
// Pixel centres, the same convention the sampler uses.
|
|
327
|
+
const sx = px + 0.5;
|
|
328
|
+
const sy = py + 0.5;
|
|
329
|
+
const l0 = ((by - cy) * (sx - cx) + (cx - bx) * (sy - cy)) / den;
|
|
330
|
+
const l1 = ((cy - ay) * (sx - cx) + (ax - cx) * (sy - cy)) / den;
|
|
331
|
+
const l2 = 1 - l0 - l1;
|
|
332
|
+
if (l0 < 0 || l1 < 0 || l2 < 0) continue;
|
|
333
|
+
seen[i] = 1;
|
|
334
|
+
const meshShift = l0 * vertexShift[ia] + l1 * vertexShift[ib] + l2 * vertexShift[ic];
|
|
335
|
+
const u = (along === 0 ? sx : sy) - about;
|
|
336
|
+
const exact = turnDisplacement(u, sampleDepth(map, sx, sy, near, tone, zScale), rad);
|
|
337
|
+
const d = Math.abs(meshShift - exact);
|
|
338
|
+
samples++;
|
|
339
|
+
total += d;
|
|
340
|
+
if (d > worst) worst = d;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let skipped = 0;
|
|
346
|
+
for (let i = 0; i < alpha.length; i++) if (alpha[i] >= threshold && !seen[i]) skipped++;
|
|
347
|
+
return { samples, skipped, mean: samples === 0 ? 0 : total / samples, worst };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// The turn a mesh can take before it folds
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The relative floor under a triangle's setup area, below which no ceiling is
|
|
356
|
+
* quoted for it.
|
|
357
|
+
*
|
|
358
|
+
* ⚠️ **Deliberately not `deformmeasure.ts`'s `DEFORM_AREA_EPSILON`, and the two
|
|
359
|
+
* must not be merged.** That one is a *shape band* on a measured reversal,
|
|
360
|
+
* combined with a float32 noise bound, and it decides whether a triangle the
|
|
361
|
+
* artifact already holds has turned over. This one guards a DIVISION: the
|
|
362
|
+
* ceiling below is `A0 / A_axis`, and a setup triangle with no area to speak of
|
|
363
|
+
* gives an angle of nearly zero that says nothing about the sheet.
|
|
364
|
+
*
|
|
365
|
+
* 🔒 What keeps them from drifting is not a shared constant — the compiler
|
|
366
|
+
* cannot import that file without linking the runtime — but a control:
|
|
367
|
+
* `TC01` in `selftest.ts` requires the ceiling reported here to be the angle
|
|
368
|
+
* `A39` actually fires at, on the triangle it actually names. A disagreement
|
|
369
|
+
* between these two numbers is a red test, not a silent difference.
|
|
370
|
+
*/
|
|
371
|
+
const CEILING_AREA_FLOOR = 1e-6;
|
|
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
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Where one triangle turns inside out, which triangle that is — and, beside it,
|
|
450
|
+
* what the REST of this axis and side's triangles do.
|
|
451
|
+
*
|
|
452
|
+
* Everything down to `stepShare` is about one triangle. `count` and `p1` are
|
|
453
|
+
* about the population it is the minimum of, and they are here because the
|
|
454
|
+
* minimum alone cannot answer the question an author actually has: `degrees` is
|
|
455
|
+
* the same number whether a whole band of the mesh reaches the limit together
|
|
456
|
+
* or one triangle does, and those are a form and a bad texel respectively
|
|
457
|
+
* ([#412](https://github.com/firejune/rigc/issues/412),
|
|
458
|
+
* `bench/studies/2026-09-05-noise` §6).
|
|
459
|
+
*
|
|
460
|
+
* ⚠️ And a band is not sufficient evidence of a form either, which is what
|
|
461
|
+
* `stepShare` is here for: an OUTLINE is a band, so a mesh whose ceiling is set
|
|
462
|
+
* by the occlusion edge of a cut-out reads `p1/degrees` near 1 and a `depthStep`
|
|
463
|
+
* of most of the sheet's range — both of the older figures reading *healthy* on
|
|
464
|
+
* the same measurement ([#448](https://github.com/firejune/rigc/issues/448)).
|
|
465
|
+
*
|
|
466
|
+
* ⛔ Neither figure changes `degrees`, and neither is a threshold. rigc does not
|
|
467
|
+
* have the authority to guess its input away, so nothing here filters,
|
|
468
|
+
* smooths or rejects a sample — the ceiling stays the raw sheet read through
|
|
469
|
+
* the mesh, which is the only thing `A39` will agree with. What to read off the
|
|
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).
|
|
476
|
+
*/
|
|
477
|
+
export interface FoldLimit {
|
|
478
|
+
/** Degrees from setup, in (0, 90). */
|
|
479
|
+
degrees: number;
|
|
480
|
+
/** Which triangle: its ordinal in the triangle list, not an index into it. */
|
|
481
|
+
triangle: number;
|
|
482
|
+
/** Its three vertex indices, so a message can name them. */
|
|
483
|
+
ids: [number, number, number];
|
|
484
|
+
/**
|
|
485
|
+
* The largest depth difference between two of THIS triangle's vertices, in
|
|
486
|
+
* the units `z` was supplied in.
|
|
487
|
+
*
|
|
488
|
+
* A reading of the sheet and not a restatement of the angle: pass it through
|
|
489
|
+
* `depthStepLevels` and it is the step in encoding levels, which is what says
|
|
490
|
+
* whether the sheet had anything to say across this triangle at all. One
|
|
491
|
+
* level is the smallest step an 8-bit sheet can carry, and at one level the
|
|
492
|
+
* ceiling is exactly `atan(255·h / zScale)` — arithmetic about the encoding
|
|
493
|
+
* with no form left in it (`bench/studies/2026-09-05-noise` §3).
|
|
494
|
+
*/
|
|
495
|
+
depthStep: number;
|
|
496
|
+
/**
|
|
497
|
+
* `depthStep` over the depth range this mesh actually sampled — what fraction
|
|
498
|
+
* of everything the sheet said across the whole part it said across the one
|
|
499
|
+
* triangle that folds first.
|
|
500
|
+
*
|
|
501
|
+
* ⭐ The figure that tells a form from a cliff, and it is the one reading the
|
|
502
|
+
* other two cannot give ([#448](https://github.com/firejune/rigc/issues/448)).
|
|
503
|
+
* A form has a slope, so refining the lattice halves the step and halves this
|
|
504
|
+
* with it while the angle converges. A **discontinuity has no slope**: the
|
|
505
|
+
* step stays the whole range however fine the lattice gets, this figure pins
|
|
506
|
+
* near 1, and the ceiling halves with every doubling instead of converging —
|
|
507
|
+
* `tan t ∝ h`, an angle that describes nothing at any density.
|
|
508
|
+
*
|
|
509
|
+
* ⚠️ **The ceiling is not wrong when this reads high; the input is not a
|
|
510
|
+
* surface.** Measured on estimated sheets: reported 1.936°, and the runtime
|
|
511
|
+
* admits +1° and reverses 8 triangles at +2°. The rig genuinely folds at two
|
|
512
|
+
* degrees. What a `depthStep` near the whole range means is an occlusion
|
|
513
|
+
* boundary — figure against background, or one part of a figure over
|
|
514
|
+
* another — and a 2.5D turn does not model occlusion at all, so **no angle is
|
|
515
|
+
* the right one to quote for it**. The fix is upstream of the ceiling: mesh
|
|
516
|
+
* only what is continuous, or state a sheet that was authored rather than
|
|
517
|
+
* estimated.
|
|
518
|
+
*
|
|
519
|
+
* 🔒 In (0, 1] by construction, never a division by zero. `depthStep` is a
|
|
520
|
+
* difference between two of this mesh's own `z` values, so the span over all
|
|
521
|
+
* of them is at least as large; and a fold only exists where the axis area
|
|
522
|
+
* with `z` substituted in is non-zero, which needs two vertices of the
|
|
523
|
+
* triangle at different depths — so a mesh with no span reports no fold and
|
|
524
|
+
* never reaches the divide.
|
|
525
|
+
*
|
|
526
|
+
* ⛔ A report and never a threshold. rigc does not decide that an author's
|
|
527
|
+
* sheet is the wrong kind of thing; nothing here filters, and nothing here
|
|
528
|
+
* moves a ceiling. What to read off the number is stated in
|
|
529
|
+
* `docs/AUTHORING.md` §3.4.
|
|
530
|
+
*/
|
|
531
|
+
stepShare: number;
|
|
532
|
+
/** How many triangles fold on this axis and side — the population below. */
|
|
533
|
+
count: number;
|
|
534
|
+
/**
|
|
535
|
+
* The 1st percentile of those triangles' fold angles in degrees, or `null`
|
|
536
|
+
* where the population is too small to have one.
|
|
537
|
+
*
|
|
538
|
+
* Nearest-rank on the ascending angles, the same definition the noise study
|
|
539
|
+
* measured its distribution tables with: index `round(0.01·(count−1))`. That
|
|
540
|
+
* index is **zero for every `count` below 51**, so on a small mesh the first
|
|
541
|
+
* percentile is arithmetically the minimum and a ratio of exactly 1.000 would
|
|
542
|
+
* be printed for a sheet with one bad texel in it as readily as for a form.
|
|
543
|
+
*
|
|
544
|
+
* ⭐ `null` rather than that number, for the reason `A21` needed a third
|
|
545
|
+
* `meshKinds` state: a default is how "nothing to measure" quietly becomes a
|
|
546
|
+
* measurement of the wrong thing. `count` is reported beside it so a reader
|
|
547
|
+
* can see how thin a population a printed percentile came from — at 51 it is
|
|
548
|
+
* the second-smallest angle, and a limit two triangles share is not yet a
|
|
549
|
+
* band.
|
|
550
|
+
*/
|
|
551
|
+
p1: number | null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* A depth step in the units `z` was supplied in, restated in ENCODING LEVELS.
|
|
556
|
+
*
|
|
557
|
+
* One arithmetic in one place: `zScale` spans the sheet's full 0..255 range, so
|
|
558
|
+
* one level is `zScale/255` world units. The CLI prints this and the controls
|
|
559
|
+
* assert on it, and neither writes the division out again.
|
|
560
|
+
*/
|
|
561
|
+
export function depthStepLevels(depthStep: number, zScale: number): number {
|
|
562
|
+
return (depthStep * 255) / zScale;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Which index of an ascending list of `n` the nearest-rank `q` percentile is.
|
|
567
|
+
*
|
|
568
|
+
* The same rule `bench/studies/2026-09-05-noise` measured its distribution
|
|
569
|
+
* tables with, so the figure the compiler prints and the figure that study
|
|
570
|
+
* reports are one statistic. Round-half-up makes it deterministic for every
|
|
571
|
+
* length, which `A18` requires. **Zero is a real answer and the caller has to
|
|
572
|
+
* read it as one** — it means the percentile of this population is its own
|
|
573
|
+
* minimum, which is a fact about the population and not a percentile.
|
|
574
|
+
*/
|
|
575
|
+
function nearestRankIndex(n: number, q: number): number {
|
|
576
|
+
return Math.min(n - 1, Math.max(0, Math.round(q * (n - 1))));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* What a mesh's own geometry says about the turn it can take, per axis and per
|
|
581
|
+
* direction. `null` where nothing in the mesh folds short of 90°.
|
|
582
|
+
*/
|
|
583
|
+
export interface TurnCeiling {
|
|
584
|
+
yaw: { positive: FoldLimit | null; negative: FoldLimit | null };
|
|
585
|
+
pitch: { positive: FoldLimit | null; negative: FoldLimit | null };
|
|
586
|
+
/** Triangles with enough setup area to give an answer. */
|
|
587
|
+
measured: number;
|
|
588
|
+
/** Triangles already flat in setup, which no angle makes worse. */
|
|
589
|
+
degenerate: number;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The largest turn this mesh takes on this depth before a triangle reverses.
|
|
594
|
+
*
|
|
595
|
+
* ## The arithmetic, in full, because it is three lines
|
|
596
|
+
*
|
|
597
|
+
* A `yaw` moves each vertex to `x' = u·cos t − z·sin t` and leaves `y` alone, so
|
|
598
|
+
* a triangle's doubled signed area is *linear in the two trig terms*:
|
|
599
|
+
*
|
|
600
|
+
* 2A(t) = cos t · [Δu_b·Δy_c − Δu_c·Δy_b] − sin t · [Δz_b·Δy_c − Δz_c·Δy_b]
|
|
601
|
+
* = 2A₀·cos t − 2A_yaw·sin t
|
|
602
|
+
*
|
|
603
|
+
* where `A_yaw` is the setup area with **z substituted for u**. It crosses zero
|
|
604
|
+
* at `tan t = A₀ / A_yaw` — exactly, with no search and no iteration. A `pitch`
|
|
605
|
+
* is the same statement with the substitution in the other slot.
|
|
606
|
+
*
|
|
607
|
+
* ⭐ **The sign of that ratio picks the direction.** A positive ratio folds at
|
|
608
|
+
* `+atan(ratio)` and a negative one at `−atan|ratio|`, so every triangle folds
|
|
609
|
+
* in exactly ONE direction and a part's two ceilings are generally different.
|
|
610
|
+
* Reporting one number for both would be quoting the tighter of two answers as
|
|
611
|
+
* if it were the only one — a face that turns 30° left and 18° right is the
|
|
612
|
+
* ordinary case, not an anomaly.
|
|
613
|
+
*
|
|
614
|
+
* ## Why this is a report and not a refusal
|
|
615
|
+
*
|
|
616
|
+
* `A39` already refuses a key that folds, from the artifact, through the
|
|
617
|
+
* runtime. This measures the same wall from the other side and *before* a key
|
|
618
|
+
* is written, which is the whole of its value: the loop it replaces is "pick an
|
|
619
|
+
* angle, build, read the refusal, guess again". Adding a second refusal here
|
|
620
|
+
* would be the compiler inventing a policy out of a measurement.
|
|
621
|
+
*
|
|
622
|
+
* ## What the minimum alone cannot say (issue #412)
|
|
623
|
+
*
|
|
624
|
+
* The four angles are correct and, on a noisy sheet, useless on their own. A
|
|
625
|
+
* clean 8-bit sheet read at 4,225 vertices reports 64.58°; move ONE texel of
|
|
626
|
+
* 160,000 by 245 levels and the same sheet reports 6.08° — and `A39` refuses at
|
|
627
|
+
* both, measured to 0°. So each `FoldLimit` also carries `p1`, the 1st
|
|
628
|
+
* percentile of its own side's fold angles, and `depthStep`, what the sheet
|
|
629
|
+
* changed across the triangle that goes first. A `p1/degrees` near 1 is a band
|
|
630
|
+
* of the mesh reaching the limit together, which is a form; near 10 it is one
|
|
631
|
+
* triangle, which is a texel. A `depthStep` of one level is `atan(255·h/zScale)`
|
|
632
|
+
* and says nothing about the form at all.
|
|
633
|
+
*
|
|
634
|
+
* ## What a band cannot say either (issue #448)
|
|
635
|
+
*
|
|
636
|
+
* Both of those figures read *healthy* on an estimated depth sheet over cut-out
|
|
637
|
+
* art, and they do not merely stay silent — they affirm it. `p1/degrees` comes
|
|
638
|
+
* back at 1.02–2.17, which reads as a band; `depthStep` at 148–252 levels of
|
|
639
|
+
* 255, which reads as plenty said. It **is** a band, because an outline is long,
|
|
640
|
+
* and the sheet did say a great deal across that triangle — it said the whole
|
|
641
|
+
* distance from the figure to the background in one step.
|
|
642
|
+
*
|
|
643
|
+
* So each `FoldLimit` also carries `stepShare`, the same step divided by the
|
|
644
|
+
* range this mesh sampled. A form's halves per refinement while its angle
|
|
645
|
+
* converges; a discontinuity's pins near 1 while the angle halves. Measured:
|
|
646
|
+
* rigc's own gallery reads 0.112 and 0.468, a synthetic raised cosine 0.394
|
|
647
|
+
* falling to 0.027 under refinement, the same cosine with one planted cliff a
|
|
648
|
+
* flat 0.50, and estimated sheets 0.92–0.99.
|
|
649
|
+
*
|
|
650
|
+
* ⛔ All three are reports. Nothing here filters the sheet, and nothing here
|
|
651
|
+
* moves a ceiling: a smoothed measurement would describe a surface the deform
|
|
652
|
+
* key is not built from, and would part company with the gate that reads the
|
|
653
|
+
* raw one.
|
|
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
|
+
*
|
|
668
|
+
* @param points Vertices in the BIND space the deform offsets are authored in.
|
|
669
|
+
* Areas are translation-invariant, so the origin does not matter; the scale
|
|
670
|
+
* and the axis directions do. A y flip alone leaves a `yaw` answer alone and
|
|
671
|
+
* SWAPS a `pitch`'s two directions, which is why the caller composes the
|
|
672
|
+
* emitter's own mapping rather than approximating it.
|
|
673
|
+
* @param z One depth per vertex, in those same units.
|
|
674
|
+
*/
|
|
675
|
+
export function turnCeiling(
|
|
676
|
+
points: ReadonlyArray<readonly [number, number]>,
|
|
677
|
+
z: readonly number[],
|
|
678
|
+
triangles: ReadonlyArray<number>,
|
|
679
|
+
): TurnCeiling {
|
|
680
|
+
if (z.length !== points.length) {
|
|
681
|
+
throw new DepthError(`the mesh has ${points.length} vertices and ${z.length} depths were supplied for it`);
|
|
682
|
+
}
|
|
683
|
+
const out: TurnCeiling = {
|
|
684
|
+
yaw: { positive: null, negative: null },
|
|
685
|
+
pitch: { positive: null, negative: null },
|
|
686
|
+
measured: 0,
|
|
687
|
+
degenerate: 0,
|
|
688
|
+
};
|
|
689
|
+
// The floor is relative, so it needs the mesh's own scale first.
|
|
690
|
+
let largest = 0;
|
|
691
|
+
const areas: number[] = [];
|
|
692
|
+
for (let t = 0; t < triangles.length; t += 3) {
|
|
693
|
+
const [ia, ib, ic] = [triangles[t], triangles[t + 1], triangles[t + 2]];
|
|
694
|
+
const a = (points[ib][0] - points[ia][0]) * (points[ic][1] - points[ia][1])
|
|
695
|
+
- (points[ic][0] - points[ia][0]) * (points[ib][1] - points[ia][1]);
|
|
696
|
+
areas.push(a);
|
|
697
|
+
if (Math.abs(a) > largest) largest = Math.abs(a);
|
|
698
|
+
}
|
|
699
|
+
const floor = largest * CEILING_AREA_FLOOR;
|
|
700
|
+
|
|
701
|
+
// The denominator `stepShare` is taken against: the depth range this mesh
|
|
702
|
+
// sampled, which is `range` in the report read off the same array. Taken over
|
|
703
|
+
// the WHOLE mesh rather than per triangle, because the question the figure
|
|
704
|
+
// answers is how much of what the sheet said here one triangle said.
|
|
705
|
+
let zLo = Infinity;
|
|
706
|
+
let zHi = -Infinity;
|
|
707
|
+
for (const d of z) {
|
|
708
|
+
if (d < zLo) zLo = d;
|
|
709
|
+
if (d > zHi) zHi = d;
|
|
710
|
+
}
|
|
711
|
+
const zSpan = zHi - zLo;
|
|
712
|
+
|
|
713
|
+
// One list per axis and side, so the ceiling can say whether it is the floor
|
|
714
|
+
// of a BAND or of a single triangle. Nothing here filters: every measurable
|
|
715
|
+
// triangle goes in exactly once, in triangle order, and the sort below is
|
|
716
|
+
// numeric — `A18` compares a second compile byte for byte.
|
|
717
|
+
const angles = {
|
|
718
|
+
yaw: { positive: [] as number[], negative: [] as number[] },
|
|
719
|
+
pitch: { positive: [] as number[], negative: [] as number[] },
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
for (let t = 0, n = 0; t < triangles.length; t += 3, n++) {
|
|
723
|
+
const [ia, ib, ic] = [triangles[t], triangles[t + 1], triangles[t + 2]];
|
|
724
|
+
const a0 = areas[n];
|
|
725
|
+
if (Math.abs(a0) <= floor) {
|
|
726
|
+
out.degenerate++;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
out.measured++;
|
|
730
|
+
const dyb = points[ib][1] - points[ia][1];
|
|
731
|
+
const dyc = points[ic][1] - points[ia][1];
|
|
732
|
+
const dxb = points[ib][0] - points[ia][0];
|
|
733
|
+
const dxc = points[ic][0] - points[ia][0];
|
|
734
|
+
const dzb = z[ib] - z[ia];
|
|
735
|
+
const dzc = z[ic] - z[ia];
|
|
736
|
+
const ids: [number, number, number] = [ia, ib, ic];
|
|
737
|
+
// The sheet read through THIS triangle, in the units `z` came in. It is the
|
|
738
|
+
// step, not the angle: a triangle whose three vertices sample one level
|
|
739
|
+
// apart has nothing but the encoding to say, whatever angle that works out
|
|
740
|
+
// to. Reported per fold below, and never used to change one.
|
|
741
|
+
const depthStep = Math.max(Math.abs(dzb), Math.abs(dzc), Math.abs(dzc - dzb));
|
|
742
|
+
// z in the driven axis's slot: x for a yaw, y for a pitch.
|
|
743
|
+
for (const [axis, aAxis] of [
|
|
744
|
+
['yaw', dzb * dyc - dzc * dyb],
|
|
745
|
+
['pitch', dxb * dzc - dxc * dzb],
|
|
746
|
+
] as const) {
|
|
747
|
+
// A zero here is a triangle the axis cannot fold at all: its area stays
|
|
748
|
+
// `A₀·cos t`, which only reaches zero at a right angle.
|
|
749
|
+
if (aAxis === 0) continue;
|
|
750
|
+
const ratio = a0 / aAxis;
|
|
751
|
+
const degrees = (Math.atan(Math.abs(ratio)) * 180) / Math.PI;
|
|
752
|
+
const side = ratio > 0 ? 'positive' : 'negative';
|
|
753
|
+
angles[axis][side].push(degrees);
|
|
754
|
+
const held = out[axis][side];
|
|
755
|
+
// `count` and `p1` are filled once the whole population is in; a minimum
|
|
756
|
+
// cannot know its own percentile while it is still being found.
|
|
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)) {
|
|
761
|
+
// `zSpan` cannot be zero here: `aAxis !== 0` needs two of this
|
|
762
|
+
// triangle's vertices at different depths, and the span over the whole
|
|
763
|
+
// mesh is at least that difference. A guard would be an unreachable
|
|
764
|
+
// branch, and an unreachable branch is not a control.
|
|
765
|
+
out[axis][side] = { degrees, triangle: n, ids, depthStep, stepShare: depthStep / zSpan, count: 0, p1: null };
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
for (const axis of ['yaw', 'pitch'] as const) {
|
|
771
|
+
for (const side of ['positive', 'negative'] as const) {
|
|
772
|
+
const held = out[axis][side];
|
|
773
|
+
if (held === null) continue;
|
|
774
|
+
const sorted = angles[axis][side].slice().sort((a, b) => a - b);
|
|
775
|
+
const rank = nearestRankIndex(sorted.length, 0.01);
|
|
776
|
+
held.count = sorted.length;
|
|
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);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return out;
|
|
784
|
+
}
|