spine-rigc 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ingest.ts CHANGED
@@ -47,6 +47,13 @@
47
47
  import { SLOT_TRACKS as EMITTED_SLOT_TRACKS, SPINE_VERSION } from './compile.ts';
48
48
  import { CompileError } from './errors.ts';
49
49
  import { CHANNELS_BY_KIND } from './timelines.ts';
50
+ import {
51
+ LEGACY_BONE_INHERIT_KEY,
52
+ PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED,
53
+ SPINE_GENERATIONS,
54
+ spineGeneration,
55
+ TOPLEVEL_CONSTRAINT_ARRAYS,
56
+ } from './generation.ts';
50
57
  import { MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
51
58
  import { parseRigSpec, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
52
59
  import type { MotionSpec } from './types.ts';
@@ -404,6 +411,26 @@ const HEADER_REDERIVED = ['spine'];
404
411
  /** The attachment types this module inverts. Everything else is refused by name. */
405
412
  const ATTACHMENT_TYPES = ['region', 'mesh', 'linkedmesh', 'boundingbox', 'clipping', 'path'];
406
413
 
414
+ /**
415
+ * The geometry keys a LINKED mesh may state and the parser never reads
416
+ * (issue #710).
417
+ *
418
+ * `readAttachment` returns from the `source` branch at `SkeletonJson.js:586`,
419
+ * before `map.uvs` is touched, so a link carrying any of these is a file that
420
+ * says one mesh while every runtime draws its source's. The rig spec cannot hold
421
+ * them either — `buildRigLinkedMesh` refuses geometry on a link by name — so a
422
+ * rebuild that carried one would be a spec `build` refuses, and a rebuild that
423
+ * dropped it in silence would be this module normalising somebody's file without
424
+ * saying so.
425
+ *
426
+ * ⚠️ A second list beside `src/validate.ts`'s, deliberately: that module links
427
+ * spine-core and this one must not, so importing it would pull the runtime into
428
+ * every `ingest`. What holds the two equal is a RUN rather than a shared
429
+ * constant — one forged skeleton through both, with the keys `A44` names and the
430
+ * keys this finding names compared as sets.
431
+ */
432
+ const LINKED_MESH_UNREAD_KEYS = ['uvs', 'triangles', 'vertices', 'hull', 'edges'];
433
+
407
434
  /**
408
435
  * The slot timelines the motion spec carries — `compileTrack`'s own table,
409
436
  * rather than a second list of the same two names.
@@ -548,6 +575,12 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
548
575
  const root = obj(skeleton);
549
576
  const boneNames: string[] = arr(root.bones).map(nameOf);
550
577
 
578
+ // -- the generation -------------------------------------------------------
579
+ // 🚨 First, because every walk below reads the file as 4.3 and a file from
580
+ // another generation is one this module cannot honestly claim to have read
581
+ // (issue #706 item 3). It records; it does not refuse — see `readGeneration`.
582
+ readGeneration(root, note);
583
+
551
584
  // -- header ---------------------------------------------------------------
552
585
  // 🚨 THE SEAM. One function decides the rig spec's `skeleton` block, and the
553
586
  // stage is the only value in this whole module that a skeleton cannot answer
@@ -716,6 +749,146 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
716
749
 
717
750
  type Note = (kind: IngestFindingKind, code: string, where: string, detail: string) => void;
718
751
 
752
+ /**
753
+ * The generation of the data this module inverts, read off the version the
754
+ * emitter writes rather than typed beside it.
755
+ *
756
+ * ⚠️ `null` here would be a compiler emitting a version string this repository's
757
+ * own detector cannot read, and the comparison below is written so that it
758
+ * blocks every file rather than none — a reader that cannot say what it reads
759
+ * cannot certify anything. `runGenerationSuite`'s positive control is what says
760
+ * out loud that it is not null.
761
+ */
762
+ const READER_GENERATION = spineGeneration(SPINE_VERSION);
763
+
764
+ /** Up to six names, so one finding cannot print a hundred. */
765
+ function spellSome(names: readonly string[]): string {
766
+ const shown = names.slice(0, 6).map((name) => `"${name}"`).join(', ');
767
+ return names.length > 6 ? `${shown} +${names.length - 6} more` : shown;
768
+ }
769
+
770
+ /**
771
+ * What a 4.3 reader loses on THIS file, counted on this file.
772
+ *
773
+ * 🚨 Three shapes, and they are the three #706 measured rather than three this
774
+ * module thought of: constraints parked in the top-level arrays 4.3 folded away
775
+ * (row 1 — 1,302 shipped skeletons parsed and loaded 0 of 8,672 constraints),
776
+ * bones carrying the key 4.3 renamed (row 6), and physics constraints omitting a
777
+ * field whose default is not the same number in 4.2 as in 4.3 (row 4).
778
+ *
779
+ * ⚠️ It is a MEASUREMENT and not an inventory: a construct none of the three
780
+ * describes is lost without being counted here, which is why the empty case says
781
+ * so rather than saying nothing was lost.
782
+ */
783
+ function generationLosses(root: JsonObject): string[] {
784
+ const out: string[] = [];
785
+
786
+ const parked = TOPLEVEL_CONSTRAINT_ARRAYS.map((kind) => [kind, arr(root[kind]).length] as const).filter(
787
+ ([, count]) => count > 0,
788
+ );
789
+ const parkedTotal = parked.reduce((total, [, count]) => total + count, 0);
790
+ if (parkedTotal > 0) {
791
+ out.push(
792
+ `${parkedTotal} constraint(s) sit in top-level arrays (${parked.map(([kind, count]) => `${kind} ${count}`).join(', ')}) ` +
793
+ 'and this reader takes constraints from "constraints" alone, so it reads none of them and the rebuilt rig has none',
794
+ );
795
+ }
796
+
797
+ const renamed = arr(root.bones)
798
+ .filter((bone) => isObj(bone) && LEGACY_BONE_INHERIT_KEY in bone)
799
+ .map((bone) => nameOf(bone));
800
+ if (renamed.length > 0) {
801
+ out.push(
802
+ `${renamed.length} bone(s) carry "${LEGACY_BONE_INHERIT_KEY}" where 4.3 spells "inherit" (${spellSome(renamed)}), ` +
803
+ 'each dropped as a field the rig spec has no home for — the BONE_FIELD line beside this one — so the ' +
804
+ 'rebuilt bone inherits Normally',
805
+ );
806
+ }
807
+
808
+ const physics = [...arr(root.physics), ...arr(root.constraints).filter((one) => isObj(one) && one.type === 'physics')].filter(
809
+ isObj,
810
+ );
811
+ const omitting = PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED.map(
812
+ (field) => [field, physics.filter((one) => one[field] === undefined).length] as const,
813
+ ).filter(([, count]) => count > 0);
814
+ if (omitting.length > 0) {
815
+ out.push(
816
+ `${physics.length} physics constraint(s), of which ${omitting.map(([field, count]) => `${count} omit "${field}"`).join(' and ')} — ` +
817
+ "JSON omits a field equal to the parser's default and that default is NOT the same number in 4.2 as in 4.3 " +
818
+ '(#706 row 4), so the omission means one rig there and a different one here',
819
+ );
820
+ }
821
+ return out;
822
+ }
823
+
824
+ /**
825
+ * The generation, read before a field of the file is.
826
+ *
827
+ * 🚨 **The silence this converts into a name was measured on the branch point.**
828
+ * A 4.3 emit with its constraints moved into the top-level arrays 4.2 kept them
829
+ * in came back through `ingest` as a rig spec with **zero** constraints and
830
+ * **no finding about them at all**; the only blocker was `BONE_FIELD`, about the
831
+ * bone key. A 3.8 label produced one `LOSS HEADER_REDERIVED` line and exit 0.
832
+ * That is the shape this whole module exists to refuse — a decompiler that is
833
+ * quiet about what it dropped.
834
+ *
835
+ * ⭐ **One code with the generation in the sentence, rather than one code per
836
+ * generation.** `IG25` derives `docs/INGEST.md` §2.0's finding table by reading
837
+ * every `note` call in this file for a code matching `[A-Z_]+`, and compares it
838
+ * against rows matched with `[A-Z_<>]+`. A composed `GENERATION_${generation}`
839
+ * would read `GENERATION_3.8` at runtime — digits and a dot — so the call would
840
+ * be one the scan cannot resolve and the code would be missing from BOTH sides
841
+ * of that comparison, which is the one failure comparing two sets cannot report.
842
+ *
843
+ * ⚠️ The same scan counts its own population with a second, dumber pattern over
844
+ * the raw text, comments included — so a prose mention of that call spelled with
845
+ * its opening bracket raises the count without raising the sites, and `IG25`
846
+ * goes red on a file with nothing wrong in it. It did, on the first green run of
847
+ * this change: **25 of 26 read**, the 26th being this very paragraph.
848
+ *
849
+ * ⚠️ It does NOT refuse the file. Everything here is a finding and both specs
850
+ * are still written, for the reason `IngestFinding` states: a spec plus a list
851
+ * of what is missing from it beats no spec. The exit code is the caller's and it
852
+ * is 1, because this is a `blocker`.
853
+ */
854
+ function readGeneration(root: JsonObject, note: Note): void {
855
+ const declared = obj(root.skeleton).spine;
856
+ const generation = typeof declared === 'string' ? spineGeneration(declared) : null;
857
+ if (generation !== null && generation === READER_GENERATION) return;
858
+ const stated = typeof declared === 'string' ? `${JSON.stringify(declared)}` : 'no `skeleton.spine` at all';
859
+ const reads = READER_GENERATION ?? '(none — this build\'s own version string is unreadable)';
860
+ const losses = generationLosses(root);
861
+ const measured =
862
+ losses.length > 0
863
+ ? `Measured on this file: ${losses.join('; ')}.`
864
+ : 'Measured on this file: no constraint in a top-level array, no bone carrying ' +
865
+ `"${LEGACY_BONE_INHERIT_KEY}", and no physics constraint omitting a default that moved — which is three ` +
866
+ 'shapes counted and not a guarantee that nothing else differs.';
867
+ if (generation === null) {
868
+ note(
869
+ 'blocker',
870
+ 'GENERATION_UNKNOWN',
871
+ 'skeleton.spine',
872
+ `the file states ${stated} and no Spine generation matches it. A version is read as its LEADING major.minor ` +
873
+ 'token — a down-export states "4.0-from-4.1.24", which is 4.0 data from a 4.1 editor — and the generations ' +
874
+ `rigc knows are ${SPINE_GENERATIONS.join(', ')}; this reader reads ${reads}. It is NOT read as the nearest ` +
875
+ 'one: a catalogue that handed 19 skeletons labelled "3.8.99" the nearest runtime it had loaded every one of ' +
876
+ `them and posed 238 of 248 bones as NaN (issue #706 row 7). ${measured}`,
877
+ );
878
+ return;
879
+ }
880
+ note(
881
+ 'blocker',
882
+ 'GENERATION_UNSUPPORTED',
883
+ 'skeleton.spine',
884
+ `the file states ${stated}, which is Spine ${generation} data, and this reader reads Spine ${reads} only — it ` +
885
+ `inverts a ${SPINE_VERSION} emitter. A generation mismatch does not throw; it drops what the newer format ` +
886
+ `moved. ${measured} Reading the file with ${generation}'s OWN defaults is issue #706 item 2 — a ` +
887
+ 'per-generation table extracted by machine from each runtime\'s `SkeletonJson` — and is not in this tool. ' +
888
+ `Re-export from a ${reads} editor, or transcribe the file by hand (docs/INGEST.md §2).`,
889
+ );
890
+ }
891
+
719
892
  /**
720
893
  * Does this header declare a stage?
721
894
  *
@@ -939,6 +1112,20 @@ function ingestAttachment(
939
1112
  // writing one the source omitted would be a rebuild that says more than the
940
1113
  // file did — and `buildRigLinkedMesh` drops it again on the way back out.
941
1114
  for (const field of ['slot', 'skin', 'timelines', 'color']) if (att[field] !== undefined) out[field] = att[field];
1115
+ const dropped = LINKED_MESH_UNREAD_KEYS.filter((field) => att[field] !== undefined);
1116
+ if (dropped.length > 0) {
1117
+ note(
1118
+ 'lossy',
1119
+ 'ATTACHMENT_LINK_GEOMETRY',
1120
+ at.where,
1121
+ `the attachment is a LINKED mesh and states ${dropped.map((field) => `\`${field}\``).join(', ')}, which the ` +
1122
+ 'parser reads with nothing at all: it returns from the `source` branch before `readVertices` ' +
1123
+ `(\`SkeletonJson.ts:582-586\`), so what this attachment draws is the geometry of ${JSON.stringify(att.source)}. ` +
1124
+ `The rebuild drops ${dropped.length === 1 ? 'it' : 'them'} — the rig spec refuses geometry on a link by name, ` +
1125
+ 'and carrying it would write a spec `build` will not take. `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` ' +
1126
+ 'is the same fact held against the source file',
1127
+ );
1128
+ }
942
1129
  } else if (type === 'region') {
943
1130
  carryArt();
944
1131
  for (const field of ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'color']) {
package/src/pose.ts CHANGED
@@ -129,7 +129,16 @@ export const COARSE_STRIDE_FRACTION = 0.25;
129
129
  /** How many scale rungs one octave gets in the coarse ladder. */
130
130
  export const SCALE_STEPS_PER_OCTAVE = 3;
131
131
 
132
- /** The coarse rotation ladder's step, in degrees. */
132
+ /**
133
+ * The COARSEST step, in degrees, the rotation ladder is allowed to take.
134
+ *
135
+ * ⚠️ A ceiling on the step rather than the step itself, and the distinction is
136
+ * the whole of issue #719. Read as "the step", a window narrower than it prints
137
+ * a resolution the search never had: `--rotation -5,5` reported `step 15°` over
138
+ * a ten-degree window. The ladder therefore divides the window into whole steps
139
+ * no coarser than this — the same shape `scaleLadder` has always had for
140
+ * octaves — and the report states the step that division produced.
141
+ */
133
142
  export const COARSE_ROTATION_STEP = 15;
134
143
 
135
144
  /** Default scale window, as frame pixels per part pixel. */
@@ -271,7 +280,15 @@ export interface PosePart {
271
280
 
272
281
  export interface PoseSearch {
273
282
  scale: { min: number; max: number; steps: number };
274
- rotation: { minDeg: number; maxDeg: number; stepDeg: number; steps: number };
283
+ /**
284
+ * The rotation window, and the ladder it produced.
285
+ *
286
+ * 🔒 `stepDeg` is read off `degrees` rather than off `COARSE_ROTATION_STEP`,
287
+ * and `degrees` is the array the coarse pass iterated — so the two cannot say
288
+ * different things about the same run (issue #719). `steps` is how many angles
289
+ * that is, which is `degrees.length`.
290
+ */
291
+ rotation: { minDeg: number; maxDeg: number; stepDeg: number; steps: number; degrees: number[] };
275
292
  /**
276
293
  * How the exhaustive first pass was sized. The level it runs at is chosen PER
277
294
  * PART — see `PosePart.coarse` — because it depends on how big the part is.
@@ -783,8 +800,28 @@ function polish(
783
800
  smooth: boolean,
784
801
  /** The scale window the report declares. A polish that walked outside it would report a scale nobody searched. */
785
802
  bounds: { min: number; max: number },
803
+ /**
804
+ * The rotation window the report declares, held for exactly the reason above.
805
+ *
806
+ * ⚠️ This argument did not exist until issue #719, and the sentence over
807
+ * `bounds` was the whole argument for it the entire time: a polish free to
808
+ * walk outside the window reports an answer nobody searched, and the window is
809
+ * a field a caller is entitled to read as a promise. `src/chainfit.ts` had
810
+ * already written that argument out for its own hinge — *"for the same reason
811
+ * `pose`'s polish clamps its scale"* — while this file, the one it was citing,
812
+ * clamped one of its two windows. Measured on a rotation window of `-5,5`: the
813
+ * ladder walked its two endpoints and the report came back with 28.1°, 121.3°
814
+ * and −122.3°.
815
+ *
816
+ * A full turn contains every angle, so it is left unclamped and the rotation
817
+ * may wrap — which is what the default window is, and why nothing about a
818
+ * default run moves.
819
+ */
820
+ rotationBounds: { min: number; max: number; wraps: boolean },
786
821
  ): Candidate {
787
822
  const clamp = (v: number): number => Math.min(bounds.max, Math.max(bounds.min, v));
823
+ const hold = (v: number): number =>
824
+ rotationBounds.wraps ? v : Math.min(rotationBounds.max, Math.max(rotationBounds.min, v));
788
825
  let cur: Candidate = { ...start, residual: residualAt(level, plate, s, start, smooth) };
789
826
  let dt = step.translate;
790
827
  let dr = step.rotate;
@@ -810,8 +847,8 @@ function polish(
810
847
  }
811
848
  }
812
849
  if (dr > floor.rotate) {
813
- push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg + dr, scale: cur.scale });
814
- push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg - dr, scale: cur.scale });
850
+ push({ cx: cur.cx, cy: cur.cy, rotDeg: hold(cur.rotDeg + dr), scale: cur.scale });
851
+ push({ cx: cur.cx, cy: cur.cy, rotDeg: hold(cur.rotDeg - dr), scale: cur.scale });
815
852
  }
