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/chainfit.ts
ADDED
|
@@ -0,0 +1,2752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chainfit — where the parts `pose` cannot see sit, read through the candidate rig.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ This is `pose`'s sibling, not its replacement, and the difference is one
|
|
5
|
+
* input. `pose` is handed a picture and a pile of loose PNGs and nothing else, so
|
|
6
|
+
* it searches each part's full rigid family — two translations, a rotation and a
|
|
7
|
+
* scale — and it is honest about what that costs on a dense figure: a part drawn
|
|
8
|
+
* behind another has the occluder's pixels where its own should be, so its
|
|
9
|
+
* residual rises **at the correct placement**. On a biped in a stance that is the
|
|
10
|
+
* far arm, both thighs, the feet, the fists and whatever the hands hold.
|
|
11
|
+
*
|
|
12
|
+
* chainfit is handed the **candidate rig as well**. That buys two things `pose`
|
|
13
|
+
* structurally cannot have:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Draw order**, so occlusion becomes measurable rather than a caveat. The
|
|
16
|
+
* parts drawn after a part are exactly what covers it, and the pixels they
|
|
17
|
+
* cover are EXCLUDED from that part's objective instead of charged to it. A
|
|
18
|
+
* residual here is over the part's VISIBLE pixels, and `visibleShare` says how
|
|
19
|
+
* much of the part that was — so a 12%-visible answer carries its uncertainty
|
|
20
|
+
* in the report rather than in the reader's head.
|
|
21
|
+
* 2. **Hierarchy and attachment geometry**, so the search collapses. A child bone
|
|
22
|
+
* whose parent is already placed does not have four degrees of freedom: its
|
|
23
|
+
* pivot is fixed by the rig's own joint offset, so all that is left is the
|
|
24
|
+
* hinge — one rotation about that pivot — plus a stretch, and only where the
|
|
25
|
+
* candidate's own timelines say the rig leaves scale free. One degree of
|
|
26
|
+
* freedom instead of four also kills the duplication `pose` has to report as
|
|
27
|
+
* ambiguous: two identical limbs are no longer two equal answers when each of
|
|
28
|
+
* them hangs off a different placed shoulder.
|
|
29
|
+
*
|
|
30
|
+
* 🚫 **Usage phase, so nothing here is a score and no pass bar attaches.** Same
|
|
31
|
+
* framing as `pose`, and for the same reason: this reads a **given condition** —
|
|
32
|
+
* the pose the user handed over — into spec coordinates an agent then states by
|
|
33
|
+
* construction. The residual and `visibleShare` exist so a caller knows how far
|
|
34
|
+
* to trust a placement and where two answers are equally good. The only
|
|
35
|
+
* thresholds in this file are the ones that decide whether to print an answer at
|
|
36
|
+
* all, and every one of them is reported and movable.
|
|
37
|
+
*
|
|
38
|
+
* 🔍 The objective is `src/pose.ts`'s, borrowed rather than rewritten — see the
|
|
39
|
+
* ⭐ note at the head of that file. What this adds is the mask, and the one place
|
|
40
|
+
* a mask can go wrong is worth stating before the code: **the visible set is
|
|
41
|
+
* frozen in the part's own space before the search runs**, at the placement the
|
|
42
|
+
* rig itself predicts. A mask recomputed per candidate placement makes the
|
|
43
|
+
* denominator a free variable, and the cheapest move is then to slide the part
|
|
44
|
+
* until the occluder covers nearly all of it and a handful of agreeing pixels are
|
|
45
|
+
* all that is scored. Frozen, the denominator is a constant and a move can only
|
|
46
|
+
* be paid for by agreeing with the frame.
|
|
47
|
+
*
|
|
48
|
+
* ⬇️ **The walk goes outward from an anchor, which means downward only.** An
|
|
49
|
+
* anchored part fixes its own bone completely — four numbers read off the picture
|
|
50
|
+
* for the four a similarity has — and every descendant then follows from the rig.
|
|
51
|
+
* A bone ABOVE an anchor does not: recovering it would need to know what the link
|
|
52
|
+
* between them did, which is precisely the unknown the anchor does not carry. So a
|
|
53
|
+
* limb with no trusted part on it or above it is refused rather than guessed at
|
|
54
|
+
* from a cousin.
|
|
55
|
+
*
|
|
56
|
+
* ⬆️ **The one exception is the INWARD step, and it is exactly one shape: a bone
|
|
57
|
+
* with two or more anchored descendants.** One anchored descendant carries no
|
|
58
|
+
* information about the link above it, which is the sentence above. TWO of them on
|
|
59
|
+
* different sub-chains carry something else entirely — not the links, but the
|
|
60
|
+
* bone's own placement. A bone's world transform has four numbers; a descendant's
|
|
61
|
+
* PIVOT depends on that bone and on the rig's own offsets and NOT on the
|
|
62
|
+
* descendant's own hinge, so each anchored descendant contributes two equations.
|
|
63
|
+
* Two of them make four, and four equations fix four numbers. That is the whole
|
|
64
|
+
* geometry, and `solveFromDescendants` is the whole solver.
|
|
65
|
+
*
|
|
66
|
+
* ⚠️ Which means the inward step reaches exactly one kind of bone: **one that
|
|
67
|
+
* branches.** A bone with a single child sub-chain can never be determined however
|
|
68
|
+
* good the anchor below it is — that is `no-bracket`, refused by name. Measured on
|
|
69
|
+
* the 2026-09-03 spineboy candidate, `torso` is the only bone in the rig that
|
|
70
|
+
* branches, and it is the bone the run recorded 30 `no-anchor` frames for.
|
|
71
|
+
*
|
|
72
|
+
* 🚫 And a determination is NOT a measurement of the bone it places. Its evidence
|
|
73
|
+
* lives on the anchors, so the numbers that price it are `disagreementPx`,
|
|
74
|
+
* `redundancy` and `leverPx` — not the bone's own `residual`. The visibility floor
|
|
75
|
+
* still refuses an inward placement nothing in the frame can confirm, for the same
|
|
76
|
+
* reason it refuses an anchor: the floor is about what the picture can check, not
|
|
77
|
+
* about how the number was arrived at.
|
|
78
|
+
*
|
|
79
|
+
* Coordinates are the frame's own — **frame pixels, y down, origin top-left** —
|
|
80
|
+
* exactly as in a `pose` report, so the two are readable side by side. The one
|
|
81
|
+
* addition is `hingeDeg` and `localRotationDeg`, which are **Spine** degrees
|
|
82
|
+
* (CCW, y up) because they are timeline values: what a `rotate` key would carry.
|
|
83
|
+
*/
|
|
84
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
85
|
+
import { basename, join, resolve } from 'node:path';
|
|
86
|
+
import { Plate, readPlate } from '../tools/plate.ts';
|
|
87
|
+
import {
|
|
88
|
+
AMBIGUITY_ABSOLUTE,
|
|
89
|
+
AMBIGUITY_RELATIVE,
|
|
90
|
+
DEFAULT_MAX_RESIDUAL,
|
|
91
|
+
POSE_SPEC,
|
|
92
|
+
UNEXPLAINED_TOLERANCE,
|
|
93
|
+
buildSamples,
|
|
94
|
+
errBilinear,
|
|
95
|
+
errNearest,
|
|
96
|
+
estimatePose,
|
|
97
|
+
halvePlate,
|
|
98
|
+
levelOf,
|
|
99
|
+
materialPlate,
|
|
100
|
+
normaliseDegrees,
|
|
101
|
+
readBackground,
|
|
102
|
+
roundTo,
|
|
103
|
+
type Level,
|
|
104
|
+
type PoseBackground,
|
|
105
|
+
type PoseReport,
|
|
106
|
+
type Samples,
|
|
107
|
+
} from './pose.ts';
|
|
108
|
+
import type { SpineBone, SpineRegionAttachment, SpineSkeletonJson } from './types.ts';
|
|
109
|
+
|
|
110
|
+
export class ChainFitError extends Error {}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The `spec` field every report carries, so a consumer can refuse a future shape.
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ Bumped from `rigc-chainfit/1` by the inward step, and the bump is the point:
|
|
116
|
+
* a consumer that switched exhaustively on `role` or on `refusal.reason` now has
|
|
117
|
+
* two values it has never seen — `inward` and `no-bracket` — and a bone whose
|
|
118
|
+
* refusal used to read `no-anchor` can now read `no-bracket` instead. Everything
|
|
119
|
+
* `/1` carried is still there and still means the same thing; what changed is the
|
|
120
|
+
* range of two enums, which is exactly what a version exists to announce.
|
|
121
|
+
*/
|
|
122
|
+
export const CHAINFIT_SPEC = 'rigc-chainfit/2';
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// the constants the search is made of — every one of them is reported
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Share of a part's own alpha weight that has to survive the occluders before an
|
|
130
|
+
* answer about it is printed rather than refused.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ Not a pass bar; it is where "this measurement is of that part" stops being
|
|
133
|
+
* true. Below it the residual is a statement about a sliver, and the refusal names
|
|
134
|
+
* the measured share beside this number so a caller who wants the sliver can read
|
|
135
|
+
* past it — the placement is still filled in.
|
|
136
|
+
*/
|
|
137
|
+
export const DEFAULT_MIN_VISIBLE = 0.25;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Alpha, 0..255, at which a pixel of a later-drawn part counts as covering.
|
|
141
|
+
*
|
|
142
|
+
* The same threshold the 2026-09-03 measurement run's occluder masks used, and it
|
|
143
|
+
* is a threshold rather than a blend because the question is binary: either this
|
|
144
|
+
* pixel of the frame is evidence about this part, or it is evidence about the
|
|
145
|
+
* thing in front of it.
|
|
146
|
+
*/
|
|
147
|
+
export const OCCLUDER_ALPHA = 96;
|
|
148
|
+
|
|
149
|
+
/** Default hinge window, in Spine degrees about the bone's setup rotation. */
|
|
150
|
+
export const DEFAULT_HINGE_MIN = -180;
|
|
151
|
+
export const DEFAULT_HINGE_MAX = 180;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The CEILING on the hinge sweep's step, in degrees — not the step itself.
|
|
155
|
+
*
|
|
156
|
+
* `hingeLadder` divides the window into whole steps no coarser than this, so a
|
|
157
|
+
* window whose span this does not divide is walked at an even, finer step, and
|
|
158
|
+
* the step the report states is read off that ladder. The full turn divides it
|
|
159
|
+
* exactly, which is why the default report carries this number.
|
|
160
|
+
*
|
|
161
|
+
* A full turn at this step is 120 evaluations of one bone, which is what a single
|
|
162
|
+
* degree of freedom buys: `pose` cannot afford an exhaustive rotation ladder at
|
|
163
|
+
* every position and every scale, and this has no positions or scales to cross.
|
|
164
|
+
* So the default window is the whole turn. A window that does not contain the
|
|
165
|
+
* truth is the failure `pose`'s §11.4 warns about — it does not reliably refuse,
|
|
166
|
+
* it reports the best thing inside the window — and one degree of freedom is cheap
|
|
167
|
+
* enough not to have to run that risk by default.
|
|
168
|
+
*/
|
|
169
|
+
export const HINGE_STEP = 3;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Ratio window the stretch degree of freedom is searched over, when it is free.
|
|
173
|
+
*
|
|
174
|
+
* Free means the candidate's own animations key a `scale` timeline on that bone,
|
|
175
|
+
* or the caller named `--stretch`. A rig that never scales a bone is a rig saying
|
|
176
|
+
* that bone does not stretch, and inventing the freedom would hand back a length
|
|
177
|
+
* the spec does not have.
|
|
178
|
+
*/
|
|
179
|
+
export const DEFAULT_STRETCH_RATIO = 1.25;
|
|
180
|
+
|
|
181
|
+
/** Rungs the stretch ladder gets across its window. */
|
|
182
|
+
export const STRETCH_STEPS = 4;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* How many times the masks are rebuilt from the answers and the fit rerun.
|
|
186
|
+
*
|
|
187
|
+
* The first pass freezes each part's visible set at the placement the RIG
|
|
188
|
+
* predicts, which is the only seed available before anything is fitted. Where the
|
|
189
|
+
* fit then moves a limb a long way, that frozen set was measured somewhere the
|
|
190
|
+
* part no longer is — `visibleShareAtFit` is the field that says so. A second pass
|
|
191
|
+
* re-freezes on the first pass's own answers, which is why two is the default and
|
|
192
|
+
* one is a legitimate, faster, less converged choice.
|
|
193
|
+
*/
|
|
194
|
+
export const DEFAULT_PASSES = 2;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* What makes a `pose` answer good enough to anchor a chain on.
|
|
198
|
+
*
|
|
199
|
+
* ⭐ These two numbers are the 2026-09-03 measurement run's own "clean frame"
|
|
200
|
+
* criterion, taken rather than invented: that run folded 147 `pose` reports and
|
|
201
|
+
* counted a part as read when its residual was within 0.16, its `unexplained`
|
|
202
|
+
* within 0.45, and it came back unambiguous. Anchoring asks the same question —
|
|
203
|
+
* *is this placement trustworthy enough to hang other placements off?* — so it
|
|
204
|
+
* gets the same line, and a caller who moves it moves a reported field.
|
|
205
|
+
*/
|
|
206
|
+
export const ANCHOR_MAX_RESIDUAL = 0.16;
|
|
207
|
+
export const ANCHOR_MAX_UNEXPLAINED = 0.45;
|
|
208
|
+
|
|
209
|
+
/** Two hinge answers this close are one answer under two names. */
|
|
210
|
+
export const AMBIGUITY_HINGE_DEG = 5;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* How far apart, in frame pixels, two anchored descendants have to sit before the
|
|
214
|
+
* rotation they determine is worth printing.
|
|
215
|
+
*
|
|
216
|
+
* ⭐ Derived rather than picked. An inward determination reads the bone's rotation
|
|
217
|
+
* off the DIRECTION between two anchored pivots, so a pivot error of ε px across a
|
|
218
|
+
* lever of L px is an angle error of about ε / L radians. The anchor pass's own
|
|
219
|
+
* placements are good to roughly half a pixel at best, and the chain-fit suite's
|
|
220
|
+
* hinge tolerance is 3° = 0.052 rad, so a lever that keeps a half-pixel error
|
|
221
|
+
* inside that tolerance has to be at least 0.5 / 0.052 ≈ 9.5 px. Eight is that
|
|
222
|
+
* arithmetic rounded down to a number a reader can hold, and it is a **reported,
|
|
223
|
+
* movable field** (`--inward-lever`) like every other threshold in this file.
|
|
224
|
+
*
|
|
225
|
+
* ⚠️ It is not a pass bar. Below it the bone is refused `no-bracket` naming the
|
|
226
|
+
* measured lever beside this number, and — unlike `occluded` — nothing is printed,
|
|
227
|
+
* because an angle read across two coincident points is not a placement that got
|
|
228
|
+
* worse, it is not a placement.
|
|
229
|
+
*/
|
|
230
|
+
export const DEFAULT_MIN_LEVER_PX = 8;
|
|
231
|
+
|
|
232
|
+
/** Anchored descendants an inward determination needs. Four unknowns, two each. */
|
|
233
|
+
export const INWARD_MIN_DETERMINANTS = 2;
|
|
234
|
+
|
|
235
|
+
/** Sample budget per part per evaluation. The reported numbers use every pixel regardless. */
|
|
236
|
+
const SEARCH_SAMPLES = 512;
|
|
237
|
+
|
|
238
|
+
/** Basins each stretch rung sends to the polish, and how many survive to it in all. */
|
|
239
|
+
const MINIMA_PER_RUNG = 6;
|
|
240
|
+
const POLISH_CANDIDATES = 12;
|
|
241
|
+
|
|
242
|
+
/** Alternates beyond this many are not printed; the count is still stated. */
|
|
243
|
+
const MAX_ALTERNATES = 3;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* How far the visible share may move between the seed and the fit before the
|
|
247
|
+
* report says the measurement and the answer are not in the same place.
|
|
248
|
+
*/
|
|
249
|
+
const VISIBILITY_DRIFT_TOLERANCE = 0.15;
|
|
250
|
+
|
|
251
|
+
const DEG = Math.PI / 180;
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// the report
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/** One placement of one part, in frame pixels, y down, origin top-left. */
|
|
258
|
+
export interface ChainFitPlacement {
|
|
259
|
+
/** Where the part image's own centre — `(width/2, height/2)` — lands. */
|
|
260
|
+
x: number;
|
|
261
|
+
y: number;
|
|
262
|
+
/** Screen degrees: positive turns clockwise on screen. `screenToSpineDegrees` converts. */
|
|
263
|
+
rotationDeg: number;
|
|
264
|
+
/** Uniform, as frame pixels per part pixel. */
|
|
265
|
+
scale: number;
|
|
266
|
+
/**
|
|
267
|
+
* The searched degree of freedom, in **Spine** degrees relative to the bone's
|
|
268
|
+
* setup rotation — the delta a `rotate` key would carry.
|
|
269
|
+
*
|
|
270
|
+
* `null` where the quantity does not exist: an anchored bone whose own parent is
|
|
271
|
+
* unplaced has a placement read straight off the picture and no link above it to
|
|
272
|
+
* measure a local rotation against.
|
|
273
|
+
*/
|
|
274
|
+
hingeDeg: number | null;
|
|
275
|
+
/** The bone's local rotation this placement implies, Spine degrees. `null` with `hingeDeg`. */
|
|
276
|
+
localRotationDeg: number | null;
|
|
277
|
+
/** The stretch factor on the bone. `1` where the DOF was not free, `null` with `hingeDeg`. */
|
|
278
|
+
stretch: number | null;
|
|
279
|
+
/**
|
|
280
|
+
* Alpha-weighted mean absolute colour error over the part's **visible** pixels,
|
|
281
|
+
* 0..1 — the objective of `src/pose.ts`, with the covered pixels dropped from
|
|
282
|
+
* both sums rather than charged. Lower is better explained; that is all it means.
|
|
283
|
+
*/
|
|
284
|
+
residual: number;
|
|
285
|
+
/**
|
|
286
|
+
* The share of the part's own alpha weight the residual was computed on: what
|
|
287
|
+
* nothing drawn after it covered, at the placement the set was frozen at.
|
|
288
|
+
*
|
|
289
|
+
* ⚠️ Read every residual next to this. Both halves of a low residual on a 0.08
|
|
290
|
+
* visible share are true, and neither is worth much on its own.
|
|
291
|
+
*/
|
|
292
|
+
visibleShare: number;
|
|
293
|
+
/** Part pixels behind that share — the count the number actually rests on. */
|
|
294
|
+
scoredPixels: number;
|
|
295
|
+
/**
|
|
296
|
+
* The same share recomputed where the answer LANDED, rather than where the set
|
|
297
|
+
* was frozen. Far from `visibleShare` means the fit moved out of its own
|
|
298
|
+
* measurement; another pass is the repair, and `search.passes` says how many ran.
|
|
299
|
+
*/
|
|
300
|
+
visibleShareAtFit: number;
|
|
301
|
+
/** Share of the VISIBLE weight whose per-pixel error clears `UNEXPLAINED_TOLERANCE`. */
|
|
302
|
+
unexplained: number;
|
|
303
|
+
/** Share of the part's WHOLE alpha weight that lands outside the frame canvas. */
|
|
304
|
+
offCanvas: number;
|
|
305
|
+
/** Frame pixels of material this placement accounts for, over its visible set. */
|
|
306
|
+
footprint: number;
|
|
307
|
+
/** Axis-aligned box the placed part occupies, frame pixels. */
|
|
308
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export type ChainFitRefusalReason =
|
|
312
|
+
| 'no-anchor'
|
|
313
|
+
/**
|
|
314
|
+
* The bone has anchored descendants but they do not bracket it: one where two
|
|
315
|
+
* are needed, or two that sit on top of each other, or every path to them
|
|
316
|
+
* crossing a bone whose own hinge or scale is unknown.
|
|
317
|
+
*
|
|
318
|
+
* ⚠️ Distinct from `no-anchor` on purpose. `no-anchor` says *nothing on this
|
|
319
|
+
* limb or below it was trusted*, and the repair is upstream at the anchor pass.
|
|
320
|
+
* `no-bracket` says *something below it was trusted and it was not enough*, and
|
|
321
|
+
* the repair is the rig's own topology or one more anchor on a different
|
|
322
|
+
* sub-chain — a different sentence to a caller who has to act on it.
|
|
323
|
+
*/
|
|
324
|
+
| 'no-bracket'
|
|
325
|
+
| 'occluded'
|
|
326
|
+
| 'no-match'
|
|
327
|
+
| 'empty-part'
|
|
328
|
+
| 'no-part-image'
|
|
329
|
+
| 'unsupported-geometry';
|
|
330
|
+
|
|
331
|
+
export interface ChainFitRefusal {
|
|
332
|
+
reason: ChainFitRefusalReason;
|
|
333
|
+
detail: string;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** One anchored descendant an inward determination was read from. */
|
|
337
|
+
export interface ChainFitInwardDeterminant {
|
|
338
|
+
/** The anchored bone whose pivot supplied two of the four equations. */
|
|
339
|
+
bone: string;
|
|
340
|
+
/** The part its anchor was read from — how the anchor pass names it. */
|
|
341
|
+
part: string;
|
|
342
|
+
/** Frame pixels from the determined bone's own pivot to this one's. */
|
|
343
|
+
leverPx: number;
|
|
344
|
+
/**
|
|
345
|
+
* Frame pixels between where the adopted answer predicts this pivot and where
|
|
346
|
+
* this anchor actually put it.
|
|
347
|
+
*
|
|
348
|
+
* ⭐ Zero by construction at `redundancy` 0 — two determinants and four unknowns
|
|
349
|
+
* leave nothing to disagree — and a real measurement above it. Same philosophy
|
|
350
|
+
* as `pivotDisagreementPx`: the number is not an error to be minimised, it is
|
|
351
|
+
* the rig and the picture disagreeing by that much, in the one place the
|
|
352
|
+
* disagreement is visible.
|
|
353
|
+
*/
|
|
354
|
+
offsetPx: number;
|
|
355
|
+
/**
|
|
356
|
+
* Bones strictly between the determined bone and this one. They carry no art, so
|
|
357
|
+
* their hinge could not be fitted and their SETUP rotation was composed through —
|
|
358
|
+
* every number in this determination inherits that assumption.
|
|
359
|
+
*/
|
|
360
|
+
carried: string[];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** How an `inward` bone was determined, and what prices the determination. */
|
|
364
|
+
export interface ChainFitInwardView {
|
|
365
|
+
/**
|
|
366
|
+
* Only one form exists, and naming it is how a future one stays readable beside
|
|
367
|
+
* it: `descendants` = two or more anchored descendants fixed all four numbers.
|
|
368
|
+
*/
|
|
369
|
+
form: 'descendants';
|
|
370
|
+
determinants: ChainFitInwardDeterminant[];
|
|
371
|
+
/** Anchored descendants that could NOT be used, named with the reason rather than dropped. */
|
|
372
|
+
rejected: { bone: string; why: string }[];
|
|
373
|
+
/**
|
|
374
|
+
* Equations the solve had beyond the four a similarity needs — `2 × determinants − 4`.
|
|
375
|
+
*
|
|
376
|
+
* 🚨 Read `disagreementPx` next to this and never without it. At `redundancy` 0
|
|
377
|
+
* the disagreement is `null`, because a determination with nothing left over
|
|
378
|
+
* cannot be checked against itself: it fits its own two points exactly whether
|
|
379
|
+
* or not the rig is right. Redundancy is where the diagnostic value lives.
|
|
380
|
+
*/
|
|
381
|
+
redundancy: number;
|
|
382
|
+
/** The widest frame-pixel span between two determinant pivots — what the rotation was read across. */
|
|
383
|
+
leverPx: number;
|
|
384
|
+
/** The floor `leverPx` had to clear, so an answer at its edge is visible as one. */
|
|
385
|
+
minLeverPx: number;
|
|
386
|
+
/**
|
|
387
|
+
* The worst `offsetPx` over the determinants: the over-determination residual,
|
|
388
|
+
* in frame pixels. `null` at `redundancy` 0.
|
|
389
|
+
*
|
|
390
|
+
* 🚨 **It says the determination disagrees with itself. It does NOT say which
|
|
391
|
+
* determinant is wrong.** The solve is least squares, so a displacement on one
|
|
392
|
+
* anchor is spread across every determinant near it — measured on the chain-fit
|
|
393
|
+
* fixture, a deliberate 4 px error on `buried` came back as 1.95 px on `arm`
|
|
394
|
+
* and 1.74 px on `buried`, because those two sit 5 bone units apart while the
|
|
395
|
+
* third is 24 away and nothing in the arithmetic can tell a tight pair apart.
|
|
396
|
+
* Read the per-determinant `offsetPx` list as a pattern, and attribute with a
|
|
397
|
+
* second frame or with `pivotDisagreementPx` on the anchors themselves.
|
|
398
|
+
*/
|
|
399
|
+
disagreementPx: number | null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** What the rig says about the bone this part hangs off, and what was searched on it. */
|
|
403
|
+
export interface ChainFitBoneView {
|
|
404
|
+
name: string;
|
|
405
|
+
parent: string | null;
|
|
406
|
+
/** The bone's own setup rotation, Spine degrees — what `hingeDeg` is measured from. */
|
|
407
|
+
setupRotationDeg: number;
|
|
408
|
+
/**
|
|
409
|
+
* Chain links from the trunk. `0` means this part's own bone IS the trunk — it
|
|
410
|
+
* was anchored, or it was determined inward — and `-1` means unplaced.
|
|
411
|
+
*/
|
|
412
|
+
depth: number;
|
|
413
|
+
/** The bone this part's placement is ultimately hung from, or `null`. */
|
|
414
|
+
anchoredTo: string | null;
|
|
415
|
+
/**
|
|
416
|
+
* What that trunk bone is: an `anchor` read off the picture, or an `inward`
|
|
417
|
+
* determination read off two anchors below it.
|
|
418
|
+
*
|
|
419
|
+
* ⚠️ The field to check before quoting anything hung off it. An `inward` trunk
|
|
420
|
+
* was never seen by the anchor pass, so every placement below it inherits the
|
|
421
|
+
* determination's own uncertainty — `bone.inward.disagreementPx` on that trunk
|
|
422
|
+
* bone is where that uncertainty is priced.
|
|
423
|
+
*/
|
|
424
|
+
anchoredToRole: 'anchor' | 'inward' | null;
|
|
425
|
+
/**
|
|
426
|
+
* Non-`null` only on a bone this run determined INWARD, and then it is the whole
|
|
427
|
+
* account of that determination. See `ChainFitInwardView`.
|
|
428
|
+
*/
|
|
429
|
+
inward: ChainFitInwardView | null;
|
|
430
|
+
dof: {
|
|
431
|
+
/**
|
|
432
|
+
* Always searched on a chain bone: the hinge is the premise of the instrument.
|
|
433
|
+
* `false` on an anchor and on an inward bone — neither had anything searched.
|
|
434
|
+
*/
|
|
435
|
+
rotation: boolean;
|
|
436
|
+
/** Searched only where the candidate leaves scale free — see `DEFAULT_STRETCH_RATIO`. */
|
|
437
|
+
stretch: boolean;
|
|
438
|
+
/**
|
|
439
|
+
* The candidate keys a `translate` timeline on this bone, so the pivot the
|
|
440
|
+
* hinge turned about is itself something the rig moves. The placement is still
|
|
441
|
+
* read off pixels; it is `localRotationDeg` that stops being keyable alone.
|
|
442
|
+
*/
|
|
443
|
+
pivotFree: boolean;
|
|
444
|
+
};
|
|
445
|
+
/** The window taken, so an answer at its edge is visible as one. */
|
|
446
|
+
window: { hingeMinDeg: number; hingeMaxDeg: number; hingeStepDeg: number; stretchMin: number; stretchMax: number };
|
|
447
|
+
/** Other parts scored together with this one, because they hang off the same bone. */
|
|
448
|
+
sharedWith: string[];
|
|
449
|
+
/**
|
|
450
|
+
* Bones between the anchor and this one that carry no art. Nothing could fit
|
|
451
|
+
* their hinge, so their setup rotation was carried through and every placement
|
|
452
|
+
* below them inherits that assumption.
|
|
453
|
+
*/
|
|
454
|
+
carriedBones: string[];
|
|
455
|
+
/**
|
|
456
|
+
* Anchored bones only: how far the chain's own prediction of this bone's pivot
|
|
457
|
+
* is from where the anchor put it, in frame pixels. It is a measure of the RIG
|
|
458
|
+
* against the picture — a large value says the joint offset the candidate
|
|
459
|
+
* declares is not the joint the frame shows. `null` when the chain had no
|
|
460
|
+
* prediction, which is every anchor whose parent is unplaced.
|
|
461
|
+
*/
|
|
462
|
+
pivotDisagreementPx: number | null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** What the anchor pass said about this part, whether or not it became an anchor. */
|
|
466
|
+
export interface ChainFitAnchorVerdict {
|
|
467
|
+
residual: number;
|
|
468
|
+
unexplained: number;
|
|
469
|
+
ambiguous: boolean;
|
|
470
|
+
/** Did it clear `ANCHOR_MAX_RESIDUAL` / `ANCHOR_MAX_UNEXPLAINED` and come back unique? */
|
|
471
|
+
eligible: boolean;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export interface ChainFitPart {
|
|
475
|
+
/** The part PNG's file name — how the report names the part everywhere. */
|
|
476
|
+
part: string;
|
|
477
|
+
path: string;
|
|
478
|
+
slot: string;
|
|
479
|
+
attachment: string;
|
|
480
|
+
width: number;
|
|
481
|
+
height: number;
|
|
482
|
+
/**
|
|
483
|
+
* `anchor` = taken from the anchor pass; `chain` = fitted through the rig;
|
|
484
|
+
* `inward` = DETERMINED from two or more anchored descendants, with nothing
|
|
485
|
+
* searched; `unplaced` = none of the three.
|
|
486
|
+
*/
|
|
487
|
+
role: 'anchor' | 'chain' | 'inward' | 'unplaced';
|
|
488
|
+
bone: ChainFitBoneView;
|
|
489
|
+
/**
|
|
490
|
+
* Why this answer should not be taken at face value, or `null`.
|
|
491
|
+
*
|
|
492
|
+
* ⚠️ `placement` is still filled in under `occluded` and `no-match`, on purpose:
|
|
493
|
+
* a refusal names why not to trust a number, it does not hide it. The reasons
|
|
494
|
+
* that leave it `null` — `no-anchor`, `no-bracket`, `empty-part`,
|
|
495
|
+
* `no-part-image`, `unsupported-geometry` — are the ones where nothing was
|
|
496
|
+
* placed at all.
|
|
497
|
+
*/
|
|
498
|
+
refusal: ChainFitRefusal | null;
|
|
499
|
+
placement: ChainFitPlacement | null;
|
|
500
|
+
/** Other hinge answers inside the ambiguity margin, best first. */
|
|
501
|
+
alternates: ChainFitPlacement[];
|
|
502
|
+
ambiguous: boolean;
|
|
503
|
+
/**
|
|
504
|
+
* What the anchor pass made of this same part on this same frame, or `null` when
|
|
505
|
+
* it had nothing for it.
|
|
506
|
+
*
|
|
507
|
+
* ⭐ The field that makes the two instruments readable together: `eligible` false
|
|
508
|
+
* with a `chain` placement beside it is a part the chain bought.
|
|
509
|
+
*/
|
|
510
|
+
anchorVerdict: ChainFitAnchorVerdict | null;
|
|
511
|
+
/** Plain-language versions of everything above, in the order they were found. */
|
|
512
|
+
notes: string[];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export interface ChainFitReport {
|
|
516
|
+
spec: string;
|
|
517
|
+
/** The coordinate contract, spelled out in the file rather than assumed. */
|
|
518
|
+
space: string;
|
|
519
|
+
candidate: {
|
|
520
|
+
skeleton: string;
|
|
521
|
+
/** The skins searched for each slot's setup attachment, in the order tried. */
|
|
522
|
+
skins: string[];
|
|
523
|
+
bones: number;
|
|
524
|
+
slots: number;
|
|
525
|
+
/** Slots with a setup attachment this run could resolve to a region. */
|
|
526
|
+
drawn: number;
|
|
527
|
+
/** Setup draw order, back to front — the order the masks were built in. */
|
|
528
|
+
drawOrder: string[];
|
|
529
|
+
};
|
|
530
|
+
images: string;
|
|
531
|
+
frame: { path: string; width: number; height: number; background: PoseBackground };
|
|
532
|
+
anchor: {
|
|
533
|
+
source: 'pose' | 'file';
|
|
534
|
+
path: string | null;
|
|
535
|
+
criterion: { maxResidual: number; maxUnexplained: number; requireUnambiguous: boolean };
|
|
536
|
+
/** The parts whose answer was trusted, and whose bones the walk started from. */
|
|
537
|
+
anchored: string[];
|
|
538
|
+
};
|
|
539
|
+
/**
|
|
540
|
+
* The inward step's own account, beside `anchor` because it is the other way a
|
|
541
|
+
* trunk gets into this report.
|
|
542
|
+
*/
|
|
543
|
+
inward: {
|
|
544
|
+
/** Bones this run determined from two or more anchored descendants, in bone order. */
|
|
545
|
+
determined: string[];
|
|
546
|
+
criterion: { minDeterminants: number; minLeverPx: number; determinantsMustBeAnchored: boolean };
|
|
547
|
+
};
|
|
548
|
+
search: {
|
|
549
|
+
hinge: { minDeg: number; maxDeg: number; stepDeg: number; steps: number };
|
|
550
|
+
stretch: { ratio: number; steps: number; freeFrom: string };
|
|
551
|
+
minVisible: number;
|
|
552
|
+
maxResidual: number;
|
|
553
|
+
ambiguity: { absolute: number; relative: number; hingeDeg: number };
|
|
554
|
+
passes: number;
|
|
555
|
+
occluderAlpha: number;
|
|
556
|
+
};
|
|
557
|
+
/** What the numbers above cannot see. Read before consuming them. */
|
|
558
|
+
caveats: string[];
|
|
559
|
+
parts: ChainFitPart[];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export interface ChainFitOptions {
|
|
563
|
+
/** A compiled candidate: a directory holding `skeleton.json`, or the path to one. */
|
|
564
|
+
candidatePath: string;
|
|
565
|
+
/** Where the candidate's attachment image names resolve to loose PNGs. */
|
|
566
|
+
imagesDir: string;
|
|
567
|
+
/** One pose frame. */
|
|
568
|
+
framePath: string;
|
|
569
|
+
/** A `rigc pose` report for this frame. Without it, one is computed internally. */
|
|
570
|
+
anchorPath?: string;
|
|
571
|
+
hinge?: { minDeg: number; maxDeg: number };
|
|
572
|
+
/** Ratio window for the stretch DOF, and an explicit request to search it everywhere. */
|
|
573
|
+
stretch?: number;
|
|
574
|
+
minVisible?: number;
|
|
575
|
+
maxResidual?: number;
|
|
576
|
+
passes?: number;
|
|
577
|
+
/** The inward step's lever floor, in frame pixels — see `DEFAULT_MIN_LEVER_PX`. */
|
|
578
|
+
minLeverPx?: number;
|
|
579
|
+
/** Sizes the internal anchor pass only; refused together with `anchorPath`. */
|
|
580
|
+
scale?: { min: number; max: number };
|
|
581
|
+
rotation?: { minDeg: number; maxDeg: number };
|
|
582
|
+
anchorMaxResidual?: number;
|
|
583
|
+
anchorMaxUnexplained?: number;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
// the candidate, as much of it as a chain fit needs
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
/** A region attachment's own geometry, defaulted, in the bone's local space. */
|
|
591
|
+
interface AttachmentGeometry {
|
|
592
|
+
x: number;
|
|
593
|
+
y: number;
|
|
594
|
+
/** Spine degrees, CCW — the attachment's own rotation inside the bone. */
|
|
595
|
+
rotation: number;
|
|
596
|
+
scaleX: number;
|
|
597
|
+
scaleY: number;
|
|
598
|
+
width: number;
|
|
599
|
+
height: number;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** One drawable this run will place. */
|
|
603
|
+
interface DrawnSlot {
|
|
604
|
+
slot: string;
|
|
605
|
+
bone: string;
|
|
606
|
+
attachment: string;
|
|
607
|
+
/** The image name the attachment resolves against `--images`. */
|
|
608
|
+
image: string;
|
|
609
|
+
geometry: AttachmentGeometry;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** A bone's placement in the frame's own pixels. */
|
|
613
|
+
interface BonePlace {
|
|
614
|
+
x: number;
|
|
615
|
+
y: number;
|
|
616
|
+
/** Screen degrees, positive clockwise — the bone's world rotation, y-flipped. */
|
|
617
|
+
rotDeg: number;
|
|
618
|
+
/** Frame pixels per bone unit. */
|
|
619
|
+
unit: number;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** A part's placement in the frame's own pixels, before it is measured. */
|
|
623
|
+
interface PartPlace {
|
|
624
|
+
cx: number;
|
|
625
|
+
cy: number;
|
|
626
|
+
rotDeg: number;
|
|
627
|
+
/** Frame pixels per part pixel. */
|
|
628
|
+
scale: number;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* A bone-local point (Spine's y-up local space) into frame pixels.
|
|
633
|
+
*
|
|
634
|
+
* ⭐ The whole y flip, in one place. Spine composes in a y-up world with CCW
|
|
635
|
+
* rotations; a frame is y-down with clockwise ones. Negating the local `y` and the
|
|
636
|
+
* rotation together turns the composition into a plain screen-space similarity,
|
|
637
|
+
* which is what lets every step below be one multiply — and it is verified rather
|
|
638
|
+
* than argued: this reproduces `src/render.ts`'s own posed quads to 1e-5 px across
|
|
639
|
+
* a 17-bone candidate, which is the check the selftest keeps.
|
|
640
|
+
*/
|
|
641
|
+
function applyBoneLocal(p: BonePlace, u: number, v: number): [number, number] {
|
|
642
|
+
const cos = Math.cos(p.rotDeg * DEG);
|
|
643
|
+
const sin = Math.sin(p.rotDeg * DEG);
|
|
644
|
+
const sv = -v;
|
|
645
|
+
return [p.x + p.unit * (cos * u - sin * sv), p.y + p.unit * (sin * u + cos * sv)];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** The child's placement, given the parent's and the two residual degrees of freedom. */
|
|
649
|
+
function childPlace(parent: BonePlace, bone: SpineBone, hingeDeg: number, stretch: number): BonePlace {
|
|
650
|
+
const [x, y] = applyBoneLocal(parent, bone.x ?? 0, bone.y ?? 0);
|
|
651
|
+
return {
|
|
652
|
+
x,
|
|
653
|
+
y,
|
|
654
|
+
rotDeg: parent.rotDeg - ((bone.rotation ?? 0) + hingeDeg),
|
|
655
|
+
unit: parent.unit * (bone.scaleX ?? 1) * stretch,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Where the part image lands, given its bone's placement. */
|
|
660
|
+
function partPlaceOf(bp: BonePlace, geometry: AttachmentGeometry, pngWidth: number): PartPlace {
|
|
661
|
+
const [cx, cy] = applyBoneLocal(bp, geometry.x, geometry.y);
|
|
662
|
+
return {
|
|
663
|
+
cx,
|
|
664
|
+
cy,
|
|
665
|
+
rotDeg: bp.rotDeg - geometry.rotation,
|
|
666
|
+
scale: (bp.unit * geometry.scaleX * geometry.width) / pngWidth,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** The inverse: one known part placement fixes its bone's, all four numbers of it. */
|
|
671
|
+
function bonePlaceFromPart(pl: PartPlace, geometry: AttachmentGeometry, pngWidth: number): BonePlace {
|
|
672
|
+
const unit = (pl.scale * pngWidth) / (geometry.width * geometry.scaleX);
|
|
673
|
+
const rotDeg = pl.rotDeg + geometry.rotation;
|
|
674
|
+
const cos = Math.cos(rotDeg * DEG);
|
|
675
|
+
const sin = Math.sin(rotDeg * DEG);
|
|
676
|
+
const sv = -geometry.y;
|
|
677
|
+
return {
|
|
678
|
+
x: pl.cx - unit * (cos * geometry.x - sin * sv),
|
|
679
|
+
y: pl.cy - unit * (sin * geometry.x + cos * sv),
|
|
680
|
+
rotDeg,
|
|
681
|
+
unit,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The link an already-placed pair of bones implies — the inverse of `childPlace`. */
|
|
686
|
+
function linkOf(parent: BonePlace, child: BonePlace, bone: SpineBone): { hingeDeg: number; stretch: number } {
|
|
687
|
+
return {
|
|
688
|
+
hingeDeg: normaliseDegrees(parent.rotDeg - child.rotDeg - (bone.rotation ?? 0)),
|
|
689
|
+
stretch: child.unit / Math.max(1e-9, parent.unit * (bone.scaleX ?? 1)),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* A point in a bone's own local space, y already negated — the `(a, b)` of the
|
|
695
|
+
* inward solve, and the coordinates `applyBoneLocal(place, a, -b)` maps to frame
|
|
696
|
+
* pixels.
|
|
697
|
+
*
|
|
698
|
+
* ⭐ Why the negation is carried in the type rather than done at each call. In
|
|
699
|
+
* these coordinates a bone's placement acts LINEARLY:
|
|
700
|
+
*
|
|
701
|
+
* X = x + p·a − q·b p = unit · cos(rotDeg)
|
|
702
|
+
* Y = y + q·a + p·b q = unit · sin(rotDeg)
|
|
703
|
+
*
|
|
704
|
+
* Four unknowns — `x`, `y`, `p`, `q` — appearing linearly, which is the whole
|
|
705
|
+
* reason the inward step has a closed form instead of a search. `unit` and
|
|
706
|
+
* `rotDeg` come back out as `hypot(p, q)` and `atan2(q, p)`.
|
|
707
|
+
*/
|
|
708
|
+
interface BoneLocal {
|
|
709
|
+
a: number;
|
|
710
|
+
b: number;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* A descendant's pivot expressed in an ancestor's own local space, or the reason
|
|
715
|
+
* that cannot be done.
|
|
716
|
+
*
|
|
717
|
+
* 🚨 The refusal is the load-bearing half. A descendant's pivot is a function of
|
|
718
|
+
* the ancestor's placement and of the SETUP offsets in between — and of the
|
|
719
|
+
* in-between bones' own hinges and scales, which is where it can stop being
|
|
720
|
+
* known. So a path is walked and every bone strictly between the two is
|
|
721
|
+
* interrogated: art on it means its hinge is a searched unknown, a free scale
|
|
722
|
+
* means the DISTANCE across it is unknown, and unsupported geometry means the
|
|
723
|
+
* composition is not a similarity at all. Any of the three and the descendant is
|
|
724
|
+
* not usable — named, not silently dropped, because "I had an anchor down there
|
|
725
|
+
* and could not use it" is the sentence a caller needs.
|
|
726
|
+
*/
|
|
727
|
+
type RigidPath = { local: BoneLocal; carried: string[] } | { blocked: string };
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Compose from `from`'s local space down to `to`'s pivot, refusing by name.
|
|
731
|
+
*
|
|
732
|
+
* `hasArt` and `stretchFree` are passed in rather than recomputed so this stays a
|
|
733
|
+
* pure function of the rig plus two predicates the caller already owns.
|
|
734
|
+
*/
|
|
735
|
+
function rigidPathTo(
|
|
736
|
+
bones: Map<string, SpineBone>,
|
|
737
|
+
unsupported: Map<string, string>,
|
|
738
|
+
hasArt: (bone: string) => boolean,
|
|
739
|
+
stretchFree: (bone: string) => boolean,
|
|
740
|
+
from: string,
|
|
741
|
+
to: string,
|
|
742
|
+
): RigidPath {
|
|
743
|
+
// Up from `to` to `from`, so the path comes out parent-first when reversed. A
|
|
744
|
+
// guard on the walk length rather than a visited set: a cycle in a skeleton's
|
|
745
|
+
// parent links would otherwise spin here, and Spine data can be forged.
|
|
746
|
+
const up: string[] = [];
|
|
747
|
+
let cursor: string | undefined = to;
|
|
748
|
+
for (let guard = 0; guard <= bones.size; guard++) {
|
|
749
|
+
if (cursor === undefined) return { blocked: `"${to}" is not a descendant of "${from}"` };
|
|
750
|
+
if (cursor === from) break;
|
|
751
|
+
up.push(cursor);
|
|
752
|
+
cursor = bones.get(cursor)?.parent;
|
|
753
|
+
}
|
|
754
|
+
if (cursor !== from) return { blocked: `"${to}" is not a descendant of "${from}"` };
|
|
755
|
+
const path = up.reverse();
|
|
756
|
+
if (path.length === 0) return { blocked: `"${to}" is "${from}" itself` };
|
|
757
|
+
|
|
758
|
+
const carried: string[] = [];
|
|
759
|
+
// Everything strictly between the two — `path` without its last entry, which is
|
|
760
|
+
// `to` and whose OWN hinge and scale are irrelevant: a bone's pivot sits above
|
|
761
|
+
// its own local transform.
|
|
762
|
+
for (const mid of path.slice(0, -1)) {
|
|
763
|
+
const bad = unsupported.get(mid);
|
|
764
|
+
if (bad !== undefined) return { blocked: `bone "${mid}" lies between them and ${bad}` };
|
|
765
|
+
if (hasArt(mid)) {
|
|
766
|
+
return {
|
|
767
|
+
blocked:
|
|
768
|
+
`bone "${mid}" lies between them and carries art, so its hinge is one of the quantities this ` +
|
|
769
|
+
'instrument searches rather than one it knows — the distance and direction across it are not fixed',
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
if (stretchFree(mid)) {
|
|
773
|
+
return {
|
|
774
|
+
blocked:
|
|
775
|
+
`bone "${mid}" lies between them and the candidate leaves its scale free, so the DISTANCE across it ` +
|
|
776
|
+
'is unknown and the bone above it stays underdetermined',
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
carried.push(mid);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Identity in `from`'s local space, then one `childPlace` per link with the
|
|
783
|
+
// hinge and the stretch at their setup values — which is exactly what `carried`
|
|
784
|
+
// above has just certified is the only thing they can be.
|
|
785
|
+
let acc: BonePlace = { x: 0, y: 0, rotDeg: 0, unit: 1 };
|
|
786
|
+
for (const name of path) {
|
|
787
|
+
const bone = bones.get(name);
|
|
788
|
+
if (bone === undefined) return { blocked: `the skeleton does not declare "${name}"` };
|
|
789
|
+
acc = childPlace(acc, bone, 0, 1);
|
|
790
|
+
}
|
|
791
|
+
// `applyBoneLocal` negated each local y on the way through, so `acc` is already
|
|
792
|
+
// in (a, b) and no second negation belongs here.
|
|
793
|
+
return { local: { a: acc.x, b: acc.y }, carried };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** One equation pair for the inward solve: a local point and where it landed. */
|
|
797
|
+
interface InwardPoint {
|
|
798
|
+
local: BoneLocal;
|
|
799
|
+
X: number;
|
|
800
|
+
Y: number;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* The four numbers a bone's placement is, from two or more anchored descendants.
|
|
805
|
+
*
|
|
806
|
+
* ⭐ Least squares in closed form, because the model is linear in the unknowns
|
|
807
|
+
* (see `BoneLocal`) — so there is no window to search, no basin to miss and no
|
|
808
|
+
* tolerance to state. With exactly two points it fits them EXACTLY; with more it
|
|
809
|
+
* is the similarity that minimises the summed squared pivot error, and what it
|
|
810
|
+
* leaves over is `disagreementPx`.
|
|
811
|
+
*
|
|
812
|
+
* `null` where the points cannot fix a similarity at all: every local point at the
|
|
813
|
+
* same place, so no direction exists to read a rotation from.
|
|
814
|
+
*/
|
|
815
|
+
function solveFromDescendants(points: InwardPoint[]): BonePlace | null {
|
|
816
|
+
const n = points.length;
|
|
817
|
+
if (n < INWARD_MIN_DETERMINANTS) return null;
|
|
818
|
+
let ma = 0;
|
|
819
|
+
let mb = 0;
|
|
820
|
+
let mX = 0;
|
|
821
|
+
let mY = 0;
|
|
822
|
+
for (const pt of points) {
|
|
823
|
+
ma += pt.local.a;
|
|
824
|
+
mb += pt.local.b;
|
|
825
|
+
mX += pt.X;
|
|
826
|
+
mY += pt.Y;
|
|
827
|
+
}
|
|
828
|
+
ma /= n;
|
|
829
|
+
mb /= n;
|
|
830
|
+
mX /= n;
|
|
831
|
+
mY /= n;
|
|
832
|
+
let spread = 0;
|
|
833
|
+
let dotp = 0;
|
|
834
|
+
let dotq = 0;
|
|
835
|
+
for (const pt of points) {
|
|
836
|
+
const a = pt.local.a - ma;
|
|
837
|
+
const b = pt.local.b - mb;
|
|
838
|
+
const X = pt.X - mX;
|
|
839
|
+
const Y = pt.Y - mY;
|
|
840
|
+
spread += a * a + b * b;
|
|
841
|
+
dotp += a * X + b * Y;
|
|
842
|
+
dotq += a * Y - b * X;
|
|
843
|
+
}
|
|
844
|
+
if (!(spread > 1e-12)) return null;
|
|
845
|
+
const p = dotp / spread;
|
|
846
|
+
const q = dotq / spread;
|
|
847
|
+
const unit = Math.hypot(p, q);
|
|
848
|
+
if (!(unit > 1e-9)) return null;
|
|
849
|
+
return {
|
|
850
|
+
x: mX - p * ma + q * mb,
|
|
851
|
+
y: mY - q * ma - p * mb,
|
|
852
|
+
rotDeg: Math.atan2(q, p) / DEG,
|
|
853
|
+
unit,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function num(value: unknown, fallback: number): number {
|
|
858
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Read the skeleton file, or refuse by name. */
|
|
862
|
+
function readSkeleton(path: string): SpineSkeletonJson {
|
|
863
|
+
if (!existsSync(path)) throw new ChainFitError(`no skeleton at ${path}`);
|
|
864
|
+
let parsed: unknown;
|
|
865
|
+
try {
|
|
866
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
867
|
+
} catch (err) {
|
|
868
|
+
throw new ChainFitError(`cannot parse ${path} as JSON: ${(err as Error).message}`);
|
|
869
|
+
}
|
|
870
|
+
const skel = parsed as SpineSkeletonJson;
|
|
871
|
+
if (!Array.isArray(skel?.bones) || !Array.isArray(skel?.slots) || !Array.isArray(skel?.skins)) {
|
|
872
|
+
throw new ChainFitError(
|
|
873
|
+
`${path} is not Spine skeleton data — a chain fit needs its bones, its slots in draw order and its skins`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
return skel;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The bones a chain fit cannot compose through, by name and reason.
|
|
881
|
+
*
|
|
882
|
+
* ⚠️ Refused rather than approximated, and propagated down the tree: shear, a
|
|
883
|
+
* non-uniform scale and every `inherit` mode but `normal` all make the world
|
|
884
|
+
* transform something other than the similarity every placement in this file is.
|
|
885
|
+
* Approximating one would put a plausible number on a part whose geometry this
|
|
886
|
+
* instrument does not model — the same failure as searching a window that does not
|
|
887
|
+
* contain the truth, which does not refuse either, it just answers wrongly.
|
|
888
|
+
*/
|
|
889
|
+
function unsupportedBones(bones: SpineBone[]): Map<string, string> {
|
|
890
|
+
const out = new Map<string, string>();
|
|
891
|
+
for (const bone of bones) {
|
|
892
|
+
const inherited = bone.parent === undefined ? undefined : out.get(bone.parent);
|
|
893
|
+
if (inherited !== undefined) {
|
|
894
|
+
out.set(bone.name, `its parent "${bone.parent}" is unsupported (${inherited})`);
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
const sx = num(bone.scaleX, 1);
|
|
898
|
+
const sy = num(bone.scaleY, 1);
|
|
899
|
+
const shearX = num(bone.shearX, 0);
|
|
900
|
+
const shearY = num(bone.shearY, 0);
|
|
901
|
+
if (shearX !== 0 || shearY !== 0) {
|
|
902
|
+
out.set(bone.name, `it shears (shearX ${shearX}, shearY ${shearY}), which is not a similarity`);
|
|
903
|
+
} else if (sx !== sy) {
|
|
904
|
+
out.set(bone.name, `its setup scale is non-uniform (scaleX ${sx}, scaleY ${sy}), which is not a similarity`);
|
|
905
|
+
} else if (sx <= 0) {
|
|
906
|
+
out.set(bone.name, `its setup scale is ${sx}; a mirrored or zero-scale bone has no rigid placement`);
|
|
907
|
+
} else if (bone.inherit !== undefined && bone.inherit !== 'normal') {
|
|
908
|
+
out.set(bone.name, `it inherits "${bone.inherit}" rather than "normal", so its world transform is not the chain's`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return out;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** The region attachment for one slot's setup attachment, and the image it names. */
|
|
915
|
+
function resolveAttachment(
|
|
916
|
+
skel: SpineSkeletonJson,
|
|
917
|
+
slot: string,
|
|
918
|
+
attachment: string,
|
|
919
|
+
): { geometry: AttachmentGeometry; image: string } | { unsupported: string } | null {
|
|
920
|
+
for (const skin of skel.skins) {
|
|
921
|
+
const raw = skin.attachments?.[slot]?.[attachment];
|
|
922
|
+
if (raw === undefined) continue;
|
|
923
|
+
// A region attachment is the one member of the union with no `type`, which is
|
|
924
|
+
// also how the parser reads it, so this is the format's own discriminator
|
|
925
|
+
// rather than a convention chosen here.
|
|
926
|
+
const kind: string = 'type' in raw ? raw.type : 'region';
|
|
927
|
+
if (kind !== 'region') {
|
|
928
|
+
return {
|
|
929
|
+
unsupported: `attachment "${attachment}" is a ${kind}, and only a region attachment has a rigid placement`,
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
const region = raw as SpineRegionAttachment;
|
|
933
|
+
const scaleX = num(region.scaleX, 1);
|
|
934
|
+
const scaleY = num(region.scaleY, 1);
|
|
935
|
+
if (scaleX !== scaleY) {
|
|
936
|
+
return { unsupported: `attachment "${attachment}" scales non-uniformly (scaleX ${scaleX}, scaleY ${scaleY})` };
|
|
937
|
+
}
|
|
938
|
+
if (scaleX <= 0) {
|
|
939
|
+
return { unsupported: `attachment "${attachment}" has scaleX ${scaleX}; a mirrored region has no rigid placement` };
|
|
940
|
+
}
|
|
941
|
+
const width = num(region.width, 0);
|
|
942
|
+
const height = num(region.height, 0);
|
|
943
|
+
if (width <= 0 || height <= 0) {
|
|
944
|
+
return { unsupported: `attachment "${attachment}" declares width ${width} and height ${height}` };
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
geometry: {
|
|
948
|
+
x: num(region.x, 0),
|
|
949
|
+
y: num(region.y, 0),
|
|
950
|
+
rotation: num(region.rotation, 0),
|
|
951
|
+
scaleX,
|
|
952
|
+
scaleY,
|
|
953
|
+
width,
|
|
954
|
+
height,
|
|
955
|
+
},
|
|
956
|
+
image: typeof region.path === 'string' && region.path.length > 0 ? region.path : attachment,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Which local properties the candidate's own animations key, per bone. */
|
|
963
|
+
function keyedProperties(skel: SpineSkeletonJson): Map<string, Set<string>> {
|
|
964
|
+
const out = new Map<string, Set<string>>();
|
|
965
|
+
for (const animation of Object.values(skel.animations ?? {})) {
|
|
966
|
+
for (const [bone, timelines] of Object.entries(animation.bones ?? {})) {
|
|
967
|
+
const set = out.get(bone) ?? new Set<string>();
|
|
968
|
+
for (const name of Object.keys(timelines)) set.add(name);
|
|
969
|
+
out.set(bone, set);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return out;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ---------------------------------------------------------------------------
|
|
976
|
+
// the mask
|
|
977
|
+
// ---------------------------------------------------------------------------
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Stamp one placed part's own coverage into a frame-sized mask.
|
|
981
|
+
*
|
|
982
|
+
* ⚠️ Walked over the DESTINATION pixels and inverse-mapped, not forward from the
|
|
983
|
+
* part's pixels. Forward stamping leaves holes wherever the placement magnifies —
|
|
984
|
+
* one part pixel then covers several frame pixels and only one of them is written
|
|
985
|
+
* — and a mask with holes lets an occluder's pixels back into a child's objective
|
|
986
|
+
* in a lattice, which is worse than not masking at all because it looks masked.
|
|
987
|
+
*/
|
|
988
|
+
function stampCover(into: Uint8Array, width: number, height: number, part: Plate, pl: PartPlace): void {
|
|
989
|
+
if (!(pl.scale > 0)) return;
|
|
990
|
+
const cos = Math.cos(pl.rotDeg * DEG);
|
|
991
|
+
const sin = Math.sin(pl.rotDeg * DEG);
|
|
992
|
+
const halfW = part.width / 2;
|
|
993
|
+
const halfH = part.height / 2;
|
|
994
|
+
const reach = Math.hypot(halfW, halfH) * pl.scale + 1;
|
|
995
|
+
const x0 = Math.max(0, Math.floor(pl.cx - reach));
|
|
996
|
+
const x1 = Math.min(width - 1, Math.ceil(pl.cx + reach));
|
|
997
|
+
const y0 = Math.max(0, Math.floor(pl.cy - reach));
|
|
998
|
+
const y1 = Math.min(height - 1, Math.ceil(pl.cy + reach));
|
|
999
|
+
for (let fy = y0; fy <= y1; fy++) {
|
|
1000
|
+
for (let fx = x0; fx <= x1; fx++) {
|
|
1001
|
+
const dx = fx + 0.5 - pl.cx;
|
|
1002
|
+
const dy = fy + 0.5 - pl.cy;
|
|
1003
|
+
const u = (dx * cos + dy * sin) / pl.scale + halfW;
|
|
1004
|
+
const v = (-dx * sin + dy * cos) / pl.scale + halfH;
|
|
1005
|
+
if (u < 0 || v < 0 || u >= part.width || v >= part.height) continue;
|
|
1006
|
+
if (part.data[(Math.floor(v) * part.width + Math.floor(u)) * 4 + 3] < OCCLUDER_ALPHA) continue;
|
|
1007
|
+
into[fy * width + fx] = 1;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** A part's own visible pixels under a cover mask, in the part's own space. */
|
|
1013
|
+
interface VisibleSet {
|
|
1014
|
+
keep: Uint8Array;
|
|
1015
|
+
/** Alpha weight the cover leaves, and the part's whole alpha weight. */
|
|
1016
|
+
weight: number;
|
|
1017
|
+
total: number;
|
|
1018
|
+
/** Part pixels the residual will rest on. */
|
|
1019
|
+
pixels: number;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function visibleMask(part: Plate, pl: PartPlace, cover: Uint8Array, width: number, height: number): VisibleSet {
|
|
1023
|
+
const keep = new Uint8Array(part.width * part.height);
|
|
1024
|
+
const cos = Math.cos(pl.rotDeg * DEG) * pl.scale;
|
|
1025
|
+
const sin = Math.sin(pl.rotDeg * DEG) * pl.scale;
|
|
1026
|
+
let weight = 0;
|
|
1027
|
+
let total = 0;
|
|
1028
|
+
let pixels = 0;
|
|
1029
|
+
for (let y = 0; y < part.height; y++) {
|
|
1030
|
+
for (let x = 0; x < part.width; x++) {
|
|
1031
|
+
const a = part.data[(y * part.width + x) * 4 + 3];
|
|
1032
|
+
if (a === 0) continue;
|
|
1033
|
+
const w = a / 255;
|
|
1034
|
+
total += w;
|
|
1035
|
+
const u = x + 0.5 - part.width / 2;
|
|
1036
|
+
const v = y + 0.5 - part.height / 2;
|
|
1037
|
+
const fx = pl.cx + u * cos - v * sin;
|
|
1038
|
+
const fy = pl.cy + u * sin + v * cos;
|
|
1039
|
+
const ix = Math.floor(fx);
|
|
1040
|
+
const iy = Math.floor(fy);
|
|
1041
|
+
// ⭐ Off the canvas is NOT covered: nothing is drawn over it, it simply is
|
|
1042
|
+
// not in the picture. It stays in the visible set and the objective charges
|
|
1043
|
+
// it the full 1, which is how a placement that hangs the part off the frame
|
|
1044
|
+
// pays for it instead of being excused by the mask.
|
|
1045
|
+
if (ix >= 0 && iy >= 0 && ix < width && iy < height && cover[iy * width + ix] === 1) continue;
|
|
1046
|
+
keep[y * part.width + x] = 1;
|
|
1047
|
+
weight += w;
|
|
1048
|
+
pixels++;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return { keep, weight, total, pixels };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** Which SAMPLES of a part survive its visible mask, and their weight. */
|
|
1055
|
+
function keptSamples(samples: Samples, keep: Uint8Array, part: Plate): { flags: Uint8Array; weight: number } {
|
|
1056
|
+
const flags = new Uint8Array(samples.count);
|
|
1057
|
+
let weight = 0;
|
|
1058
|
+
for (let i = 0; i < samples.count; i++) {
|
|
1059
|
+
// `u + width/2` is exactly the centre of the mip cell this sample averages,
|
|
1060
|
+
// in full-resolution part pixels, so this reads the mask where the sample is.
|
|
1061
|
+
const px = Math.floor(samples.u[i] + part.width / 2);
|
|
1062
|
+
const py = Math.floor(samples.v[i] + part.height / 2);
|
|
1063
|
+
if (px < 0 || py < 0 || px >= part.width || py >= part.height) continue;
|
|
1064
|
+
if (keep[py * part.width + px] === 0) continue;
|
|
1065
|
+
flags[i] = 1;
|
|
1066
|
+
weight += samples.w[i];
|
|
1067
|
+
}
|
|
1068
|
+
return { flags, weight };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// ---------------------------------------------------------------------------
|
|
1072
|
+
// the objective
|
|
1073
|
+
// ---------------------------------------------------------------------------
|
|
1074
|
+
|
|
1075
|
+
/** One part, ready to be scored: its art, its samples and the frozen visible set. */
|
|
1076
|
+
interface Target {
|
|
1077
|
+
geometry: AttachmentGeometry;
|
|
1078
|
+
plate: Plate;
|
|
1079
|
+
samples: Samples;
|
|
1080
|
+
flags: Uint8Array;
|
|
1081
|
+
/** Sampled visible weight — the denominator, and a constant by construction. */
|
|
1082
|
+
weight: number;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* The objective for one bone, over every part that hangs off it.
|
|
1087
|
+
*
|
|
1088
|
+
* ⭐ The degree of freedom belongs to the BONE, not to the part, so a bone
|
|
1089
|
+
* carrying four slots — a head with its eye, its mouth and its goggles — is fitted
|
|
1090
|
+
* once against all four rather than four times against one each. The parts are
|
|
1091
|
+
* pooled by their visible weight, which is the honest weighting: a bone whose head
|
|
1092
|
+
* is most of the way visible and whose eye is a sliver is mostly told by the head.
|
|
1093
|
+
*/
|
|
1094
|
+
function boneResidual(bp: BonePlace, targets: Target[], level: Level, plate: Plate, smooth: boolean): number {
|
|
1095
|
+
let acc = 0;
|
|
1096
|
+
let denom = 0;
|
|
1097
|
+
for (const target of targets) {
|
|
1098
|
+
if (target.weight <= 0) continue;
|
|
1099
|
+
const s = target.samples;
|
|
1100
|
+
const pl = partPlaceOf(bp, target.geometry, target.plate.width);
|
|
1101
|
+
const cos = Math.cos(pl.rotDeg * DEG) * pl.scale;
|
|
1102
|
+
const sin = Math.sin(pl.rotDeg * DEG) * pl.scale;
|
|
1103
|
+
for (let i = 0; i < s.count; i++) {
|
|
1104
|
+
if (target.flags[i] === 0) continue;
|
|
1105
|
+
const fx = pl.cx + s.u[i] * cos - s.v[i] * sin;
|
|
1106
|
+
const fy = pl.cy + s.u[i] * sin + s.v[i] * cos;
|
|
1107
|
+
acc +=
|
|
1108
|
+
s.w[i] *
|
|
1109
|
+
(smooth
|
|
1110
|
+
? errBilinear(level, plate, fx, fy, s.r[i], s.g[i], s.b[i])
|
|
1111
|
+
: errNearest(level, fx, fy, s.r[i], s.g[i], s.b[i]));
|
|
1112
|
+
}
|
|
1113
|
+
denom += target.weight;
|
|
1114
|
+
}
|
|
1115
|
+
return denom > 0 ? acc / denom : 1;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** One answer mid-search: the two residual degrees of freedom and what they scored. */
|
|
1119
|
+
interface HingeCandidate {
|
|
1120
|
+
hingeDeg: number;
|
|
1121
|
+
stretch: number;
|
|
1122
|
+
residual: number;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Pattern search on the residual degrees of freedom: probe, take the best
|
|
1127
|
+
* improvement, halve the steps when none of them improves.
|
|
1128
|
+
*
|
|
1129
|
+
* The sweep has already done the part a local method cannot — with one degree of
|
|
1130
|
+
* freedom, "find the right basin" is an exhaustive scan of a line, which is why
|
|
1131
|
+
* this file can afford the whole turn as its default window.
|
|
1132
|
+
*/
|
|
1133
|
+
function polishHinge(
|
|
1134
|
+
start: HingeCandidate,
|
|
1135
|
+
targets: Target[],
|
|
1136
|
+
level: Level,
|
|
1137
|
+
plate: Plate,
|
|
1138
|
+
parent: BonePlace,
|
|
1139
|
+
bone: SpineBone,
|
|
1140
|
+
stretchBounds: { min: number; max: number },
|
|
1141
|
+
/**
|
|
1142
|
+
* The hinge window the report declares.
|
|
1143
|
+
*
|
|
1144
|
+
* ⚠️ Clamped here for the same reason `pose`'s polish clamps its scale: a
|
|
1145
|
+
* refinement free to walk outside the window would report an answer nobody
|
|
1146
|
+
* searched, and the window is a reported field a caller is entitled to read as
|
|
1147
|
+
* a promise. A full turn contains every angle, so it is left unclamped and the
|
|
1148
|
+
* hinge may wrap.
|
|
1149
|
+
*/
|
|
1150
|
+
hingeBounds: { min: number; max: number; wraps: boolean },
|
|
1151
|
+
): HingeCandidate {
|
|
1152
|
+
const at = (hingeDeg: number, stretch: number): number =>
|
|
1153
|
+
boneResidual(childPlace(parent, bone, hingeDeg, stretch), targets, level, plate, true);
|
|
1154
|
+
const clamp = (v: number): number => Math.min(stretchBounds.max, Math.max(stretchBounds.min, v));
|
|
1155
|
+
const hold = (v: number): number =>
|
|
1156
|
+
hingeBounds.wraps ? v : Math.min(hingeBounds.max, Math.max(hingeBounds.min, v));
|
|
1157
|
+
let cur: HingeCandidate = { ...start, residual: at(start.hingeDeg, start.stretch) };
|
|
1158
|
+
let dh = HINGE_STEP;
|
|
1159
|
+
let ds = stretchBounds.max > stretchBounds.min ? 0.04 : 0;
|
|
1160
|
+
for (let guard = 0; guard < 200; guard++) {
|
|
1161
|
+
if (dh <= 0.02 && ds <= 0.002) break;
|
|
1162
|
+
let best = cur;
|
|
1163
|
+
const probe = (hingeDeg: number, stretch: number): void => {
|
|
1164
|
+
const residual = at(hingeDeg, stretch);
|
|
1165
|
+
if (residual < best.residual) best = { hingeDeg, stretch, residual };
|
|
1166
|
+
};
|
|
1167
|
+
if (dh > 0.02) {
|
|
1168
|
+
probe(hold(cur.hingeDeg + dh), cur.stretch);
|
|
1169
|
+
probe(hold(cur.hingeDeg - dh), cur.stretch);
|
|
1170
|
+
}
|
|
1171
|
+
if (ds > 0.002) {
|
|
1172
|
+
probe(cur.hingeDeg, clamp(cur.stretch * (1 + ds)));
|
|
1173
|
+
probe(cur.hingeDeg, clamp(cur.stretch * (1 - ds)));
|
|
1174
|
+
}
|
|
1175
|
+
if (best === cur) {
|
|
1176
|
+
dh /= 2;
|
|
1177
|
+
ds /= 2;
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
cur = best;
|
|
1181
|
+
}
|
|
1182
|
+
return cur;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* The hinge ladder, with a full turn's duplicate endpoint dropped.
|
|
1187
|
+
*
|
|
1188
|
+
* ⭐ `pose`'s `rotationLadder` is the shape this follows, and for the same reason
|
|
1189
|
+
* (issue #738). This one used to march `HINGE_STEP` off the floor and then append
|
|
1190
|
+
* the ceiling, so any window whose span is not a whole number of steps got a
|
|
1191
|
+
* short final gap — `--hinge -20,20` walked thirteen gaps of 3° and one of 1° —
|
|
1192
|
+
* while the report printed `step 3°` and every part's note said `in 3° steps`.
|
|
1193
|
+
* Now the window is divided into `Math.ceil(span / HINGE_STEP)` equal steps,
|
|
1194
|
+
* endpoints included, and the step reported is `hingeLadderStep` of what this returns.
|
|
1195
|
+
*
|
|
1196
|
+
* ⚠️ The count is a CEILING rather than a rounding: rounding down would give a
|
|
1197
|
+
* window like 4° one step of 4°, and the constant would stop bounding the step.
|
|
1198
|
+
* With the ceiling the rung count is the march's own — `ceil(span / step) + 1`
|
|
1199
|
+
* either way — so the repair moves where the rungs sit and never how many there
|
|
1200
|
+
* are, and a span the constant divides gets the same rungs it always had.
|
|
1201
|
+
*
|
|
1202
|
+
* Exported because `CF19` and `CUR82` read the printed step back against it: the
|
|
1203
|
+
* run walks exactly this, so it is the ladder a report line is a promise about.
|
|
1204
|
+
*/
|
|
1205
|
+
export function hingeLadder(minDeg: number, maxDeg: number): number[] {
|
|
1206
|
+
const span = maxDeg - minDeg;
|
|
1207
|
+
if (span <= 0) return [minDeg];
|
|
1208
|
+
if (span >= 360 - 1e-9) {
|
|
1209
|
+
const count = Math.round(360 / HINGE_STEP);
|
|
1210
|
+
const out: number[] = [];
|
|
1211
|
+
for (let i = 0; i < count; i++) out.push(minDeg + (i * 360) / count);
|
|
1212
|
+
return out;
|
|
1213
|
+
}
|
|
1214
|
+
const steps = Math.ceil(span / HINGE_STEP - 1e-9);
|
|
1215
|
+
const out: number[] = [];
|
|
1216
|
+
for (let i = 0; i <= steps; i++) out.push(minDeg + (span * i) / steps);
|
|
1217
|
+
return out;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** The step a hinge ladder walks, read off the ladder rather than off the constant it was capped at. */
|
|
1221
|
+
export function hingeLadderStep(degrees: readonly number[]): number {
|
|
1222
|
+
return degrees.length > 1 ? degrees[1] - degrees[0] : 0;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** How a chain part's note says the hinge was walked — the same step the `search` line states. */
|
|
1226
|
+
export function hingeWalkPhrase(stepDeg: number): string {
|
|
1227
|
+
// A shut window walks one rung and has no step, so the old `in 3° steps` on a
|
|
1228
|
+
// `--hinge 12,12` note was a claim about a ladder that did not exist.
|
|
1229
|
+
return stepDeg > 0 ? `in ${roundTo(stepDeg, 3)}° steps` : 'at its one rung';
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* The `search` line's hinge clause.
|
|
1234
|
+
*
|
|
1235
|
+
* ⭐ Exported for the reason `pose`'s `searchRotationClause` is: `docs/AUTHORING.md`
|
|
1236
|
+
* quotes this clause, and `CUR82` builds it here over the window the page names
|
|
1237
|
+
* and looks for it in the page, so the two go stale together or not at all.
|
|
1238
|
+
* Rounded for the console alone — `search.hinge.stepDeg` in the JSON is the
|
|
1239
|
+
* ladder's own step, unrounded.
|
|
1240
|
+
*/
|
|
1241
|
+
export function searchHingeClause(hinge: ChainFitReport['search']['hinge']): string {
|
|
1242
|
+
return `hinge ${hinge.minDeg}°–${hinge.maxDeg}° step ${roundTo(hinge.stepDeg, 3)}° (${hinge.steps} rungs)`;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function stretchLadder(bounds: { min: number; max: number }): number[] {
|
|
1246
|
+
if (bounds.max <= bounds.min) return [1];
|
|
1247
|
+
const out: number[] = [];
|
|
1248
|
+
for (let i = 0; i <= STRETCH_STEPS; i++) out.push(bounds.min * (bounds.max / bounds.min) ** (i / STRETCH_STEPS));
|
|
1249
|
+
return out;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Every basin on the hinge line, best first.
|
|
1254
|
+
*
|
|
1255
|
+
* 🚨 Local minima rather than the global best alone, and per stretch rung rather
|
|
1256
|
+
* than pooled — the same reason `pose` keeps its coarse scale fields apart. A limb
|
|
1257
|
+
* that explains the picture pointing forwards and again pointing back is two
|
|
1258
|
+
* answers, and an instrument that reports one of them has picked without saying so.
|
|
1259
|
+
*/
|
|
1260
|
+
function hingeMinima(sweep: HingeCandidate[], wraps: boolean): HingeCandidate[] {
|
|
1261
|
+
const out: HingeCandidate[] = [];
|
|
1262
|
+
const n = sweep.length;
|
|
1263
|
+
for (let i = 0; i < n; i++) {
|
|
1264
|
+
const prev = i === 0 ? (wraps ? sweep[n - 1] : null) : sweep[i - 1];
|
|
1265
|
+
const next = i === n - 1 ? (wraps ? sweep[0] : null) : sweep[i + 1];
|
|
1266
|
+
if (prev !== null && prev.residual < sweep[i].residual) continue;
|
|
1267
|
+
if (next !== null && next.residual < sweep[i].residual) continue;
|
|
1268
|
+
out.push(sweep[i]);
|
|
1269
|
+
}
|
|
1270
|
+
if (out.length === 0 && n > 0) out.push(sweep.reduce((a, b) => (b.residual < a.residual ? b : a)));
|
|
1271
|
+
return out.sort((a, b) => a.residual - b.residual);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ---------------------------------------------------------------------------
|
|
1275
|
+
// measuring the answer
|
|
1276
|
+
// ---------------------------------------------------------------------------
|
|
1277
|
+
|
|
1278
|
+
interface Measured {
|
|
1279
|
+
residual: number;
|
|
1280
|
+
visibleShare: number;
|
|
1281
|
+
scoredPixels: number;
|
|
1282
|
+
unexplained: number;
|
|
1283
|
+
offCanvas: number;
|
|
1284
|
+
footprint: number;
|
|
1285
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* The reported numbers, over EVERY visible pixel of the part rather than a sample
|
|
1290
|
+
* of them — and over the frozen set, which is the set the search minimised.
|
|
1291
|
+
*/
|
|
1292
|
+
function measure(part: Plate, pl: PartPlace, frozen: VisibleSet, level: Level, plate: Plate): Measured {
|
|
1293
|
+
const cos = Math.cos(pl.rotDeg * DEG) * pl.scale;
|
|
1294
|
+
const sin = Math.sin(pl.rotDeg * DEG) * pl.scale;
|
|
1295
|
+
let acc = 0;
|
|
1296
|
+
let unexplained = 0;
|
|
1297
|
+
let onMaterial = 0;
|
|
1298
|
+
let off = 0;
|
|
1299
|
+
let minX = Infinity;
|
|
1300
|
+
let minY = Infinity;
|
|
1301
|
+
let maxX = -Infinity;
|
|
1302
|
+
let maxY = -Infinity;
|
|
1303
|
+
for (let y = 0; y < part.height; y++) {
|
|
1304
|
+
for (let x = 0; x < part.width; x++) {
|
|
1305
|
+
const i = (y * part.width + x) * 4;
|
|
1306
|
+
const a = part.data[i + 3];
|
|
1307
|
+
if (a === 0) continue;
|
|
1308
|
+
const w = a / 255;
|
|
1309
|
+
const u = x + 0.5 - part.width / 2;
|
|
1310
|
+
const v = y + 0.5 - part.height / 2;
|
|
1311
|
+
const fx = pl.cx + u * cos - v * sin;
|
|
1312
|
+
const fy = pl.cy + u * sin + v * cos;
|
|
1313
|
+
if (fx < minX) minX = fx;
|
|
1314
|
+
if (fx > maxX) maxX = fx;
|
|
1315
|
+
if (fy < minY) minY = fy;
|
|
1316
|
+
if (fy > maxY) maxY = fy;
|
|
1317
|
+
const inside = fx >= 0 && fy >= 0 && fx < level.width && fy < level.height;
|
|
1318
|
+
if (!inside) off += w;
|
|
1319
|
+
if (frozen.keep[y * part.width + x] === 0) continue;
|
|
1320
|
+
const err = errBilinear(level, plate, fx, fy, part.data[i], part.data[i + 1], part.data[i + 2]);
|
|
1321
|
+
acc += w * err;
|
|
1322
|
+
if (err > UNEXPLAINED_TOLERANCE) unexplained += w;
|
|
1323
|
+
if (inside) {
|
|
1324
|
+
const ix = Math.min(level.width - 1, Math.floor(fx));
|
|
1325
|
+
const iy = Math.min(level.height - 1, Math.floor(fy));
|
|
1326
|
+
onMaterial += w * (plate.data[(iy * level.width + ix) * 4 + 3] / 255);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
const denom = frozen.weight > 0 ? frozen.weight : 1;
|
|
1331
|
+
const total = frozen.total > 0 ? frozen.total : 1;
|
|
1332
|
+
return {
|
|
1333
|
+
residual: frozen.weight > 0 ? acc / denom : 1,
|
|
1334
|
+
visibleShare: frozen.weight / total,
|
|
1335
|
+
scoredPixels: frozen.pixels,
|
|
1336
|
+
unexplained: frozen.weight > 0 ? unexplained / denom : 1,
|
|
1337
|
+
offCanvas: off / total,
|
|
1338
|
+
// One part pixel covers `scale²` frame pixels, so this is the frame area the
|
|
1339
|
+
// placement accounts for over the pixels it was allowed to claim.
|
|
1340
|
+
footprint: onMaterial * pl.scale * pl.scale,
|
|
1341
|
+
bbox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function toPlacement(
|
|
1346
|
+
pl: PartPlace,
|
|
1347
|
+
link: { hingeDeg: number; stretch: number } | null,
|
|
1348
|
+
setupRotation: number,
|
|
1349
|
+
stats: Measured,
|
|
1350
|
+
visibleShareAtFit: number,
|
|
1351
|
+
): ChainFitPlacement {
|
|
1352
|
+
return {
|
|
1353
|
+
x: roundTo(pl.cx, 3),
|
|
1354
|
+
y: roundTo(pl.cy, 3),
|
|
1355
|
+
rotationDeg: roundTo(normaliseDegrees(pl.rotDeg), 3),
|
|
1356
|
+
scale: roundTo(pl.scale, 5),
|
|
1357
|
+
hingeDeg: link === null ? null : roundTo(normaliseDegrees(link.hingeDeg), 3),
|
|
1358
|
+
localRotationDeg: link === null ? null : roundTo(normaliseDegrees(setupRotation + link.hingeDeg), 3),
|
|
1359
|
+
stretch: link === null ? null : roundTo(link.stretch, 5),
|
|
1360
|
+
residual: roundTo(stats.residual, 5),
|
|
1361
|
+
visibleShare: roundTo(stats.visibleShare, 4),
|
|
1362
|
+
scoredPixels: stats.scoredPixels,
|
|
1363
|
+
visibleShareAtFit: roundTo(visibleShareAtFit, 4),
|
|
1364
|
+
unexplained: roundTo(stats.unexplained, 4),
|
|
1365
|
+
offCanvas: roundTo(stats.offCanvas, 4),
|
|
1366
|
+
footprint: roundTo(stats.footprint, 1),
|
|
1367
|
+
bbox: {
|
|
1368
|
+
x: roundTo(stats.bbox.x, 2),
|
|
1369
|
+
y: roundTo(stats.bbox.y, 2),
|
|
1370
|
+
width: roundTo(stats.bbox.width, 2),
|
|
1371
|
+
height: roundTo(stats.bbox.height, 2),
|
|
1372
|
+
},
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// ---------------------------------------------------------------------------
|
|
1377
|
+
// the anchor
|
|
1378
|
+
// ---------------------------------------------------------------------------
|
|
1379
|
+
|
|
1380
|
+
/** One part's anchor-pass answer, in the shape the walk needs it. */
|
|
1381
|
+
interface AnchorEntry {
|
|
1382
|
+
place: PartPlace;
|
|
1383
|
+
verdict: ChainFitAnchorVerdict;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function readAnchorFile(path: string): PoseReport {
|
|
1387
|
+
if (!existsSync(path)) throw new ChainFitError(`no anchor report at ${path}`);
|
|
1388
|
+
let parsed: unknown;
|
|
1389
|
+
try {
|
|
1390
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
1391
|
+
} catch (err) {
|
|
1392
|
+
throw new ChainFitError(`cannot parse the anchor report ${path} as JSON: ${(err as Error).message}`);
|
|
1393
|
+
}
|
|
1394
|
+
const report = parsed as PoseReport;
|
|
1395
|
+
if (report?.spec !== POSE_SPEC) {
|
|
1396
|
+
throw new ChainFitError(
|
|
1397
|
+
`${path} declares spec ${JSON.stringify(report?.spec ?? null)}; --anchor takes a ${POSE_SPEC} report, ` +
|
|
1398
|
+
'which is what `rigc pose --out` writes',
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
if (!Array.isArray(report.parts)) throw new ChainFitError(`${path} carries no parts array`);
|
|
1402
|
+
return report;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** An anchor report folded into per-part entries, keyed by PNG file name. */
|
|
1406
|
+
function anchorEntries(
|
|
1407
|
+
report: PoseReport,
|
|
1408
|
+
criterion: { maxResidual: number; maxUnexplained: number },
|
|
1409
|
+
): Map<string, AnchorEntry> {
|
|
1410
|
+
const out = new Map<string, AnchorEntry>();
|
|
1411
|
+
for (const part of report.parts) {
|
|
1412
|
+
if (part.placement === null) continue;
|
|
1413
|
+
const p = part.placement;
|
|
1414
|
+
const eligible =
|
|
1415
|
+
part.refusal === null &&
|
|
1416
|
+
!part.ambiguous &&
|
|
1417
|
+
p.residual <= criterion.maxResidual &&
|
|
1418
|
+
p.unexplained <= criterion.maxUnexplained;
|
|
1419
|
+
out.set(basename(part.part), {
|
|
1420
|
+
place: { cx: p.x, cy: p.y, rotDeg: p.rotationDeg, scale: p.scale },
|
|
1421
|
+
verdict: { residual: p.residual, unexplained: p.unexplained, ambiguous: part.ambiguous, eligible },
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
return out;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// ---------------------------------------------------------------------------
|
|
1428
|
+
// the instrument
|
|
1429
|
+
// ---------------------------------------------------------------------------
|
|
1430
|
+
|
|
1431
|
+
/** The mutable state of one part across the passes. */
|
|
1432
|
+
interface PartState {
|
|
1433
|
+
drawn: DrawnSlot;
|
|
1434
|
+
path: string;
|
|
1435
|
+
plate: Plate | null;
|
|
1436
|
+
/** Why nothing was searched, when nothing was. */
|
|
1437
|
+
blocked: ChainFitRefusal | null;
|
|
1438
|
+
place: PartPlace | null;
|
|
1439
|
+
/** The visible set the search was scored on, frozen before it ran. */
|
|
1440
|
+
frozen: VisibleSet | null;
|
|
1441
|
+
/** The link its bone ended up on, or `null` where the quantity does not exist. */
|
|
1442
|
+
link: { hingeDeg: number; stretch: number } | null;
|
|
1443
|
+
/** Its bone's visible set had to be relocated by an unmasked look before it froze. */
|
|
1444
|
+
relocated: boolean;
|
|
1445
|
+
/**
|
|
1446
|
+
* This part IS the one its bone's anchor was read from.
|
|
1447
|
+
*
|
|
1448
|
+
* ⭐ The distinction the refusals turn on. Three kinds of placement live in one
|
|
1449
|
+
* report: the anchor's own, which came from the anchor pass and was not searched
|
|
1450
|
+
* here; the other parts on an anchored bone, whose placement is the RIG's and is
|
|
1451
|
+
* therefore a real measurement of it; and the chain's, which was searched. This
|
|
1452
|
+
* instrument refuses the last two and does not second-guess the first — an
|
|
1453
|
+
* `occluded` refusal says "a search over a sliver is not a measurement", and no
|
|
1454
|
+
* search happened. Its trust signal is `anchorVerdict`, which is the pass's own.
|
|
1455
|
+
*/
|
|
1456
|
+
isAnchorSource: boolean;
|
|
1457
|
+
alternates: { place: PartPlace; link: { hingeDeg: number; stretch: number } }[];
|
|
1458
|
+
role: 'anchor' | 'chain' | 'inward' | 'unplaced';
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
export function estimateChainFit(options: ChainFitOptions): ChainFitReport {
|
|
1462
|
+
const skeletonPath = resolveSkeletonPath(options.candidatePath);
|
|
1463
|
+
const framePath = resolve(options.framePath);
|
|
1464
|
+
const imagesDir = resolve(options.imagesDir);
|
|
1465
|
+
if (!existsSync(framePath)) throw new ChainFitError(`no pose frame at ${framePath}`);
|
|
1466
|
+
if (!existsSync(imagesDir) || !statSync(imagesDir).isDirectory()) {
|
|
1467
|
+
throw new ChainFitError(`${imagesDir} is not a directory — --images takes the directory the part PNGs are in`);
|
|
1468
|
+
}
|
|
1469
|
+
if (options.anchorPath !== undefined && (options.scale !== undefined || options.rotation !== undefined)) {
|
|
1470
|
+
throw new ChainFitError(
|
|
1471
|
+
'--scale and --rotation size the internal anchor pass, and --anchor means there is no internal pass; ' +
|
|
1472
|
+
'give those two to `rigc pose` when you make the report instead',
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
let frame: Plate;
|
|
1476
|
+
try {
|
|
1477
|
+
frame = readPlate(framePath);
|
|
1478
|
+
} catch (err) {
|
|
1479
|
+
throw new ChainFitError(`cannot read the pose frame ${framePath}: ${(err as Error).message}`);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const skel = readSkeleton(skeletonPath);
|
|
1483
|
+
const bones = new Map<string, SpineBone>(skel.bones.map((b) => [b.name, b]));
|
|
1484
|
+
const boneOrder = skel.bones.map((b) => b.name);
|
|
1485
|
+
const unsupported = unsupportedBones(skel.bones);
|
|
1486
|
+
const keyed = keyedProperties(skel);
|
|
1487
|
+
|
|
1488
|
+
const hingeMin = options.hinge?.minDeg ?? DEFAULT_HINGE_MIN;
|
|
1489
|
+
const hingeMax = options.hinge?.maxDeg ?? DEFAULT_HINGE_MAX;
|
|
1490
|
+
const hinges = hingeLadder(hingeMin, hingeMax);
|
|
1491
|
+
// One derivation of the step, read off the ladder the run walks: the report's
|
|
1492
|
+
// `search.hinge`, every part's `window` and every part's note all take it
|
|
1493
|
+
// from here, and none of them from `HINGE_STEP`, which only caps it.
|
|
1494
|
+
const hingeStep = hingeLadderStep(hinges);
|
|
1495
|
+
const wraps = hingeMax - hingeMin >= 360 - 1e-9;
|
|
1496
|
+
const stretchRatio = options.stretch ?? DEFAULT_STRETCH_RATIO;
|
|
1497
|
+
const stretchEverywhere = options.stretch !== undefined;
|
|
1498
|
+
const minVisible = options.minVisible ?? DEFAULT_MIN_VISIBLE;
|
|
1499
|
+
const maxResidual = options.maxResidual ?? DEFAULT_MAX_RESIDUAL;
|
|
1500
|
+
const passes = Math.max(1, Math.round(options.passes ?? DEFAULT_PASSES));
|
|
1501
|
+
const minLeverPx = options.minLeverPx ?? DEFAULT_MIN_LEVER_PX;
|
|
1502
|
+
const criterion = {
|
|
1503
|
+
maxResidual: options.anchorMaxResidual ?? ANCHOR_MAX_RESIDUAL,
|
|
1504
|
+
maxUnexplained: options.anchorMaxUnexplained ?? ANCHOR_MAX_UNEXPLAINED,
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
// --- what the candidate draws, in its own order --------------------------
|
|
1508
|
+
const drawn: DrawnSlot[] = [];
|
|
1509
|
+
const undrawn: string[] = [];
|
|
1510
|
+
const blockedSlots: { slot: string; why: string }[] = [];
|
|
1511
|
+
for (const slot of skel.slots) {
|
|
1512
|
+
if (slot.attachment === undefined) {
|
|
1513
|
+
undrawn.push(slot.name);
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
const resolved = resolveAttachment(skel, slot.name, slot.attachment);
|
|
1517
|
+
if (resolved === null) {
|
|
1518
|
+
undrawn.push(slot.name);
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
if ('unsupported' in resolved) {
|
|
1522
|
+
blockedSlots.push({ slot: slot.name, why: resolved.unsupported });
|
|
1523
|
+
continue;
|
|
1524
|
+
}
|
|
1525
|
+
drawn.push({
|
|
1526
|
+
slot: slot.name,
|
|
1527
|
+
bone: slot.bone,
|
|
1528
|
+
attachment: slot.attachment,
|
|
1529
|
+
image: resolved.image,
|
|
1530
|
+
geometry: resolved.geometry,
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
if (drawn.length === 0) {
|
|
1534
|
+
throw new ChainFitError(
|
|
1535
|
+
`${skeletonPath} poses no region attachment in its setup pose — a chain fit places region attachments, and ` +
|
|
1536
|
+
'this candidate has none to place',
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// --- the parts, and the states that carry them ---------------------------
|
|
1541
|
+
const states: PartState[] = [];
|
|
1542
|
+
const plateCache = new Map<string, Plate | null>();
|
|
1543
|
+
for (const slot of drawn) {
|
|
1544
|
+
const path = join(imagesDir, `${slot.image}.png`);
|
|
1545
|
+
const state: PartState = {
|
|
1546
|
+
drawn: slot,
|
|
1547
|
+
path,
|
|
1548
|
+
plate: null,
|
|
1549
|
+
blocked: null,
|
|
1550
|
+
place: null,
|
|
1551
|
+
frozen: null,
|
|
1552
|
+
link: null,
|
|
1553
|
+
relocated: false,
|
|
1554
|
+
isAnchorSource: false,
|
|
1555
|
+
alternates: [],
|
|
1556
|
+
role: 'unplaced',
|
|
1557
|
+
};
|
|
1558
|
+
const boneBlock = unsupported.get(slot.bone);
|
|
1559
|
+
if (!bones.has(slot.bone)) {
|
|
1560
|
+
state.blocked = {
|
|
1561
|
+
reason: 'unsupported-geometry',
|
|
1562
|
+
detail: `slot "${slot.slot}" hangs off bone "${slot.bone}", which the skeleton does not declare`,
|
|
1563
|
+
};
|
|
1564
|
+
} else if (boneBlock !== undefined) {
|
|
1565
|
+
state.blocked = { reason: 'unsupported-geometry', detail: `bone "${slot.bone}": ${boneBlock}` };
|
|
1566
|
+
} else {
|
|
1567
|
+
const plate = loadPart(path, plateCache);
|
|
1568
|
+
if (typeof plate === 'string') {
|
|
1569
|
+
state.blocked = { reason: 'no-part-image', detail: plate };
|
|
1570
|
+
} else if (!hasMaterial(plate)) {
|
|
1571
|
+
state.blocked = {
|
|
1572
|
+
reason: 'empty-part',
|
|
1573
|
+
detail: `${basename(path)} is ${plate.width}x${plate.height} and every pixel of it is transparent`,
|
|
1574
|
+
};
|
|
1575
|
+
} else {
|
|
1576
|
+
state.plate = plate;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
states.push(state);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// --- the frame, as the objective reads it --------------------------------
|
|
1583
|
+
const background = readBackground(frame);
|
|
1584
|
+
const material = materialPlate(frame, background);
|
|
1585
|
+
background.materialShare = roundTo(material.share, 4);
|
|
1586
|
+
const level = levelOf(material.plate, 1);
|
|
1587
|
+
|
|
1588
|
+
// --- the anchor ----------------------------------------------------------
|
|
1589
|
+
const anchorSource: 'pose' | 'file' = options.anchorPath === undefined ? 'pose' : 'file';
|
|
1590
|
+
const partPaths = [...new Set(states.filter((s) => s.plate !== null).map((s) => s.path))].sort();
|
|
1591
|
+
const anchorReport =
|
|
1592
|
+
options.anchorPath === undefined
|
|
1593
|
+
? // ⭐ The internal anchor pass IS `pose`, over exactly the parts this
|
|
1594
|
+
// candidate draws — not a second estimator with the same job. `--anchor`
|
|
1595
|
+
// is the same call made earlier and saved.
|
|
1596
|
+
estimatePose({ imagesDir, framePath, parts: partPaths, scale: options.scale, rotation: options.rotation })
|
|
1597
|
+
: readAnchorFile(options.anchorPath);
|
|
1598
|
+
const anchors = anchorEntries(anchorReport, criterion);
|
|
1599
|
+
|
|
1600
|
+
const anchorForBone = new Map<string, { state: PartState; entry: AnchorEntry }>();
|
|
1601
|
+
for (const state of states) {
|
|
1602
|
+
if (state.plate === null) continue;
|
|
1603
|
+
const entry = anchors.get(basename(state.path));
|
|
1604
|
+
if (entry === undefined || !entry.verdict.eligible) continue;
|
|
1605
|
+
const held = anchorForBone.get(state.drawn.bone);
|
|
1606
|
+
// A bone carrying several drawn parts is anchored by the one the pass trusts
|
|
1607
|
+
// most, because they cannot all be right and the residual is the tie-break.
|
|
1608
|
+
if (held === undefined || entry.verdict.residual < held.entry.verdict.residual) {
|
|
1609
|
+
anchorForBone.set(state.drawn.bone, { state, entry });
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// --- the walk ------------------------------------------------------------
|
|
1614
|
+
const placedBones = new Map<string, BonePlace>();
|
|
1615
|
+
const depthOf = new Map<string, number>();
|
|
1616
|
+
const anchoredTo = new Map<string, string>();
|
|
1617
|
+
const carried = new Map<string, string[]>();
|
|
1618
|
+
const pivotDisagreement = new Map<string, number>();
|
|
1619
|
+
const targetsOfBone = new Map<string, PartState[]>();
|
|
1620
|
+
for (const state of states) {
|
|
1621
|
+
if (state.plate === null) continue;
|
|
1622
|
+
const list = targetsOfBone.get(state.drawn.bone) ?? [];
|
|
1623
|
+
list.push(state);
|
|
1624
|
+
targetsOfBone.set(state.drawn.bone, list);
|
|
1625
|
+
}
|
|
1626
|
+
/** Bones the inward step determined, and the account of each determination. */
|
|
1627
|
+
const inwardBones = new Map<string, ChainFitInwardView>();
|
|
1628
|
+
/** Why a bone the inward step LOOKED at could not be determined, for its refusal. */
|
|
1629
|
+
const inwardAttempt = new Map<string, string>();
|
|
1630
|
+
const hasArt = (bone: string): boolean => (targetsOfBone.get(bone) ?? []).length > 0;
|
|
1631
|
+
const boneStretchFree = (bone: string): boolean => stretchEverywhere || (keyed.get(bone)?.has('scale') ?? false);
|
|
1632
|
+
|
|
1633
|
+
/** Where the rig currently puts a part, given the placed bones. */
|
|
1634
|
+
const placeOf = (state: PartState): PartPlace | null => {
|
|
1635
|
+
const bp = placedBones.get(state.drawn.bone);
|
|
1636
|
+
if (bp === undefined || state.plate === null) return null;
|
|
1637
|
+
return partPlaceOf(bp, state.drawn.geometry, state.plate.width);
|
|
1638
|
+
};
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Seed every bone: trunk bones from their own answer, the rest from the rig.
|
|
1642
|
+
*
|
|
1643
|
+
* ⭐ Two kinds of trunk now, and they are seeded identically on purpose. An
|
|
1644
|
+
* ANCHORED bone's four numbers were read off the picture; an INWARD bone's were
|
|
1645
|
+
* determined from two anchored descendants. Either way the bone is fixed, its
|
|
1646
|
+
* `depth` is 0 and everything under it follows from the rig — so the only thing
|
|
1647
|
+
* that distinguishes them downstream is `anchoredToRole`, which is exactly the
|
|
1648
|
+
* field a caller reads to decide how much to trust a subtree.
|
|
1649
|
+
*
|
|
1650
|
+
* Idempotent, and run more than once: the inward step needs the anchored bones
|
|
1651
|
+
* placed before it can determine anything, and its determinations then need
|
|
1652
|
+
* propagating to everything below them.
|
|
1653
|
+
*/
|
|
1654
|
+
const seedBones = (): void => {
|
|
1655
|
+
for (const name of boneOrder) {
|
|
1656
|
+
const bone = bones.get(name);
|
|
1657
|
+
if (bone === undefined || unsupported.has(name)) continue;
|
|
1658
|
+
const anchor = anchorForBone.get(name);
|
|
1659
|
+
const parentPlace = bone.parent === undefined ? undefined : placedBones.get(bone.parent);
|
|
1660
|
+
const chainPlace = parentPlace === undefined ? null : childPlace(parentPlace, bone, 0, 1);
|
|
1661
|
+
if (anchor !== undefined && anchor.state.plate !== null) {
|
|
1662
|
+
const place = bonePlaceFromPart(anchor.entry.place, anchor.state.drawn.geometry, anchor.state.plate.width);
|
|
1663
|
+
placedBones.set(name, place);
|
|
1664
|
+
depthOf.set(name, 0);
|
|
1665
|
+
anchoredTo.set(name, name);
|
|
1666
|
+
carried.set(name, []);
|
|
1667
|
+
if (chainPlace !== null) {
|
|
1668
|
+
pivotDisagreement.set(name, Math.hypot(chainPlace.x - place.x, chainPlace.y - place.y));
|
|
1669
|
+
}
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
if (inwardBones.has(name)) {
|
|
1673
|
+
// Already in `placedBones` — put there by `inwardStep`, which is the only
|
|
1674
|
+
// writer of it. Its own bookkeeping is set here so the two trunk kinds go
|
|
1675
|
+
// through one code path.
|
|
1676
|
+
depthOf.set(name, 0);
|
|
1677
|
+
anchoredTo.set(name, name);
|
|
1678
|
+
carried.set(name, []);
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
if (chainPlace === null || bone.parent === undefined) continue;
|
|
1682
|
+
placedBones.set(name, chainPlace);
|
|
1683
|
+
depthOf.set(name, (depthOf.get(bone.parent) ?? 0) + 1);
|
|
1684
|
+
const root = anchoredTo.get(bone.parent);
|
|
1685
|
+
if (root !== undefined) anchoredTo.set(name, root);
|
|
1686
|
+
const inherited = carried.get(bone.parent) ?? [];
|
|
1687
|
+
const fittable = (targetsOfBone.get(name) ?? []).length > 0;
|
|
1688
|
+
carried.set(name, fittable ? inherited : [...inherited, name]);
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* The inward step: bones with two or more ANCHORED descendants, determined.
|
|
1694
|
+
*
|
|
1695
|
+
* ⬆️ Run after `seedBones` and before anything is fitted, so what it sees is
|
|
1696
|
+
* exactly the anchored bones and the rig's own prediction below them. A bone it
|
|
1697
|
+
* reaches is a bone the outward walk left unplaced, which — because seeding
|
|
1698
|
+
* propagates through every placed parent — means a bone with **no placed
|
|
1699
|
+
* ancestor at all**. So there is no parent side to bracket against, and the
|
|
1700
|
+
* whole determination comes from below.
|
|
1701
|
+
*
|
|
1702
|
+
* 🚨 The determinants are ANCHORED bones and nothing else. A bone the outward
|
|
1703
|
+
* walk placed is sitting at whatever hinge the rig's setup happens to declare
|
|
1704
|
+
* until it is fitted, and a bone this step determined is itself derived —
|
|
1705
|
+
* reading either as evidence would compound a guess into a placement and there
|
|
1706
|
+
* would be no field that said so. `criterion.determinantsMustBeAnchored` states
|
|
1707
|
+
* it in the report.
|
|
1708
|
+
*/
|
|
1709
|
+
const inwardStep = (): void => {
|
|
1710
|
+
for (const name of boneOrder) {
|
|
1711
|
+
if (placedBones.has(name) || unsupported.has(name)) continue;
|
|
1712
|
+
const bone = bones.get(name);
|
|
1713
|
+
if (bone === undefined) continue;
|
|
1714
|
+
|
|
1715
|
+
const usable: { bone: string; part: string; local: BoneLocal; carried: string[]; place: BonePlace }[] = [];
|
|
1716
|
+
const rejected: { bone: string; why: string }[] = [];
|
|
1717
|
+
for (const [anchoredBone, held] of anchorForBone) {
|
|
1718
|
+
const place = placedBones.get(anchoredBone);
|
|
1719
|
+
if (place === undefined) continue;
|
|
1720
|
+
const path = rigidPathTo(bones, unsupported, hasArt, boneStretchFree, name, anchoredBone);
|
|
1721
|
+
if ('blocked' in path) {
|
|
1722
|
+
// Only worth reporting for an anchor that IS below this bone: "not a
|
|
1723
|
+
// descendant" is true of most of the skeleton and says nothing.
|
|
1724
|
+
if (!path.blocked.includes('is not a descendant of')) rejected.push({ bone: anchoredBone, why: path.blocked });
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
usable.push({
|
|
1728
|
+
bone: anchoredBone,
|
|
1729
|
+
part: basename(held.state.path),
|
|
1730
|
+
local: path.local,
|
|
1731
|
+
carried: path.carried,
|
|
1732
|
+
place,
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
const listed = (): string =>
|
|
1737
|
+
rejected.length === 0
|
|
1738
|
+
? ''
|
|
1739
|
+
: `; ${rejected.length} anchored descendant(s) could not be used — ${rejected
|
|
1740
|
+
.map((r) => `"${r.bone}" (${r.why})`)
|
|
1741
|
+
.join('; ')}`;
|
|
1742
|
+
|
|
1743
|
+
if (usable.length + rejected.length === 0) continue; // nothing below it: `no-anchor`, unchanged
|
|
1744
|
+
if (usable.length < INWARD_MIN_DETERMINANTS) {
|
|
1745
|
+
inwardAttempt.set(
|
|
1746
|
+
name,
|
|
1747
|
+
`bone "${name}" has ${usable.length} usable anchored descendant(s) ` +
|
|
1748
|
+
`(${usable.length === 0 ? 'none' : usable.map((u) => `"${u.bone}"`).join(', ')}) and an inward ` +
|
|
1749
|
+
`determination needs ${INWARD_MIN_DETERMINANTS}: four numbers fix a placement and each anchored ` +
|
|
1750
|
+
'descendant supplies two. One anchor below a bone says nothing about the link above it' +
|
|
1751
|
+
listed(),
|
|
1752
|
+
);
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const solved = solveFromDescendants(usable.map((u) => ({ local: u.local, X: u.place.x, Y: u.place.y })));
|
|
1757
|
+
let lever = 0;
|
|
1758
|
+
for (let i = 0; i < usable.length; i++) {
|
|
1759
|
+
for (let j = i + 1; j < usable.length; j++) {
|
|
1760
|
+
lever = Math.max(lever, Math.hypot(usable[i].place.x - usable[j].place.x, usable[i].place.y - usable[j].place.y));
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
if (solved === null) {
|
|
1764
|
+
inwardAttempt.set(
|
|
1765
|
+
name,
|
|
1766
|
+
`bone "${name}" has ${usable.length} usable anchored descendant(s) (${usable
|
|
1767
|
+
.map((u) => `"${u.bone}"`)
|
|
1768
|
+
.join(', ')}) and they do not fix a rotation: the rig places their pivots at the same point in ` +
|
|
1769
|
+
`"${name}"'s own local space, so there is no direction to read one from` + listed(),
|
|
1770
|
+
);
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
if (lever < minLeverPx) {
|
|
1774
|
+
inwardAttempt.set(
|
|
1775
|
+
name,
|
|
1776
|
+
`bone "${name}"'s anchored descendants (${usable.map((u) => `"${u.bone}"`).join(', ')}) sit ` +
|
|
1777
|
+
`${roundTo(lever, 3)} px apart in the frame, below the lever floor ${minLeverPx}: a rotation read ` +
|
|
1778
|
+
'across that span turns a half-pixel anchor error into several degrees, so nothing is printed rather ' +
|
|
1779
|
+
'than printed and disowned' +
|
|
1780
|
+
listed(),
|
|
1781
|
+
);
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// What the adopted answer PREDICTS for each determinant's own pivot, against
|
|
1786
|
+
// where that anchor actually put it. At two determinants this is zero by
|
|
1787
|
+
// construction; above two it is the diagnostic.
|
|
1788
|
+
const determinants: ChainFitInwardDeterminant[] = usable.map((u) => {
|
|
1789
|
+
const [px, py] = applyBoneLocal(solved, u.local.a, -u.local.b);
|
|
1790
|
+
return {
|
|
1791
|
+
bone: u.bone,
|
|
1792
|
+
part: u.part,
|
|
1793
|
+
leverPx: roundTo(Math.hypot(u.place.x - solved.x, u.place.y - solved.y), 3),
|
|
1794
|
+
offsetPx: roundTo(Math.hypot(px - u.place.x, py - u.place.y), 4),
|
|
1795
|
+
carried: u.carried,
|
|
1796
|
+
};
|
|
1797
|
+
});
|
|
1798
|
+
const redundancy = 2 * usable.length - 4;
|
|
1799
|
+
placedBones.set(name, solved);
|
|
1800
|
+
inwardBones.set(name, {
|
|
1801
|
+
form: 'descendants',
|
|
1802
|
+
determinants,
|
|
1803
|
+
rejected,
|
|
1804
|
+
redundancy,
|
|
1805
|
+
leverPx: roundTo(lever, 3),
|
|
1806
|
+
minLeverPx,
|
|
1807
|
+
disagreementPx: redundancy > 0 ? roundTo(Math.max(...determinants.map((d) => d.offsetPx)), 4) : null,
|
|
1808
|
+
});
|
|
1809
|
+
inwardAttempt.delete(name);
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* The union of everything drawn AFTER each of the named parts, at wherever those
|
|
1815
|
+
* later parts currently sit.
|
|
1816
|
+
*
|
|
1817
|
+
* 🚨 Rebuilt for each bone the moment before that bone is fitted, and NOT once
|
|
1818
|
+
* per pass — this is the difference between the instrument working on a stance
|
|
1819
|
+
* and not. Building every mask from the rig's setup prediction seems equivalent
|
|
1820
|
+
* and is not: a setup pose has the arms hanging down the body, so the first pass
|
|
1821
|
+
* masks a thigh with an arm that is not there. Measured on `ess/idle/f0000`,
|
|
1822
|
+
* `front-thigh` came back 4.5% visible, was searched over 202 pixels and landed
|
|
1823
|
+
* 78° out; with the mask refreshed from the arm's own fitted placement — the arm
|
|
1824
|
+
* bones are declared before the thighs, so they are already fitted by then — the
|
|
1825
|
+
* same part reads 71% visible and lands on the leg. What remains order-dependent
|
|
1826
|
+
* is what `passes` is for.
|
|
1827
|
+
*
|
|
1828
|
+
* Walked in reverse draw order with one running union, so the union holds
|
|
1829
|
+
* exactly the later-drawn parts at each snapshot, and one frame-sized array is
|
|
1830
|
+
* all it costs however many parts there are.
|
|
1831
|
+
*/
|
|
1832
|
+
const coversFor = (indices: number[]): Map<number, Uint8Array> => {
|
|
1833
|
+
const out = new Map<number, Uint8Array>();
|
|
1834
|
+
if (indices.length === 0) return out;
|
|
1835
|
+
const wanted = new Set(indices);
|
|
1836
|
+
const lowest = Math.min(...indices);
|
|
1837
|
+
const cover = new Uint8Array(frame.width * frame.height);
|
|
1838
|
+
for (let i = states.length - 1; i >= lowest; i--) {
|
|
1839
|
+
if (wanted.has(i)) out.set(i, cover.slice());
|
|
1840
|
+
const state = states[i];
|
|
1841
|
+
if (state.plate === null || state.place === null) continue;
|
|
1842
|
+
stampCover(cover, frame.width, frame.height, state.plate, state.place);
|
|
1843
|
+
}
|
|
1844
|
+
return out;
|
|
1845
|
+
};
|
|
1846
|
+
const indexOfState = new Map<PartState, number>();
|
|
1847
|
+
states.forEach((state, i) => indexOfState.set(state, i));
|
|
1848
|
+
|
|
1849
|
+
const samplesCache = new Map<string, Samples>();
|
|
1850
|
+
const samplesFor = (state: PartState, plate: Plate, scale: number): Samples => {
|
|
1851
|
+
// The part is reduced to about the frame's own resolution before it is
|
|
1852
|
+
// sampled — comparing full-resolution art against a frame drawn at a fifth of
|
|
1853
|
+
// it charges the frame's own downsampling on every edge pixel.
|
|
1854
|
+
const mip = Math.max(0, Math.min(8, Math.round(Math.log2(1 / Math.max(1e-6, scale)))));
|
|
1855
|
+
const key = `${state.path}:${mip}`;
|
|
1856
|
+
const hit = samplesCache.get(key);
|
|
1857
|
+
if (hit !== undefined) return hit;
|
|
1858
|
+
let reduced = plate;
|
|
1859
|
+
for (let i = 0; i < mip; i++) reduced = halvePlate(reduced);
|
|
1860
|
+
const built = buildSamples(reduced, 2 ** mip, plate.width / 2, plate.height / 2, SEARCH_SAMPLES);
|
|
1861
|
+
samplesCache.set(key, built);
|
|
1862
|
+
return built;
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1865
|
+
for (let pass = 0; pass < passes; pass++) {
|
|
1866
|
+
if (pass === 0) {
|
|
1867
|
+
seedBones();
|
|
1868
|
+
// ⬆️ Then inward, then seed again to carry each determination down its own
|
|
1869
|
+
// subtree — and around, because determining one bone can put a NEW anchored
|
|
1870
|
+
// descendant within a rigid path of the next one up. The loop settles in one
|
|
1871
|
+
// extra round on every rig measured so far; the guard is there because a
|
|
1872
|
+
// forged skeleton is allowed to be strange, not because this is expected to
|
|
1873
|
+
// iterate.
|
|
1874
|
+
for (let round = 0; round < 4; round++) {
|
|
1875
|
+
const before = inwardBones.size;
|
|
1876
|
+
inwardStep();
|
|
1877
|
+
if (inwardBones.size === before) break;
|
|
1878
|
+
seedBones();
|
|
1879
|
+
}
|
|
1880
|
+
// An attempt recorded for a bone that a LATER round then placed — through a
|
|
1881
|
+
// determination above it — would refuse a part that is in the report with a
|
|
1882
|
+
// placement. The refusal is only for what is still unplaced.
|
|
1883
|
+
for (const name of [...inwardAttempt.keys()]) if (placedBones.has(name)) inwardAttempt.delete(name);
|
|
1884
|
+
for (const state of states) state.place = placeOf(state);
|
|
1885
|
+
}
|
|
1886
|
+
// Pass 0 seeds every chain bone on the rig's own prediction from the anchors;
|
|
1887
|
+
// every later pass seeds on the previous pass's answers, which is what makes
|
|
1888
|
+
// the frozen visible set and the answer converge on the same place.
|
|
1889
|
+
//
|
|
1890
|
+
// The inward step is NOT repeated: its determinants are the anchored bones,
|
|
1891
|
+
// which no pass moves, so the second pass would compute the same four numbers.
|
|
1892
|
+
|
|
1893
|
+
for (const name of boneOrder) {
|
|
1894
|
+
const bone = bones.get(name);
|
|
1895
|
+
if (bone === undefined || unsupported.has(name) || anchorForBone.has(name) || inwardBones.has(name)) continue;
|
|
1896
|
+
const parentPlace = bone.parent === undefined ? undefined : placedBones.get(bone.parent);
|
|
1897
|
+
if (parentPlace === undefined) continue;
|
|
1898
|
+
const boneTargets = targetsOfBone.get(name) ?? [];
|
|
1899
|
+
const stretchFree = stretchEverywhere || (keyed.get(name)?.has('scale') ?? false);
|
|
1900
|
+
const stretchBounds = stretchFree ? { min: 1 / stretchRatio, max: stretchRatio } : { min: 1, max: 1 };
|
|
1901
|
+
|
|
1902
|
+
// The occluders do not move while this bone is fitted, so one snapshot per
|
|
1903
|
+
// bone serves both the seed below and the freeze after it.
|
|
1904
|
+
const covers = coversFor(boneTargets.map((s) => indexOfState.get(s) ?? 0));
|
|
1905
|
+
|
|
1906
|
+
/**
|
|
1907
|
+
* Freeze the bone's parts where they currently sit, and report how much of
|
|
1908
|
+
* the bone that leaves scoreable.
|
|
1909
|
+
*
|
|
1910
|
+
* ⭐ Frozen HERE: the visible set is decided once, before the search, and
|
|
1911
|
+
* the search cannot change it. A denominator the search can shrink turns
|
|
1912
|
+
* "explain these pixels" into "cover yourself up", and the cheapest move is
|
|
1913
|
+
* then to slide behind the occluder until a handful of agreeing pixels are
|
|
1914
|
+
* all that is scored — measured, that produced parts reporting an 8–20%
|
|
1915
|
+
* visible share with a confident residual at rotations 100–140° out on an
|
|
1916
|
+
* upright stance.
|
|
1917
|
+
*/
|
|
1918
|
+
const freezeHere = (): { targets: Target[]; visible: number; whole: number } => {
|
|
1919
|
+
const targets: Target[] = [];
|
|
1920
|
+
let visible = 0;
|
|
1921
|
+
let whole = 0;
|
|
1922
|
+
for (const state of boneTargets) {
|
|
1923
|
+
const cover = covers.get(indexOfState.get(state) ?? -1);
|
|
1924
|
+
if (state.plate === null || cover === undefined || state.place === null) continue;
|
|
1925
|
+
const set = visibleMask(state.plate, state.place, cover, frame.width, frame.height);
|
|
1926
|
+
state.frozen = set;
|
|
1927
|
+
visible += set.weight;
|
|
1928
|
+
whole += set.total;
|
|
1929
|
+
const samples = samplesFor(state, state.plate, state.place.scale);
|
|
1930
|
+
const kept = keptSamples(samples, set.keep, state.plate);
|
|
1931
|
+
targets.push({
|
|
1932
|
+
geometry: state.drawn.geometry,
|
|
1933
|
+
plate: state.plate,
|
|
1934
|
+
samples,
|
|
1935
|
+
flags: kept.flags,
|
|
1936
|
+
weight: kept.weight,
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
return { targets, visible, whole };
|
|
1940
|
+
};
|
|
1941
|
+
|
|
1942
|
+
/** The same parts with nothing masked out — every pixel of them scoreable. */
|
|
1943
|
+
const bareTargets = (): Target[] => {
|
|
1944
|
+
const out: Target[] = [];
|
|
1945
|
+
for (const state of boneTargets) {
|
|
1946
|
+
if (state.plate === null || state.place === null) continue;
|
|
1947
|
+
const samples = samplesFor(state, state.plate, state.place.scale);
|
|
1948
|
+
out.push({
|
|
1949
|
+
geometry: state.drawn.geometry,
|
|
1950
|
+
plate: state.plate,
|
|
1951
|
+
samples,
|
|
1952
|
+
flags: new Uint8Array(samples.count).fill(1),
|
|
1953
|
+
weight: samples.weight,
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
return out;
|
|
1957
|
+
};
|
|
1958
|
+
|
|
1959
|
+
/** The best hinge on one set of targets, found by sweeping and refining. */
|
|
1960
|
+
const searchOver = (targets: Target[]): HingeCandidate[] => {
|
|
1961
|
+
const seeds: HingeCandidate[] = [];
|
|
1962
|
+
for (const stretch of stretchLadder(stretchBounds)) {
|
|
1963
|
+
const rung: HingeCandidate[] = hinges.map((hingeDeg) => ({
|
|
1964
|
+
hingeDeg,
|
|
1965
|
+
stretch,
|
|
1966
|
+
residual: boneResidual(
|
|
1967
|
+
childPlace(parentPlace, bone, hingeDeg, stretch),
|
|
1968
|
+
targets,
|
|
1969
|
+
level,
|
|
1970
|
+
material.plate,
|
|
1971
|
+
false,
|
|
1972
|
+
),
|
|
1973
|
+
}));
|
|
1974
|
+
seeds.push(...hingeMinima(rung, wraps).slice(0, MINIMA_PER_RUNG));
|
|
1975
|
+
}
|
|
1976
|
+
seeds.sort((a, b) => a.residual - b.residual);
|
|
1977
|
+
return seeds
|
|
1978
|
+
.slice(0, POLISH_CANDIDATES)
|
|
1979
|
+
.map((seed) =>
|
|
1980
|
+
polishHinge(seed, targets, level, material.plate, parentPlace, bone, stretchBounds, {
|
|
1981
|
+
min: hingeMin,
|
|
1982
|
+
max: hingeMax,
|
|
1983
|
+
wraps,
|
|
1984
|
+
}),
|
|
1985
|
+
)
|
|
1986
|
+
.sort((a, b) => a.residual - b.residual);
|
|
1987
|
+
};
|
|
1988
|
+
|
|
1989
|
+
/** Move the bone, and its parts with it. */
|
|
1990
|
+
const put = (cand: HingeCandidate): void => {
|
|
1991
|
+
placedBones.set(name, childPlace(parentPlace, bone, cand.hingeDeg, cand.stretch));
|
|
1992
|
+
for (const state of boneTargets) state.place = placeOf(state);
|
|
1993
|
+
};
|
|
1994
|
+
|
|
1995
|
+
// What to fall back to, saved before anything moves. On pass 0 this is the
|
|
1996
|
+
// rig's own prediction from the anchor; on every later pass it is the
|
|
1997
|
+
// previous pass's answer, and either is a better fallback than resetting the
|
|
1998
|
+
// hinge to zero — which would throw away a relocation the first pass found.
|
|
1999
|
+
const seedBone = placedBones.get(name);
|
|
2000
|
+
const seedPlaces = boneTargets.map((st) => st.place);
|
|
2001
|
+
const restoreSeed = (): void => {
|
|
2002
|
+
if (seedBone !== undefined) placedBones.set(name, seedBone);
|
|
2003
|
+
boneTargets.forEach((st, i) => {
|
|
2004
|
+
st.place = seedPlaces[i];
|
|
2005
|
+
});
|
|
2006
|
+
};
|
|
2007
|
+
|
|
2008
|
+
let frozenHere = freezeHere();
|
|
2009
|
+
let relocated = false;
|
|
2010
|
+
// 🚨 A part the RIG predicts is invisible would otherwise never be looked
|
|
2011
|
+
// for. The seed of the first pass is the candidate's own setup, and a setup
|
|
2012
|
+
// pose routinely hides a limb the frame shows — a spineboy setup has both
|
|
2013
|
+
// arms hanging down the body, so the thigh's visible set freezes at 4% and a
|
|
2014
|
+
// search over 202 pixels lands 78° out. So a bone whose frozen share is
|
|
2015
|
+
// under the floor gets ONE unmasked look first — `pose`'s own question,
|
|
2016
|
+
// restricted to this arc — purely to decide WHERE to freeze. The answer is
|
|
2017
|
+
// then re-searched with the mask in place, so nothing is ever ranked on a
|
|
2018
|
+
// denominator the search could shrink.
|
|
2019
|
+
if (frozenHere.visible < minVisible * frozenHere.whole) {
|
|
2020
|
+
const bare = bareTargets();
|
|
2021
|
+
if (bare.some((t) => t.weight > 0)) {
|
|
2022
|
+
const wasVisible = frozenHere.visible;
|
|
2023
|
+
const relocation = searchOver(bare);
|
|
2024
|
+
if (relocation.length > 0) {
|
|
2025
|
+
put(relocation[0]);
|
|
2026
|
+
const after = freezeHere();
|
|
2027
|
+
// ⚠️ Kept only if it actually helped. An unmasked look at a part that
|
|
2028
|
+
// is genuinely behind something reads the occluder and can land
|
|
2029
|
+
// somewhere even more covered than the rig's guess; taking that would
|
|
2030
|
+
// trade a useful fallback — "the chain put it here" — for a wandered
|
|
2031
|
+
// number, and the refusal below would then name a placement nobody
|
|
2032
|
+
// has a reason to believe.
|
|
2033
|
+
if (after.visible > wasVisible) {
|
|
2034
|
+
frozenHere = after;
|
|
2035
|
+
relocated = true;
|
|
2036
|
+
for (const state of boneTargets) {
|
|
2037
|
+
state.link = { hingeDeg: relocation[0].hingeDeg, stretch: relocation[0].stretch };
|
|
2038
|
+
}
|
|
2039
|
+
} else {
|
|
2040
|
+
restoreSeed();
|
|
2041
|
+
frozenHere = freezeHere();
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// ⚠️ Still under the floor after that look: nothing more is searched, and
|
|
2048
|
+
// what gets reported is where the chain put it. The honest answer there is
|
|
2049
|
+
// "the chain put it here and the pixels could not confirm it", which is what
|
|
2050
|
+
// the `occluded` refusal says — and the placement is printed anyway.
|
|
2051
|
+
if (frozenHere.targets.every((t) => t.weight <= 0) || frozenHere.visible < minVisible * frozenHere.whole) {
|
|
2052
|
+
for (const state of boneTargets) {
|
|
2053
|
+
state.role = 'chain';
|
|
2054
|
+
state.alternates = [];
|
|
2055
|
+
state.relocated = relocated;
|
|
2056
|
+
// The seed is already what is placed — the relocation either stuck or
|
|
2057
|
+
// was undone — so all this settles is how it gets described.
|
|
2058
|
+
state.link = state.link ?? { hingeDeg: 0, stretch: 1 };
|
|
2059
|
+
}
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
const polished = searchOver(frozenHere.targets);
|
|
2064
|
+
const distinct: HingeCandidate[] = [];
|
|
2065
|
+
for (const cand of polished) {
|
|
2066
|
+
const same = distinct.some(
|
|
2067
|
+
(d) =>
|
|
2068
|
+
Math.abs(normaliseDegrees(d.hingeDeg - cand.hingeDeg)) <= AMBIGUITY_HINGE_DEG &&
|
|
2069
|
+
Math.abs(Math.log(d.stretch / cand.stretch)) <= Math.log(1.03),
|
|
2070
|
+
);
|
|
2071
|
+
if (!same) distinct.push(cand);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
put(distinct[0]);
|
|
2075
|
+
for (const state of boneTargets) {
|
|
2076
|
+
state.role = 'chain';
|
|
2077
|
+
state.link = { hingeDeg: distinct[0].hingeDeg, stretch: distinct[0].stretch };
|
|
2078
|
+
state.relocated = relocated;
|
|
2079
|
+
state.alternates =
|
|
2080
|
+
state.plate === null
|
|
2081
|
+
? []
|
|
2082
|
+
: distinct.slice(1, 1 + MAX_ALTERNATES).map((cand) => ({
|
|
2083
|
+
place: partPlaceOf(
|
|
2084
|
+
childPlace(parentPlace, bone, cand.hingeDeg, cand.stretch),
|
|
2085
|
+
state.drawn.geometry,
|
|
2086
|
+
(state.plate as Plate).width,
|
|
2087
|
+
),
|
|
2088
|
+
link: { hingeDeg: cand.hingeDeg, stretch: cand.stretch },
|
|
2089
|
+
}));
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// Anchored parts keep the anchor's own placement — nothing here re-fits it.
|
|
2094
|
+
// The LINK is still derived where the parent is placed, because "what local
|
|
2095
|
+
// rotation does this anchor imply" is a number the caller wants and the chain
|
|
2096
|
+
// above it can answer.
|
|
2097
|
+
//
|
|
2098
|
+
// ⬆️ An INWARD part goes through the same block for the same reason: nothing
|
|
2099
|
+
// fitted it either, its placement is wherever the determination put its bone,
|
|
2100
|
+
// and its `hingeDeg` exists only if the bone above it happens to be placed —
|
|
2101
|
+
// which, for a bone the inward step reached, it is not. So the link comes back
|
|
2102
|
+
// `null` and the report says the quantity does not exist rather than printing
|
|
2103
|
+
// a zero.
|
|
2104
|
+
for (const state of states) {
|
|
2105
|
+
if (state.plate === null) continue;
|
|
2106
|
+
const anchor = anchorForBone.get(state.drawn.bone);
|
|
2107
|
+
const determined = inwardBones.has(state.drawn.bone);
|
|
2108
|
+
if (anchor === undefined && !determined) continue;
|
|
2109
|
+
const bonePlace = placedBones.get(state.drawn.bone);
|
|
2110
|
+
const bone = bones.get(state.drawn.bone);
|
|
2111
|
+
state.role = anchor === undefined ? 'inward' : 'anchor';
|
|
2112
|
+
state.isAnchorSource = anchor !== undefined && anchor.state === state;
|
|
2113
|
+
state.place = state.isAnchorSource && anchor !== undefined ? anchor.entry.place : placeOf(state);
|
|
2114
|
+
const parentPlace =
|
|
2115
|
+
bone === undefined || bone.parent === undefined ? undefined : placedBones.get(bone.parent);
|
|
2116
|
+
state.link =
|
|
2117
|
+
bone !== undefined && bonePlace !== undefined && parentPlace !== undefined
|
|
2118
|
+
? linkOf(parentPlace, bonePlace, bone)
|
|
2119
|
+
: null;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// What each part shows WHERE IT LANDED, as opposed to where its visible set was
|
|
2124
|
+
// frozen. One reverse-draw-order sweep over the fitted placements, and it does
|
|
2125
|
+
// double duty: it is `visibleShareAtFit` for a fitted part, and it IS the frozen
|
|
2126
|
+
// set for a part nothing searched — an anchor's placement is its own seed, so
|
|
2127
|
+
// "frozen at the seed" and "measured where it landed" are the same set there.
|
|
2128
|
+
const shareAtFit = new Map<string, number>();
|
|
2129
|
+
{
|
|
2130
|
+
const cover = new Uint8Array(frame.width * frame.height);
|
|
2131
|
+
for (let i = states.length - 1; i >= 0; i--) {
|
|
2132
|
+
const state = states[i];
|
|
2133
|
+
if (state.plate === null || state.place === null) continue;
|
|
2134
|
+
const set = visibleMask(state.plate, state.place, cover, frame.width, frame.height);
|
|
2135
|
+
shareAtFit.set(state.drawn.slot, set.total > 0 ? set.weight / set.total : 0);
|
|
2136
|
+
if (state.frozen === null) state.frozen = set;
|
|
2137
|
+
stampCover(cover, frame.width, frame.height, state.plate, state.place);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// --- the report ----------------------------------------------------------
|
|
2142
|
+
const report: ChainFitReport = {
|
|
2143
|
+
spec: CHAINFIT_SPEC,
|
|
2144
|
+
space:
|
|
2145
|
+
'frame pixels, y down, origin top-left. (x, y) is where the part image\'s own centre lands; rotationDeg is ' +
|
|
2146
|
+
'screen degrees, positive clockwise; scale is frame pixels per part pixel. Reconstruct a part pixel p as ' +
|
|
2147
|
+
'centre + scale * R(rotationDeg) * (p - (width/2, height/2)) — the same contract a rigc-pose report ' +
|
|
2148
|
+
'carries. hingeDeg and localRotationDeg are SPINE degrees (CCW, y up) instead, because they are timeline ' +
|
|
2149
|
+
'values: what a rotate key would carry. src/transform.ts converts between the two (screenToSpineDegrees, ' +
|
|
2150
|
+
'cropToSpineY).',
|
|
2151
|
+
candidate: {
|
|
2152
|
+
skeleton: skeletonPath,
|
|
2153
|
+
skins: skel.skins.map((s) => s.name),
|
|
2154
|
+
bones: skel.bones.length,
|
|
2155
|
+
slots: skel.slots.length,
|
|
2156
|
+
drawn: drawn.length,
|
|
2157
|
+
drawOrder: drawn.map((d) => d.slot),
|
|
2158
|
+
},
|
|
2159
|
+
images: imagesDir,
|
|
2160
|
+
frame: { path: framePath, width: frame.width, height: frame.height, background },
|
|
2161
|
+
anchor: {
|
|
2162
|
+
source: anchorSource,
|
|
2163
|
+
path: options.anchorPath === undefined ? null : resolve(options.anchorPath),
|
|
2164
|
+
criterion: { ...criterion, requireUnambiguous: true },
|
|
2165
|
+
anchored: [...anchorForBone.values()].map((a) => basename(a.state.path)).sort(),
|
|
2166
|
+
},
|
|
2167
|
+
inward: {
|
|
2168
|
+
determined: boneOrder.filter((name) => inwardBones.has(name)),
|
|
2169
|
+
criterion: {
|
|
2170
|
+
minDeterminants: INWARD_MIN_DETERMINANTS,
|
|
2171
|
+
minLeverPx,
|
|
2172
|
+
determinantsMustBeAnchored: true,
|
|
2173
|
+
},
|
|
2174
|
+
},
|
|
2175
|
+
search: {
|
|
2176
|
+
hinge: { minDeg: hingeMin, maxDeg: hingeMax, stepDeg: hingeStep, steps: hinges.length },
|
|
2177
|
+
stretch: {
|
|
2178
|
+
ratio: stretchRatio,
|
|
2179
|
+
steps: STRETCH_STEPS,
|
|
2180
|
+
freeFrom: stretchEverywhere
|
|
2181
|
+
? 'every bone, because --stretch was named'
|
|
2182
|
+
: "the candidate's own bone `scale` timelines",
|
|
2183
|
+
},
|
|
2184
|
+
minVisible,
|
|
2185
|
+
maxResidual,
|
|
2186
|
+
ambiguity: { absolute: AMBIGUITY_ABSOLUTE, relative: AMBIGUITY_RELATIVE, hingeDeg: AMBIGUITY_HINGE_DEG },
|
|
2187
|
+
passes,
|
|
2188
|
+
occluderAlpha: OCCLUDER_ALPHA,
|
|
2189
|
+
},
|
|
2190
|
+
caveats: [
|
|
2191
|
+
'No number here is a score and none of them has a pass bar. This reads a given condition — the pose you ' +
|
|
2192
|
+
'were handed — into spec coordinates an agent then states by construction. The residual and ' +
|
|
2193
|
+
'`visibleShare` say how far to trust a placement and where two answers are equally good.',
|
|
2194
|
+
'Every residual is over the part\'s VISIBLE pixels: what nothing drawn after it in the candidate\'s own ' +
|
|
2195
|
+
'setup draw order covered. That is the whole difference from `rigc pose`, whose residuals are charged for ' +
|
|
2196
|
+
'the occluder — so the two are NOT the same number on an occluded part, and this one always has to be read ' +
|
|
2197
|
+
'next to `visibleShare`. A low residual on a 0.08 share is a confident statement about a sliver.',
|
|
2198
|
+
'The occlusion is the CANDIDATE\'s, not the picture\'s, and so is the geometry. A wrong draw order masks the ' +
|
|
2199
|
+
'wrong pixels, and a joint offset the rig gets wrong moves the pivot every hinge below it turns about — an ' +
|
|
2200
|
+
'answer here is only as good as the structure it was read through. `pivotDisagreementPx` on an anchored ' +
|
|
2201
|
+
'bone is the one direct measurement of that: how far the rig\'s own prediction of the joint sits from ' +
|
|
2202
|
+
'where the anchor found it.',
|
|
2203
|
+
'Setup draw order, on one frame. A `drawOrder` timeline reorders the slots at runtime and this cannot know ' +
|
|
2204
|
+
'the time, so a candidate that has one is masked in the order its setup pose declares.',
|
|
2205
|
+
'The hinge is searched; the pivot is NOT. A bone the candidate keys a `translate` timeline on carries ' +
|
|
2206
|
+
'`dof.pivotFree`, which means the arc this answer sits on has a centre the rig itself moves — the ' +
|
|
2207
|
+
'placement is still read off pixels, but `localRotationDeg` alone will not reproduce it.',
|
|
2208
|
+
'The walk goes OUTWARD from a trunk, so a limb with no trusted part on it or above it is refused rather ' +
|
|
2209
|
+
'than guessed at. An anchor fixes its own bone completely; it says nothing about what the link above it ' +
|
|
2210
|
+
'did, so nothing above a SINGLE anchor is recoverable from it — that is `no-anchor` and `no-bracket`.',
|
|
2211
|
+
'The INWARD step is the one exception and it is one shape: a bone with two or more ANCHORED descendants on ' +
|
|
2212
|
+
"different sub-chains. A descendant's pivot depends on this bone and on the rig's own offsets and NOT on " +
|
|
2213
|
+
'the descendant\'s own hinge, so each one supplies two of the four numbers a placement is. Which means it ' +
|
|
2214
|
+
'reaches exactly the bones that BRANCH: a bone with one child sub-chain is `no-bracket` however good the ' +
|
|
2215
|
+
'anchor below it is. `inward.determined` names what it reached.',
|
|
2216
|
+
'An `inward` placement is NOT a measurement of the bone it places. Its evidence is the anchors below it, so ' +
|
|
2217
|
+
'what prices it is `bone.inward.disagreementPx` read next to `bone.inward.redundancy` — and at redundancy ' +
|
|
2218
|
+
'0 the disagreement is `null`, because two determinants and four unknowns fit each other exactly whether ' +
|
|
2219
|
+
'or not the rig is right. The bone\'s own `residual` and `visibleShare` say how much of the answer the ' +
|
|
2220
|
+
'frame can independently confirm, which on a bone drawn behind its own limbs can be very little.',
|
|
2221
|
+
'An `ambiguous` part has two or more hinge answers this instrument cannot separate — a limb that explains ' +
|
|
2222
|
+
'the picture forwards and again backwards. All of them are reported and none was picked.',
|
|
2223
|
+
'A part refused `occluded` is refused because too little of it survives the parts in front of it, and the ' +
|
|
2224
|
+
'best placement found is still in `placement`: the chain put it there and the pixels did not confirm it. ' +
|
|
2225
|
+
'A refusal names why not to trust a number; it does not hide it.',
|
|
2226
|
+
'An ANCHOR can be refused too, and it is not a contradiction: the anchor pass judged the placement over ' +
|
|
2227
|
+
"the part's WHOLE footprint — all `pose` can see, and blind to what covers it — while this instrument has " +
|
|
2228
|
+
'measured how much of the part is visible at all. A refused anchor means the placement may well be right ' +
|
|
2229
|
+
'and the CONFIRMATION is missing, and every part whose `anchoredTo` names that bone rests on it. ' +
|
|
2230
|
+
'`anchorVerdict` carries the pass\'s own numbers so the two readings can be compared rather than merged.',
|
|
2231
|
+
],
|
|
2232
|
+
parts: [],
|
|
2233
|
+
};
|
|
2234
|
+
|
|
2235
|
+
if (undrawn.length > 0) {
|
|
2236
|
+
report.caveats.push(
|
|
2237
|
+
`${undrawn.length} slot(s) hold no region this run could resolve in the setup pose, so they were neither ` +
|
|
2238
|
+
`placed nor treated as occluders: ${undrawn.join(', ')}.`,
|
|
2239
|
+
);
|
|
2240
|
+
}
|
|
2241
|
+
for (const { slot, why } of blockedSlots) {
|
|
2242
|
+
report.caveats.push(`slot "${slot}" was neither placed nor treated as an occluder: ${why}.`);
|
|
2243
|
+
}
|
|
2244
|
+
const unplacedOccluders = states.filter((s) => s.plate === null || s.place === null).map((s) => s.drawn.slot);
|
|
2245
|
+
if (unplacedOccluders.length > 0) {
|
|
2246
|
+
report.caveats.push(
|
|
2247
|
+
`${unplacedOccluders.length} drawn slot(s) could not be placed, so they masked nothing and every part ` +
|
|
2248
|
+
`behind them is reported MORE visible than the picture shows: ${unplacedOccluders.join(', ')}.`,
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
if (Object.values(skel.animations ?? {}).some((a) => Array.isArray(a.drawOrder) && a.drawOrder.length > 0)) {
|
|
2252
|
+
report.caveats.push(
|
|
2253
|
+
'the candidate carries a `drawOrder` timeline, so the order the masks were built in is its setup order and ' +
|
|
2254
|
+
'not necessarily the order this frame was drawn in.',
|
|
2255
|
+
);
|
|
2256
|
+
}
|
|
2257
|
+
if (skel.constraints !== undefined && skel.constraints.length > 0) {
|
|
2258
|
+
report.caveats.push(
|
|
2259
|
+
`the candidate declares ${skel.constraints.length} constraint(s) (${skel.constraints
|
|
2260
|
+
.map((c) => `${c.name}:${c.type}`)
|
|
2261
|
+
.join(', ')}). A constraint moves bones after their local transforms are composed, so a fitted ` +
|
|
2262
|
+
'`localRotationDeg` here is a placement, not necessarily a value you can key and reproduce.',
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
if (report.inward.determined.length > 0) {
|
|
2267
|
+
report.caveats.push(
|
|
2268
|
+
`${report.inward.determined.length} bone(s) were determined INWARD rather than read or fitted ` +
|
|
2269
|
+
`(${report.inward.determined.join(', ')}), and every placement whose \`anchoredToRole\` is "inward" hangs ` +
|
|
2270
|
+
'off one of them. The determinants are anchor-pass-eligible bones, so the caveat about a refused anchor ' +
|
|
2271
|
+
'applies to them too — a determinant this instrument can barely see is still a determinant.',
|
|
2272
|
+
);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
const ctx: FinishContext = {
|
|
2276
|
+
bones,
|
|
2277
|
+
keyed,
|
|
2278
|
+
placedBones,
|
|
2279
|
+
depthOf,
|
|
2280
|
+
anchoredTo,
|
|
2281
|
+
carried,
|
|
2282
|
+
pivotDisagreement,
|
|
2283
|
+
targetsOfBone,
|
|
2284
|
+
anchorForBone,
|
|
2285
|
+
anchors,
|
|
2286
|
+
inwardBones,
|
|
2287
|
+
inwardAttempt,
|
|
2288
|
+
shareAtFit,
|
|
2289
|
+
level,
|
|
2290
|
+
material: material.plate,
|
|
2291
|
+
hinge: { minDeg: hingeMin, maxDeg: hingeMax, stepDeg: hingeStep },
|
|
2292
|
+
stretchRatio,
|
|
2293
|
+
stretchEverywhere,
|
|
2294
|
+
minVisible,
|
|
2295
|
+
maxResidual,
|
|
2296
|
+
};
|
|
2297
|
+
for (const state of states) report.parts.push(finishPart(state, ctx));
|
|
2298
|
+
return report;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
interface FinishContext {
|
|
2302
|
+
bones: Map<string, SpineBone>;
|
|
2303
|
+
keyed: Map<string, Set<string>>;
|
|
2304
|
+
placedBones: Map<string, BonePlace>;
|
|
2305
|
+
depthOf: Map<string, number>;
|
|
2306
|
+
anchoredTo: Map<string, string>;
|
|
2307
|
+
carried: Map<string, string[]>;
|
|
2308
|
+
pivotDisagreement: Map<string, number>;
|
|
2309
|
+
targetsOfBone: Map<string, PartState[]>;
|
|
2310
|
+
anchorForBone: Map<string, { state: PartState; entry: AnchorEntry }>;
|
|
2311
|
+
anchors: Map<string, AnchorEntry>;
|
|
2312
|
+
inwardBones: Map<string, ChainFitInwardView>;
|
|
2313
|
+
inwardAttempt: Map<string, string>;
|
|
2314
|
+
shareAtFit: Map<string, number>;
|
|
2315
|
+
level: Level;
|
|
2316
|
+
material: Plate;
|
|
2317
|
+
hinge: { minDeg: number; maxDeg: number; stepDeg: number };
|
|
2318
|
+
stretchRatio: number;
|
|
2319
|
+
stretchEverywhere: boolean;
|
|
2320
|
+
minVisible: number;
|
|
2321
|
+
maxResidual: number;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
function finishPart(state: PartState, ctx: FinishContext): ChainFitPart {
|
|
2325
|
+
const name = basename(state.path);
|
|
2326
|
+
const bone = ctx.bones.get(state.drawn.bone);
|
|
2327
|
+
const anchored = ctx.anchorForBone.has(state.drawn.bone);
|
|
2328
|
+
const determined = ctx.inwardBones.get(state.drawn.bone) ?? null;
|
|
2329
|
+
const stretchFree = ctx.stretchEverywhere || (ctx.keyed.get(state.drawn.bone)?.has('scale') ?? false);
|
|
2330
|
+
const trunk = ctx.anchoredTo.get(state.drawn.bone) ?? null;
|
|
2331
|
+
const view: ChainFitBoneView = {
|
|
2332
|
+
name: state.drawn.bone,
|
|
2333
|
+
parent: bone?.parent ?? null,
|
|
2334
|
+
setupRotationDeg: num(bone?.rotation, 0),
|
|
2335
|
+
depth: ctx.depthOf.get(state.drawn.bone) ?? -1,
|
|
2336
|
+
anchoredTo: trunk,
|
|
2337
|
+
anchoredToRole: trunk === null ? null : ctx.inwardBones.has(trunk) ? 'inward' : 'anchor',
|
|
2338
|
+
inward: determined,
|
|
2339
|
+
dof: {
|
|
2340
|
+
// Neither an anchor nor a determination searched anything, so neither
|
|
2341
|
+
// reports a searched degree of freedom. `dof` is what was SEARCHED, not
|
|
2342
|
+
// what the rig leaves free.
|
|
2343
|
+
rotation: !anchored && determined === null,
|
|
2344
|
+
stretch: stretchFree && !anchored && determined === null,
|
|
2345
|
+
pivotFree: ctx.keyed.get(state.drawn.bone)?.has('translate') ?? false,
|
|
2346
|
+
},
|
|
2347
|
+
window: {
|
|
2348
|
+
hingeMinDeg: ctx.hinge.minDeg,
|
|
2349
|
+
hingeMaxDeg: ctx.hinge.maxDeg,
|
|
2350
|
+
hingeStepDeg: ctx.hinge.stepDeg,
|
|
2351
|
+
stretchMin: stretchFree ? roundTo(1 / ctx.stretchRatio, 5) : 1,
|
|
2352
|
+
stretchMax: stretchFree ? ctx.stretchRatio : 1,
|
|
2353
|
+
},
|
|
2354
|
+
sharedWith: (ctx.targetsOfBone.get(state.drawn.bone) ?? [])
|
|
2355
|
+
.filter((s) => s !== state)
|
|
2356
|
+
.map((s) => basename(s.path))
|
|
2357
|
+
.sort(),
|
|
2358
|
+
carriedBones: ctx.carried.get(state.drawn.bone) ?? [],
|
|
2359
|
+
pivotDisagreementPx: ctx.pivotDisagreement.has(state.drawn.bone)
|
|
2360
|
+
? roundTo(ctx.pivotDisagreement.get(state.drawn.bone) as number, 3)
|
|
2361
|
+
: null,
|
|
2362
|
+
};
|
|
2363
|
+
const out: ChainFitPart = {
|
|
2364
|
+
part: name,
|
|
2365
|
+
path: state.path,
|
|
2366
|
+
slot: state.drawn.slot,
|
|
2367
|
+
attachment: state.drawn.attachment,
|
|
2368
|
+
width: state.plate?.width ?? 0,
|
|
2369
|
+
height: state.plate?.height ?? 0,
|
|
2370
|
+
role: state.role,
|
|
2371
|
+
bone: view,
|
|
2372
|
+
refusal: state.blocked,
|
|
2373
|
+
placement: null,
|
|
2374
|
+
alternates: [],
|
|
2375
|
+
ambiguous: false,
|
|
2376
|
+
anchorVerdict: ctx.anchors.get(name)?.verdict ?? null,
|
|
2377
|
+
notes: [],
|
|
2378
|
+
};
|
|
2379
|
+
if (state.blocked !== null) {
|
|
2380
|
+
out.notes.push(`${name} was not searched: ${state.blocked.detail}.`);
|
|
2381
|
+
return out;
|
|
2382
|
+
}
|
|
2383
|
+
const plate = state.plate as Plate;
|
|
2384
|
+
const frozen = state.frozen;
|
|
2385
|
+
if (state.place === null || frozen === null || !ctx.placedBones.has(state.drawn.bone)) {
|
|
2386
|
+
out.role = 'unplaced';
|
|
2387
|
+
// ⭐ Two refusals, and which one it is says where the repair lives. The inward
|
|
2388
|
+
// step recorded an attempt exactly when it found something anchored below this
|
|
2389
|
+
// bone and could not use it — so an attempt present means "there WAS evidence
|
|
2390
|
+
// down there", and a caller reads a different sentence and takes a different
|
|
2391
|
+
// action than the one who has nothing anywhere on the limb.
|
|
2392
|
+
const attempt = ctx.inwardAttempt.get(state.drawn.bone);
|
|
2393
|
+
if (attempt !== undefined) {
|
|
2394
|
+
out.refusal = { reason: 'no-bracket', detail: `${name}: ${attempt}` };
|
|
2395
|
+
out.notes.push(
|
|
2396
|
+
`${name} was not placed, and not for want of an anchor: something below it WAS trusted. Two anchored ` +
|
|
2397
|
+
'descendants on different sub-chains would determine this bone outright — so the repair is either one ' +
|
|
2398
|
+
'more anchor on a sub-chain that has none, or a rig whose topology branches here at all. A single ' +
|
|
2399
|
+
'anchored descendant, however good, carries nothing about the link above it.',
|
|
2400
|
+
);
|
|
2401
|
+
return out;
|
|
2402
|
+
}
|
|
2403
|
+
out.refusal = {
|
|
2404
|
+
reason: 'no-anchor',
|
|
2405
|
+
detail:
|
|
2406
|
+
`${name} hangs off bone "${state.drawn.bone}", which has no placed ancestor and no anchored descendant: ` +
|
|
2407
|
+
'no part on it, above it or below it came back from the anchor pass inside the anchor criterion, so there ' +
|
|
2408
|
+
'was nothing to walk a chain from and nothing to determine it inward from either',
|
|
2409
|
+
};
|
|
2410
|
+
out.notes.push(
|
|
2411
|
+
`${name} was not placed. A chain needs a trunk — supply --anchor with a report that reads at least one part ` +
|
|
2412
|
+
'of this limb or above it, or loosen --anchor-residual. Nothing above a single anchor is recoverable from ' +
|
|
2413
|
+
'it, so one trusted part further out does not help this one; TWO of them on different sub-chains below ' +
|
|
2414
|
+
'this bone would, and that is the inward step.',
|
|
2415
|
+
);
|
|
2416
|
+
return out;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
const stats = measure(plate, state.place, frozen, ctx.level, ctx.material);
|
|
2420
|
+
const atFit = ctx.shareAtFit.get(state.drawn.slot) ?? stats.visibleShare;
|
|
2421
|
+
const placement = toPlacement(state.place, state.link, view.setupRotationDeg, stats, atFit);
|
|
2422
|
+
out.placement = placement;
|
|
2423
|
+
|
|
2424
|
+
const margin = Math.max(AMBIGUITY_ABSOLUTE, placement.residual * AMBIGUITY_RELATIVE);
|
|
2425
|
+
out.alternates = state.alternates
|
|
2426
|
+
.map((alt) =>
|
|
2427
|
+
toPlacement(alt.place, alt.link, view.setupRotationDeg, measure(plate, alt.place, frozen, ctx.level, ctx.material), atFit),
|
|
2428
|
+
)
|
|
2429
|
+
.filter((alt) => alt.residual - placement.residual <= margin)
|
|
2430
|
+
.slice(0, MAX_ALTERNATES);
|
|
2431
|
+
out.ambiguous = out.alternates.length > 0;
|
|
2432
|
+
if (out.ambiguous) {
|
|
2433
|
+
out.notes.push(
|
|
2434
|
+
`${name} has ${out.alternates.length + 1} hinge answers within ${roundTo(margin, 4)} residual of each other ` +
|
|
2435
|
+
'— all of them are reported and none was picked. A limb that explains the picture forwards and again ' +
|
|
2436
|
+
'backwards looks exactly like this.',
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
if (out.role === 'anchor') {
|
|
2441
|
+
out.notes.push(
|
|
2442
|
+
`${name} is an ANCHOR: this placement is the anchor pass's own answer, taken because it cleared the anchor ` +
|
|
2443
|
+
'criterion, and nothing here re-fitted it. Its residual and visible share are this instrument\'s, measured ' +
|
|
2444
|
+
'over the masked pixels.',
|
|
2445
|
+
);
|
|
2446
|
+
if (view.pivotDisagreementPx !== null && view.pivotDisagreementPx > 2) {
|
|
2447
|
+
out.notes.push(
|
|
2448
|
+
`the chain above it predicts this bone's pivot ${view.pivotDisagreementPx} px from where the anchor put ` +
|
|
2449
|
+
"it — the candidate's own joint offset and the picture disagree by that much.",
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2452
|
+
} else if (out.role === 'inward' && view.inward !== null) {
|
|
2453
|
+
const inward = view.inward;
|
|
2454
|
+
out.notes.push(
|
|
2455
|
+
`${name} is on a bone this run determined INWARD: nothing searched it and the anchor pass never saw it. ` +
|
|
2456
|
+
`Its four numbers come from ${inward.determinants.length} anchored descendant(s) — ` +
|
|
2457
|
+
`${inward.determinants.map((d) => `${d.bone} (${d.part})`).join(', ')} — read across a ` +
|
|
2458
|
+
`${inward.leverPx} px lever, with ${inward.redundancy} surplus equation(s).`,
|
|
2459
|
+
);
|
|
2460
|
+
out.notes.push(
|
|
2461
|
+
inward.disagreementPx === null
|
|
2462
|
+
? `redundancy 0, so \`disagreementPx\` is null rather than zero: ${inward.determinants.length} determinants ` +
|
|
2463
|
+
'supply exactly the four numbers a placement is, and a solve with nothing left over fits its own points ' +
|
|
2464
|
+
'exactly whether or not the rig is right. One more anchored descendant on a third sub-chain is what ' +
|
|
2465
|
+
'would make this checkable.'
|
|
2466
|
+
: `the determination disagrees with itself by ${inward.disagreementPx} px at worst ` +
|
|
2467
|
+
`(${inward.determinants.map((d) => `${d.bone} ${d.offsetPx}`).join(', ')} px): over ` +
|
|
2468
|
+
`${inward.redundancy} surplus equation(s), that is the rig's own joint offsets and the anchors below ` +
|
|
2469
|
+
'this bone disagreeing by that much. It is a rig diagnostic in the same sense as ' +
|
|
2470
|
+
'`pivotDisagreementPx`, not an error bar on the placement.',
|
|
2471
|
+
);
|
|
2472
|
+
for (const d of inward.determinants) {
|
|
2473
|
+
if (d.carried.length === 0) continue;
|
|
2474
|
+
out.notes.push(
|
|
2475
|
+
`the path to determinant "${d.bone}" runs through ${d.carried.join(', ')}, which carry nothing scoreable — ` +
|
|
2476
|
+
'their setup rotation was composed through, and this determination inherits that assumption.',
|
|
2477
|
+
);
|
|
2478
|
+
}
|
|
2479
|
+
for (const r of inward.rejected) {
|
|
2480
|
+
out.notes.push(`anchored descendant "${r.bone}" was not usable as a determinant: ${r.why}.`);
|
|
2481
|
+
}
|
|
2482
|
+
if (placement.hingeDeg === null) {
|
|
2483
|
+
out.notes.push(
|
|
2484
|
+
`"${view.name}" has no placed parent — that is why the inward step had to determine it — so \`hingeDeg\` ` +
|
|
2485
|
+
'and `localRotationDeg` are null: there is no link above it to measure a local rotation against. The ' +
|
|
2486
|
+
'placement is a world one, and `src/transform.ts` converts it.',
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
} else {
|
|
2490
|
+
const dof = view.dof.stretch ? 'a hinge and a stretch' : 'one hinge';
|
|
2491
|
+
out.notes.push(
|
|
2492
|
+
`${name} was read by walking ${view.depth} link(s) out from ${view.anchoredTo ?? 'an anchor'} and searching ` +
|
|
2493
|
+
`${dof} over ${ctx.hinge.minDeg}°…${ctx.hinge.maxDeg}° ${hingeWalkPhrase(ctx.hinge.stepDeg)} — not the four degrees of ` +
|
|
2494
|
+
'freedom `pose` has to search.',
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
if (view.anchoredToRole === 'inward' && out.role !== 'inward') {
|
|
2498
|
+
const trunk = view.anchoredTo === null ? null : ctx.inwardBones.get(view.anchoredTo);
|
|
2499
|
+
out.notes.push(
|
|
2500
|
+
`⚠️ the trunk this hangs off — "${view.anchoredTo}" — was DETERMINED inward from ` +
|
|
2501
|
+
`${trunk?.determinants.length ?? 0} anchored descendant(s), not read off the picture` +
|
|
2502
|
+
(trunk?.disagreementPx === null
|
|
2503
|
+
? ' with no surplus equation to check it against'
|
|
2504
|
+
: ` (worst self-disagreement ${trunk?.disagreementPx} px)`) +
|
|
2505
|
+
'. Every number here rests on that determination.',
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
if (state.relocated) {
|
|
2509
|
+
out.notes.push(
|
|
2510
|
+
`the rig predicted ${name} almost entirely covered, so its visible set was frozen at an UNMASKED look ` +
|
|
2511
|
+
"instead — `pose`'s own question restricted to this arc — and the masked search ran from there. The share " +
|
|
2512
|
+
'below is what that relocation left scoreable.',
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
if (view.carriedBones.length > 0) {
|
|
2516
|
+
out.notes.push(
|
|
2517
|
+
`${view.carriedBones.join(', ')} carry nothing scoreable, so their hinge could not be fitted and their setup ` +
|
|
2518
|
+
'rotation was carried through. Every number here inherits that assumption.',
|
|
2519
|
+
);
|
|
2520
|
+
}
|
|
2521
|
+
if (view.dof.pivotFree) {
|
|
2522
|
+
out.notes.push(
|
|
2523
|
+
`the candidate keys a \`translate\` timeline on "${view.name}", so the pivot this hinge turned about is ` +
|
|
2524
|
+
'itself something the rig moves; `localRotationDeg` alone will not reproduce this placement.',
|
|
2525
|
+
);
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
if (placement.visibleShare < ctx.minVisible) {
|
|
2529
|
+
// ⚠️ An ANCHOR gets this refusal too, and the wording carries why rather than
|
|
2530
|
+
// leaving a reader to reconcile two rows. The two criteria disagree on
|
|
2531
|
+
// purpose: the anchor pass judged this placement over the part's WHOLE
|
|
2532
|
+
// footprint, which is all `pose` can see and which does not know what covers
|
|
2533
|
+
// it, and this instrument has just measured that almost nothing of the part is
|
|
2534
|
+
// visible. Both are true. Suppressing the refusal because "no search happened
|
|
2535
|
+
// here" was tried and is worse — measured on the 2026-09-03 corpus it printed
|
|
2536
|
+
// `rear-bracer` as READ on 82 of 147 frames at a median visible share of 0.1%,
|
|
2537
|
+
// which is the number this floor exists to stop anybody quoting.
|
|
2538
|
+
out.refusal = {
|
|
2539
|
+
reason: 'occluded',
|
|
2540
|
+
detail:
|
|
2541
|
+
`${name}: only ${(placement.visibleShare * 100).toFixed(1)}% of it survives the parts drawn over it, ` +
|
|
2542
|
+
`below the visibility floor ${ctx.minVisible}; the residual ${placement.residual.toFixed(4)} is a ` +
|
|
2543
|
+
`statement about ${placement.scoredPixels} part pixel(s)` +
|
|
2544
|
+
(state.isAnchorSource
|
|
2545
|
+
? ` — and it is an ANCHOR, accepted by the anchor pass on its own criterion (residual ` +
|
|
2546
|
+
`${(out.anchorVerdict?.residual ?? 0).toFixed(4)}, unexplained ` +
|
|
2547
|
+
`${((out.anchorVerdict?.unexplained ?? 0) * 100).toFixed(0)}% over the part's WHOLE footprint, which ` +
|
|
2548
|
+
'cannot know what covers it), so every placement hung off it inherits this doubt'
|
|
2549
|
+
: out.role === 'inward'
|
|
2550
|
+
? ` — and it is an INWARD determination, so the residual is not what the placement rests on ` +
|
|
2551
|
+
`(that is ${view.inward?.determinants.length ?? 0} anchored descendant(s) across a ` +
|
|
2552
|
+
`${view.inward?.leverPx ?? 0} px lever). What this floor refuses is the CONFIRMATION: the frame ` +
|
|
2553
|
+
'cannot check the answer either way'
|
|
2554
|
+
: ''),
|
|
2555
|
+
};
|
|
2556
|
+
out.notes.push(
|
|
2557
|
+
state.isAnchorSource
|
|
2558
|
+
? `${name} anchors this chain and this instrument can barely see it. The placement is the anchor pass's ` +
|
|
2559
|
+
'and may well be right; what is refused is the confirmation, and everything with ' +
|
|
2560
|
+
`\`anchoredTo\` = "${view.name}" rests on it.`
|
|
2561
|
+
: out.role === 'inward'
|
|
2562
|
+
? // ⚠️ The floor is kept here deliberately, and this is the second time
|
|
2563
|
+
// that call has been made in this file: exempting a placement nothing
|
|
2564
|
+
// searched was tried for anchors in `2ff80af` and reverted in
|
|
2565
|
+
// `0ff25ea` because it prints a part nobody can see as READ. An inward
|
|
2566
|
+
// determination has a better provenance than an anchor's sliver and
|
|
2567
|
+
// the floor still applies, because the floor is about what the PICTURE
|
|
2568
|
+
// can confirm, not about how the number was obtained — and a count of
|
|
2569
|
+
// "readable" that includes bones the frame cannot check is exactly the
|
|
2570
|
+
// figure this floor exists to keep out of a table.
|
|
2571
|
+
`${name} sits on a bone that was determined inward, and this frame cannot confirm it: the geometry ` +
|
|
2572
|
+
'says where it goes and the pixels are not there to agree or disagree. The determination is in ' +
|
|
2573
|
+
'`bone.inward` and the placement is still in `placement`; what is refused is the confirmation.'
|
|
2574
|
+
: `${name} is too far behind other parts to measure on this frame. The best placement found is still in ` +
|
|
2575
|
+
'`placement`.',
|
|
2576
|
+
);
|
|
2577
|
+
} else if (placement.residual > ctx.maxResidual) {
|
|
2578
|
+
out.refusal = {
|
|
2579
|
+
reason: 'no-match',
|
|
2580
|
+
detail:
|
|
2581
|
+
`${name}: the best placement found has residual ${placement.residual.toFixed(4)} over its visible pixels, ` +
|
|
2582
|
+
`above --max-residual ${ctx.maxResidual}`,
|
|
2583
|
+
};
|
|
2584
|
+
out.notes.push(
|
|
2585
|
+
out.role === 'inward'
|
|
2586
|
+
? // No window was searched here, so the usual advice about it would be
|
|
2587
|
+
// wrong: this is the determination and the picture disagreeing, and the
|
|
2588
|
+
// determination is the thing to look at.
|
|
2589
|
+
`${name} was DETERMINED inward and its visible pixels do not agree with the frame where the ` +
|
|
2590
|
+
'determination put it. That is a real disagreement between the anchors below this bone and the ' +
|
|
2591
|
+
"candidate's own joint offsets — read `bone.inward.disagreementPx` and the determinants' own " +
|
|
2592
|
+
'`anchorVerdict`s before believing either side.'
|
|
2593
|
+
: `${name}'s visible pixels do not agree with the frame at any hinge in the window. Check the window, the ` +
|
|
2594
|
+
"candidate's joint offset for this limb, and its draw order.",
|
|
2595
|
+
);
|
|
2596
|
+
}
|
|
2597
|
+
if (Math.abs(placement.visibleShareAtFit - placement.visibleShare) > VISIBILITY_DRIFT_TOLERANCE) {
|
|
2598
|
+
out.notes.push(
|
|
2599
|
+
`the visible set was frozen where ${(placement.visibleShare * 100).toFixed(1)}% of ${name} showed and the ` +
|
|
2600
|
+
`answer landed where ${(placement.visibleShareAtFit * 100).toFixed(1)}% does — the measurement and the ` +
|
|
2601
|
+
'answer are not quite in the same place. Another --passes is the repair.',
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
if (placement.offCanvas > 0.01) {
|
|
2605
|
+
out.notes.push(
|
|
2606
|
+
`${roundTo(placement.offCanvas * 100, 1)}% of ${name}'s material falls outside the frame canvas at this placement.`,
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
return out;
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/** A part PNG, or the reason it is not one. */
|
|
2613
|
+
function loadPart(path: string, cache: Map<string, Plate | null>): Plate | string {
|
|
2614
|
+
if (cache.has(path)) {
|
|
2615
|
+
const held = cache.get(path) ?? null;
|
|
2616
|
+
return held === null ? `cannot read ${path}` : held;
|
|
2617
|
+
}
|
|
2618
|
+
if (!existsSync(path)) {
|
|
2619
|
+
cache.set(path, null);
|
|
2620
|
+
return `${path} is not there — --images must hold one PNG per attachment image name the candidate uses`;
|
|
2621
|
+
}
|
|
2622
|
+
try {
|
|
2623
|
+
const plate = readPlate(path);
|
|
2624
|
+
cache.set(path, plate);
|
|
2625
|
+
return plate;
|
|
2626
|
+
} catch (err) {
|
|
2627
|
+
cache.set(path, null);
|
|
2628
|
+
return `cannot decode ${path}: ${(err as Error).message}`;
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function hasMaterial(plate: Plate): boolean {
|
|
2633
|
+
for (let i = 3; i < plate.data.length; i += 4) if (plate.data[i] !== 0) return true;
|
|
2634
|
+
return false;
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
/** A directory or a `skeleton.json` path, the way every other command takes a candidate. */
|
|
2638
|
+
function resolveSkeletonPath(target: string): string {
|
|
2639
|
+
const abs = resolve(target);
|
|
2640
|
+
if (!existsSync(abs)) throw new ChainFitError(`nothing at ${abs}`);
|
|
2641
|
+
if (statSync(abs).isDirectory()) return join(abs, 'skeleton.json');
|
|
2642
|
+
if (!abs.endsWith('.json')) throw new ChainFitError(`${abs} is neither a directory nor a .json skeleton`);
|
|
2643
|
+
return abs;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// ---------------------------------------------------------------------------
|
|
2647
|
+
// the console report
|
|
2648
|
+
// ---------------------------------------------------------------------------
|
|
2649
|
+
|
|
2650
|
+
function placementLine(p: ChainFitPlacement): string {
|
|
2651
|
+
return (
|
|
2652
|
+
`x=${p.x.toFixed(1).padStart(7)} y=${p.y.toFixed(1).padStart(7)} rot=${p.rotationDeg.toFixed(1).padStart(7)}° ` +
|
|
2653
|
+
`scale=${p.scale.toFixed(3)} residual=${p.residual.toFixed(4)} visible=${(p.visibleShare * 100).toFixed(0).padStart(3)}%`
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
export function chainFitLines(report: ChainFitReport): string[] {
|
|
2658
|
+
const bg = report.frame.background;
|
|
2659
|
+
const bgText =
|
|
2660
|
+
bg.kind === 'colour' && bg.colour !== null
|
|
2661
|
+
? `rgb(${bg.colour.join(', ')}) over ${(bg.borderShare * 100).toFixed(0)}% of the border ring`
|
|
2662
|
+
: bg.kind === 'transparent'
|
|
2663
|
+
? `transparency over ${(bg.borderShare * 100).toFixed(0)}% of the border ring`
|
|
2664
|
+
: 'UNKNOWN — the border ring has no dominant colour, so every pixel counts as material and the silhouette ' +
|
|
2665
|
+
'signal is gone; residuals here are colour agreement only';
|
|
2666
|
+
const lines = [
|
|
2667
|
+
` .. frame ${report.frame.path} (${report.frame.width}x${report.frame.height})`,
|
|
2668
|
+
` .. ground ${bgText}`,
|
|
2669
|
+
` .. candidate ${report.candidate.skeleton} (${report.candidate.bones} bones, ` +
|
|
2670
|
+
`${report.candidate.drawn} of ${report.candidate.slots} slots drawn)`,
|
|
2671
|
+
` .. parts ${report.images}`,
|
|
2672
|
+
` .. anchor ${report.anchor.source === 'pose' ? 'an internal `rigc pose` pass' : (report.anchor.path ?? '')} · ` +
|
|
2673
|
+
`${report.anchor.anchored.length} trusted (residual ≤ ${report.anchor.criterion.maxResidual}, ` +
|
|
2674
|
+
`unexplained ≤ ${report.anchor.criterion.maxUnexplained}, unambiguous)`,
|
|
2675
|
+
` .. inward ${
|
|
2676
|
+
report.inward.determined.length === 0
|
|
2677
|
+
? `nothing determined (needs ${report.inward.criterion.minDeterminants} anchored descendants on ` +
|
|
2678
|
+
`different sub-chains, ≥ ${report.inward.criterion.minLeverPx} px apart)`
|
|
2679
|
+
: `${report.inward.determined.length} bone(s) determined from anchored descendants: ` +
|
|
2680
|
+
report.inward.determined.join(', ')
|
|
2681
|
+
}`,
|
|
2682
|
+
` .. search ${searchHingeClause(report.search.hinge)} · stretch free from ` +
|
|
2683
|
+
`${report.search.stretch.freeFrom} · refuse below visible ${report.search.minVisible} or above residual ` +
|
|
2684
|
+
`${report.search.maxResidual} · ${report.search.passes} pass(es)`,
|
|
2685
|
+
];
|
|
2686
|
+
const width = Math.max(8, ...report.parts.map((p) => p.part.length));
|
|
2687
|
+
for (const part of report.parts) {
|
|
2688
|
+
const label = part.part.padEnd(width);
|
|
2689
|
+
const pad = ' '.repeat(width);
|
|
2690
|
+
if (part.placement === null) {
|
|
2691
|
+
lines.push(` REFUSE ${label} ${part.refusal?.reason ?? 'unplaced'}: ${part.refusal?.detail ?? ''}`);
|
|
2692
|
+
continue;
|
|
2693
|
+
}
|
|
2694
|
+
const tag =
|
|
2695
|
+
part.refusal !== null
|
|
2696
|
+
? 'REFUSE'
|
|
2697
|
+
: part.ambiguous
|
|
2698
|
+
? 'AMBIG '
|
|
2699
|
+
: part.role === 'anchor'
|
|
2700
|
+
? 'ANCHOR'
|
|
2701
|
+
: part.role === 'inward'
|
|
2702
|
+
? 'INWARD'
|
|
2703
|
+
: 'CHAIN ';
|
|
2704
|
+
lines.push(` ${tag} ${label} ${placementLine(part.placement)}`);
|
|
2705
|
+
if (part.bone.inward !== null) {
|
|
2706
|
+
const inward = part.bone.inward;
|
|
2707
|
+
lines.push(
|
|
2708
|
+
` ${pad} bone ${part.bone.name} · DETERMINED from ${inward.determinants
|
|
2709
|
+
.map((d) => `${d.bone}@${d.leverPx}px`)
|
|
2710
|
+
.join(' + ')} · lever ${inward.leverPx} px · redundancy ${inward.redundancy} · disagreement ` +
|
|
2711
|
+
`${inward.disagreementPx === null ? 'n/a at redundancy 0' : `${inward.disagreementPx} px`}`,
|
|
2712
|
+
);
|
|
2713
|
+
for (const r of inward.rejected) lines.push(` ${pad} unusable determinant ${r.bone}: ${r.why}`);
|
|
2714
|
+
} else if (part.bone.anchoredToRole === 'inward') {
|
|
2715
|
+
lines.push(` ${pad} hangs off "${part.bone.anchoredTo}", which was determined inward`);
|
|
2716
|
+
}
|
|
2717
|
+
if (part.role === 'chain') {
|
|
2718
|
+
const hinge = part.placement.hingeDeg === null ? '—' : `${part.placement.hingeDeg.toFixed(2)}°`;
|
|
2719
|
+
const local = part.placement.localRotationDeg === null ? '—' : `${part.placement.localRotationDeg.toFixed(2)}°`;
|
|
2720
|
+
lines.push(
|
|
2721
|
+
` ${pad} bone ${part.bone.name} · depth ${part.bone.depth} from ${part.bone.anchoredTo ?? '?'} · ` +
|
|
2722
|
+
`hinge ${hinge} (local ${local} Spine)` +
|
|
2723
|
+
(part.bone.dof.stretch && part.placement.stretch !== null ? ` · stretch ${part.placement.stretch.toFixed(3)}` : '') +
|
|
2724
|
+
` · ${part.placement.scoredPixels} px scored`,
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
if (part.bone.carriedBones.length > 0) {
|
|
2728
|
+
lines.push(` ${pad} carried through un-fitted: ${part.bone.carriedBones.join(', ')}`);
|
|
2729
|
+
}
|
|
2730
|
+
part.alternates.forEach((alt, i) => {
|
|
2731
|
+
const hinge = alt.hingeDeg === null ? '—' : `${alt.hingeDeg.toFixed(2)}°`;
|
|
2732
|
+
lines.push(` ${pad} alt ${i + 2}: ${placementLine(alt)} hinge ${hinge}`);
|
|
2733
|
+
});
|
|
2734
|
+
if (part.refusal !== null) lines.push(` ${pad} ${part.refusal.reason}: ${part.refusal.detail}`);
|
|
2735
|
+
}
|
|
2736
|
+
const read = report.parts.filter((p) => p.refusal === null).length;
|
|
2737
|
+
const bought = report.parts.filter(
|
|
2738
|
+
(p) => p.refusal === null && p.role === 'chain' && p.anchorVerdict?.eligible === false,
|
|
2739
|
+
).length;
|
|
2740
|
+
const onInward = report.parts.filter(
|
|
2741
|
+
(p) => p.refusal === null && (p.role === 'inward' || p.bone.anchoredToRole === 'inward'),
|
|
2742
|
+
).length;
|
|
2743
|
+
lines.push('');
|
|
2744
|
+
lines.push(
|
|
2745
|
+
` .. ${read} of ${report.parts.length} part(s) read; ${bought} of them the anchor pass refused and the chain bought` +
|
|
2746
|
+
(report.inward.determined.length === 0 ? '.' : `; ${onInward} of them rest on an inward determination.`),
|
|
2747
|
+
);
|
|
2748
|
+
lines.push(' .. residuals are over VISIBLE pixels and are a trust signal, not a score — nothing here has a');
|
|
2749
|
+
lines.push(' .. pass bar. Read every one next to its `visible` share, and remember the occlusion is the');
|
|
2750
|
+
lines.push(" .. candidate's own: a wrong draw order masks the wrong pixels.");
|
|
2751
|
+
return lines;
|
|
2752
|
+
}
|