816
853
  if (ds > floor.scale) {
817
854
  push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg, scale: clamp(cur.scale * (1 + ds)) });
@@ -997,22 +1034,76 @@ function scaleLadder(min: number, max: number): number[] {
997
1034
  return out;
998
1035
  }
999
1036
 
1000
- function rotationLadder(minDeg: number, maxDeg: number): number[] {
1037
+ /**
1038
+ * The angles the coarse pass actually walks, evenly dividing the window.
1039
+ *
1040
+ * ⭐ `scaleLadder` above is the shape this follows, and it is the reason the
1041
+ * defect was reachable: that one takes a rung count off its window and divides,
1042
+ * so the rung it reports is the rung it walks. This one used to march
1043
+ * `COARSE_ROTATION_STEP` off the floor and then append the ceiling, which left
1044
+ * two ways for the reported step to be a different number from the applied one —
1045
+ * a window narrower than the constant got its two endpoints and a gap of the
1046
+ * window's own width, and any window whose span is not a whole number of steps
1047
+ * got a short final gap. Both printed `step 15°`.
1048
+ *
1049
+ * ⚠️ The count is a CEILING rather than a rounding, which is not tidiness: a
1050
+ * rounding down would make the applied step wider than `COARSE_ROTATION_STEP`
1051
+ * for a window like 20°, so the constant would stop being an upper bound on the
1052
+ * step. Rounding up cannot coarsen the search — measured against the old ladder,
1053
+ * every window it changes gets at least as many angles as before.
1054
+ */
1055
+ export function rotationLadder(minDeg: number, maxDeg: number): number[] {
1001
1056
  const span = maxDeg - minDeg;
1002
1057
  if (span <= 0) return [minDeg];
1003
- // A full turn's two endpoints are the same rotation, so it gets one of them.
1004
- if (span >= 360 - 1e-9) {
1005
- const count = Math.round(360 / COARSE_ROTATION_STEP);
1006
- const out: number[] = [];
1007
- for (let i = 0; i < count; i++) out.push(minDeg + (i * 360) / count);
1008
- return out;
1009
- }
1058
+ const steps = Math.ceil(span / COARSE_ROTATION_STEP - 1e-9);
1010
1059
  const out: number[] = [];
1011
- for (let deg = minDeg; deg <= maxDeg + 1e-9; deg += COARSE_ROTATION_STEP) out.push(deg);
1012
- if (out[out.length - 1] < maxDeg - 1e-9) out.push(maxDeg);
1060
+ for (let i = 0; i <= steps; i++) out.push(minDeg + (span * i) / steps);
1061
+ // A full turn's two endpoints are the same rotation, so it gets one of them.
1062
+ if (span >= 360 - 1e-9) out.pop();
1013
1063
  return out;
1014
1064
  }
1015
1065
 
1066
+ /** The step a ladder walks, read off the ladder rather than off the constant it was built from. */
1067
+ function ladderStep(degrees: number[]): number {
1068
+ return degrees.length > 1 ? degrees[1] - degrees[0] : 0;
1069
+ }
1070
+
1071
+ /**
1072
+ * The `search` line's rotation clause.
1073
+ *
1074
+ * ⭐ Exported for the same reason `windowEdgeNote` is: `docs/AUTHORING.md`
1075
+ * quotes this line, and a guide that spells a report's own sentence by hand is
1076
+ * a second implementation of it. `CUR47` builds the clause here and looks for it
1077
+ * in the page, so the two go stale together or not at all.
1078
+ */
1079
+ export function searchRotationClause(rotation: PoseSearch['rotation']): string {
1080
+ // Rounded for the console alone — `search.rotation` in the JSON carries the
1081
+ // ladder unrounded, because a window that divides into thirds has angles no
1082
+ // decimal place holds.
1083
+ return `rotation ${rotation.minDeg}°–${rotation.maxDeg}° in ${rotation.steps} step(s) of ${roundTo(rotation.stepDeg, 3)}°`;
1084
+ }
1085
+
1086
+ /**
1087
+ * The sentence a refusal carries when its best placement sits on a WALL of the
1088
+ * search window rather than somewhere inside it.
1089
+ *
1090
+ * ⭐ Exported because the guide quotes it and `CUR48` compares the two: a
1091
+ * message and the document that teaches it are the same interface, and the only
1092
+ * way they cannot drift is for one of them to be built from the other.
1093
+ *
1094
+ * The claim is deliberately weak — *may* lie outside — because that is all that
1095
+ * is known. The search was bounded, the optimum walked to the bound and stopped;
1096
+ * whether the truth is past it or the part simply does not appear in this frame
1097
+ * are two readings this instrument cannot separate. Naming the wall is what lets
1098
+ * an author separate them, by moving the wall.
1099
+ */
1100
+ export function windowEdgeNote(axis: 'scale' | 'rotation', edge: 'floor' | 'ceiling', at: string, window: string): string {
1101
+ return (
1102
+ `best placement at ${axis} ${at}, the ${edge} of --${axis} ${window} — ` +
1103
+ `the truth may lie ${edge === 'floor' ? 'below' : 'above'} the window`
1104
+ );
1105
+ }
1106
+
1016
1107
  /** The PNGs in a directory, in name order — the parts, and the order the report lists them. */
1017
1108
  export function partFiles(imagesDir: string, exclude: string): string[] {
1018
1109
  const dir = resolve(imagesDir);
@@ -1072,7 +1163,18 @@ export function estimatePose(options: PoseOptions): PoseReport {
1072
1163
  frame: { path: framePath, width: frame.width, height: frame.height, background },
1073
1164
  search: {
1074
1165
  scale: { min: scaleMin, max: scaleMax, steps: scales.length },
1075
- rotation: { minDeg: rotMin, maxDeg: rotMax, stepDeg: COARSE_ROTATION_STEP, steps: rotations.length },
1166
+ rotation: {
1167
+ minDeg: rotMin,
1168
+ maxDeg: rotMax,
1169
+ // ⚠️ Neither of these is rounded, and every other number in this report
1170
+ // is. Rounding them would make the reported ladder a near-copy of the
1171
+ // applied one, which is the defect this field exists to close — a window
1172
+ // that divides into thirds has angles no decimal place holds. The console
1173
+ // rounds for display; the record is exact.
1174
+ stepDeg: ladderStep(rotations),
1175
+ steps: rotations.length,
1176
+ degrees: [...rotations],
1177
+ },
1076
1178
  coarse: {
1077
1179
  frameLongSide: COARSE_LONG_SIDE,
1078
1180
  partSpan: COARSE_PART_SPAN,
@@ -1097,14 +1199,29 @@ export function estimatePose(options: PoseOptions): PoseReport {
1097
1199
  'only inside the frame canvas. ⚠️ A window that does not contain the true value does NOT reliably ' +
1098
1200
  'refuse: a part shrunk inside the region it came from still explains those pixels, so the answer is the ' +
1099
1201
  'best placement available INSIDE the window and its residual can look reasonable. That is why the window ' +
1100
- 'is a reported field — if the numbers surprise you, check it before you trust them.',
1202
+ 'is a reported field — if the numbers surprise you, check it before you trust them. A refused part whose ' +
1203
+ 'best placement stopped ON a wall of the window says so in its own `refusal.detail`, which is the case ' +
1204
+ 'where the window is the first thing to move.',
1101
1205
  ],
1102
1206
  parts: [],
1103
1207
  };
1104
1208
 
1209
+ // A window spanning a whole turn contains every angle there is, so nothing is
1210
+ // outside it and nothing has to be held inside it.
1211
+ const rotationBounds = { min: rotMin, max: rotMax, wraps: rotMax - rotMin >= 360 - 1e-9 };
1105
1212
  for (const path of paths) {
1106
1213
  report.parts.push(
1107
- placePart(path, frame, levels, framePyramid, scales, rotations, maxResidual, { min: scaleMin, max: scaleMax }),
1214
+ placePart(
1215
+ path,
1216
+ frame,
1217
+ levels,
1218
+ framePyramid,
1219
+ scales,
1220
+ rotations,
1221
+ maxResidual,
1222
+ { min: scaleMin, max: scaleMax },
1223
+ rotationBounds,
1224
+ ),
1108
1225
  );
1109
1226
  }
1110
1227
  return report;
@@ -1119,6 +1236,7 @@ function placePart(
1119
1236
  rotations: number[],
1120
1237
  maxResidual: number,
1121
1238
  scaleBounds: { min: number; max: number },
1239
+ rotationBounds: { min: number; max: number; wraps: boolean },
1122
1240
  ): PosePart {
1123
1241
  const scaleMin = scaleBounds.min;
1124
1242
  /** The scale the sample sets are sized for — the middle of the window, and NOT the scale under test. */
@@ -1303,7 +1421,7 @@ function placePart(
1303
1421
  }
1304
1422
  seeds.push(start);
1305
1423
  }
1306
- candidates = seeds.map((seed) => polish(level, plate, s, seed, step, floor, smooth, scaleBounds));
1424
+ candidates = seeds.map((seed) => polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds));
1307
1425
  candidates.sort((a, b) => a.residual - b.residual);
1308
1426
  // ⚠️ Eight branches that walked to one optimum are one candidate, not eight —
1309
1427
  // and the radius has to scale with the PART rather than be a pixel count.
@@ -1324,20 +1442,31 @@ function placePart(
1324
1442
  // The one rotation family the translation scan cannot see: a part that is its
1325
1443
  // own mirror after a quarter or a half turn sits in the SAME place at more than
1326
1444
  // one angle, so the field records only whichever won. Probe them explicitly.
1445
+ //
1446
+ // 🚨 Only the turns the window contains, and this is the other half of #719's
1447
+ // measurement. A quarter turn off is a SEED, not a ladder rung — so under
1448
+ // `--rotation -5,5` it entered the answer from outside a window the report was
1449
+ // calling the search, and the candidate who ran the exam read `rot=91.2°`
1450
+ // under `rotation -5°–5°`. A caller who bounds the rotation has said the part
1451
+ // is not a quarter turn over; the honest response is not to look there rather
1452
+ // than to look and report it.
1327
1453
  if (!rotationFree && candidates.length > 0) {
1328
1454
  const primary = candidates[0];
1329
1455
  const s = samplesFor(1, POLISH_SAMPLES);
1330
1456
  for (const turn of [90, 180, 270]) {
1457
+ const turned = primary.rotDeg + turn;
1458
+ if (!rotationBounds.wraps && (turned < rotationBounds.min - 1e-9 || turned > rotationBounds.max + 1e-9)) continue;
1331
1459
  candidates.push(
1332
1460
  polish(
1333
1461
  levels[0],
1334
1462
  plates[0],
1335
1463
  s,
1336
- { ...primary, rotDeg: primary.rotDeg + turn },
1464
+ { ...primary, rotDeg: turned },
1337
1465
  { translate: 1.5, rotate: 4, scale: 0.04 },
1338
1466
  { translate: 0.05, rotate: 0.1, scale: 0.001 },
1339
1467
  true,
1340
1468
  scaleBounds,
1469
+ rotationBounds,
1341
1470
  ),
1342
1471
  );
1343
1472
  }
@@ -1373,14 +1502,56 @@ function placePart(
1373
1502
  );
1374
1503
  }
1375
1504
  if (best.residual > maxResidual) {
1505
+ // ⭐ The wall the answer stopped against, named in the refusal that reports
1506
+ // it (issue #719). A refusal that states only the residual and the threshold
1507
+ // sends an author to the one remedy that cannot work — every part of a frame
1508
+ // rendered below the scale floor came back refused at the floor, and the
1509
+ // window that could not reach the truth was a line further up the report
1510
+ // nobody was told to read.
1511
+ //
1512
+ // ⚠️ On a refusal and on nothing else. An accepted placement at a wall is an
1513
+ // author who chose the window to bracket the answer, which is the flag
1514
+ // working; saying "the truth may lie outside" over every one of those is how
1515
+ // a warning stops being read. And a window with no interior — `min === max`
1516
+ // — has no wall to be at, so it gets no sentence: being at the only value
1517
+ // there is says nothing about where the truth is.
1518
+ const edges: string[] = [];
1519
+ if (scaleBounds.max > scaleBounds.min) {
1520
+ const window = `${scaleBounds.min},${scaleBounds.max}`;
1521
+ if (best.scale <= scaleBounds.min * (1 + 1e-9)) {
1522
+ edges.push(windowEdgeNote('scale', 'floor', best.scale.toFixed(3), window));
1523
+ } else if (best.scale >= scaleBounds.max * (1 - 1e-9)) {
1524
+ edges.push(windowEdgeNote('scale', 'ceiling', best.scale.toFixed(3), window));
1525
+ }
1526
+ }
1527
+ if (!rotationBounds.wraps && rotationBounds.max > rotationBounds.min) {
1528
+ const window = `${rotationBounds.min},${rotationBounds.max}`;
1529
+ const said = `${best.rotationDeg.toFixed(1)}°`;
1530
+ // Compared through `normaliseDegrees` because the reported angle is
1531
+ // normalised into (-180, 180] and a window need not be: `--rotation
1532
+ // 170,190` has a ceiling the report spells −170°.
1533
+ if (Math.abs(normaliseDegrees(best.rotationDeg - rotationBounds.min)) <= 1e-6) {
1534
+ edges.push(windowEdgeNote('rotation', 'floor', said, window));
1535
+ } else if (Math.abs(normaliseDegrees(best.rotationDeg - rotationBounds.max)) <= 1e-6) {
1536
+ edges.push(windowEdgeNote('rotation', 'ceiling', said, window));
1537
+ }
1538
+ }
1376
1539
  base.refusal = {
1377
1540
  reason: 'no-match',
1378
- detail: `${name}: the best placement found has residual ${best.residual.toFixed(4)}, above --max-residual ${maxResidual}`,
1541
+ detail:
1542
+ `${name}: the best placement found has residual ${best.residual.toFixed(4)}, above --max-residual ${maxResidual}` +
1543
+ (edges.length === 0 ? '' : `; ${edges.join('; ')}`),
1379
1544
  };
1380
1545
  base.notes.push(
1381
1546
  `${name} matches nowhere in this frame well enough to report. The best placement found is still in ` +
1382
1547
  '`placement` — a refusal names why not to trust it, it does not hide it.',
1383
1548
  );
1549
+ if (edges.length > 0) {
1550
+ base.notes.push(
1551
+ `${name}'s best placement sits on a wall of the search window, so the window is the first thing to move: ` +
1552
+ `${edges.join('; ')}.`,
1553
+ );
1554
+ }
1384
1555
  }
1385
1556
  if (best.unexplained > 0.25 && best.residual <= maxResidual) {
1386
1557
  base.notes.push(
@@ -1419,7 +1590,10 @@ export function poseLines(report: PoseReport): string[] {
1419
1590
  ` .. ground ${bgText}`,
1420
1591
  ` .. parts ${report.images} (${report.parts.length} png)`,
1421
1592
  ` .. search scale ${report.search.scale.min}–${report.search.scale.max} in ${report.search.scale.steps} step(s) · ` +
1422
- `rotation ${report.search.rotation.minDeg}°–${report.search.rotation.maxDeg}° step ${report.search.rotation.stepDeg}° · ` +
1593
+ // The step is the ladder's own rather than the constant it was capped at.
1594
+ // Printing the constant here is what issue #719 was: a line that said
1595
+ // `step 15°` over a window ten degrees wide, which no run had ever walked.
1596
+ `${searchRotationClause(report.search.rotation)} · ` +
1423
1597
  `refuse above residual ${report.search.maxResidual}`,
1424
1598
  ];
1425
1599
  const width = Math.max(8, ...report.parts.map((p) => p.part.length));