spine-rigc 2.19.0 → 2.20.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.
@@ -299,6 +299,8 @@ export interface MeasureRow {
299
299
  raster?: RasterSensitivity;
300
300
  /** Sampled rows — correction 2. */
301
301
  sampling?: { domain: string; count: number };
302
+ /** Motion rows only (stage C, `src/meshcompare.ts`): how the row's value was taken over the schedule. */
303
+ motion?: MotionRowDetail;
302
304
  }
303
305
 
304
306
  /** Correction 2: the spatial grid and the value's own granularity are two fields. */
@@ -353,9 +355,10 @@ export interface MotionSchedule {
353
355
 
354
356
  /**
355
357
  * The schedule a motion section was measured under. The contract names this
356
- * type without defining it; until stage C does, it is the schedule as given.
358
+ * type without defining it; stage C (issue #1230) defines it as the schedule as
359
+ * given plus what was walked from it (`ScheduleWalked`, at the end of this file).
357
360
  */
358
- export type ScheduleUsed = MotionSchedule;
361
+ export type ScheduleUsed = MotionSchedule & ScheduleWalked;
359
362
 
360
363
  /** One candidate's evidence — correction 1. `reduce` and `measure` carry exactly one. */
361
364
  export interface CandidateReport {
@@ -421,8 +424,8 @@ export interface EffectiveSettings {
421
424
  frame: SourceFrame;
422
425
  maskSize: [number, number];
423
426
  minArtSamples: number;
424
- /** P9: each region's own floor, in the order the regions were given. */
425
- regions: Array<{ name: string; minArtSamples: number }>;
427
+ /** P9: each region's own floor, in the order the regions were given — and, on a `compare`, its polygon. */
428
+ regions: Array<{ name: string; minArtSamples: number; polygon?: Array<[number, number]> }>;
426
429
  }>;
427
430
  sourceBounds: ArtFitBounds | null;
428
431
  referenceArtFit: ArtFitBounds | null;
@@ -473,6 +476,20 @@ const HAUSDORFF_TOLERANCE = 1e-9;
473
476
  /** The predicate epsilon `segmentsMeet` and `prunePolygon` use (`src/mesh.ts`), for "on the boundary". */
474
477
  const ON_BOUNDARY = 1e-9;
475
478
 
479
+ /**
480
+ * How near a region's band's outer boundary, in drawing px, an edge's nearest
481
+ * point has to come to count as touching it rather than entering the band —
482
+ * the precision of "a single point on the outer boundary" in
483
+ * `edgeIsHeldByRegion`, ten units of the `r6` grid. The refinement puts the
484
+ * vertex it splits an edge at half of this inside the outer boundary and then
485
+ * on the grid, which moves it by at most √2 × 5e-7 px, so the piece beyond it
486
+ * lands inside this tolerance from either side.
487
+ */
488
+ export const BAND_CONTACT_TOLERANCE = 1e-5;
489
+
490
+ /** Halvings of the parameter interval in the convex searches along an edge (`edgeIsHeldByRegion`): 2^-60 of an edge is below a double's resolution of it. */
491
+ const EDGE_SEARCH_STEPS = 60;
492
+
476
493
  // ---------------------------------------------------------------------------
477
494
  // refusals
478
495
  // ---------------------------------------------------------------------------
@@ -708,6 +725,110 @@ function segmentToPolygon(a: Pt, b: Pt, poly: readonly Pt[]): number {
708
725
  return best;
709
726
  }
710
727
 
728
+ /**
729
+ * The parameter in `[0, 1]` along `a`–`b` nearest which `c`–`d` lies — the
730
+ * minimum of the distance from `a + t(b − a)` to the segment `c`–`d`, which is
731
+ * convex in `t`, found by golden-section search. A flat minimum (parallel
732
+ * segments) returns some point of it; the callers never read one there.
733
+ */
734
+ function nearestParameter(a: Pt, b: Pt, c: Pt, d: Pt): number {
735
+ const at = (t: number): number => distanceToSegment([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], c, d);
736
+ const g = (Math.sqrt(5) - 1) / 2;
737
+ let lo = 0;
738
+ let hi = 1;
739
+ for (let i = 0; i < EDGE_SEARCH_STEPS; i++) {
740
+ const p = hi - g * (hi - lo);
741
+ const q = lo + g * (hi - lo);
742
+ if (at(p) <= at(q)) hi = q;
743
+ else lo = p;
744
+ }
745
+ return (lo + hi) / 2;
746
+ }
747
+
748
+ /**
749
+ * Does `a`–`b` run along the outer boundary of a band of width `transition`
750
+ * for a positive length — some polygon edge it overlaps in projection by more
751
+ * than `BAND_CONTACT_TOLERANCE`, at that edge's perpendicular distance
752
+ * `transition` (within the tolerance) at both ends of the overlap, so at every
753
+ * point between them? The caller has already found the edge's nearest approach
754
+ * to the polygon within the tolerance of `transition`.
755
+ */
756
+ function runsAlongOuterBoundary(a: Pt, b: Pt, poly: readonly Pt[], transition: number): boolean {
757
+ const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
758
+ const n = poly.length;
759
+ for (let i = 0; i < n; i++) {
760
+ const c = poly[i];
761
+ const e = poly[(i + 1) % n];
762
+ const side = Math.hypot(e[0] - c[0], e[1] - c[1]);
763
+ if (side === 0) continue;
764
+ const ux = (e[0] - c[0]) / side;
765
+ const uy = (e[1] - c[1]) / side;
766
+ const sa = (a[0] - c[0]) * ux + (a[1] - c[1]) * uy;
767
+ const sb = (b[0] - c[0]) * ux + (b[1] - c[1]) * uy;
768
+ let t0 = 0;
769
+ let t1 = 1;
770
+ if (sa === sb) {
771
+ if (sa < 0 || sa > side) continue;
772
+ } else {
773
+ const p = (0 - sa) / (sb - sa);
774
+ const q = (side - sa) / (sb - sa);
775
+ t0 = Math.max(0, Math.min(p, q));
776
+ t1 = Math.min(1, Math.max(p, q));
777
+ }
778
+ if ((t1 - t0) * length <= BAND_CONTACT_TOLERANCE) continue;
779
+ const off = (t: number): number => Math.abs(ux * (a[1] + (b[1] - a[1]) * t - c[1]) - uy * (a[0] + (b[0] - a[0]) * t - c[0]));
780
+ if (Math.abs(off(t0) - transition) <= BAND_CONTACT_TOLERANCE && Math.abs(off(t1) - transition) <= BAND_CONTACT_TOLERANCE) return true;
781
+ }
782
+ return false;
783
+ }
784
+
785
+ /**
786
+ * [agreed, spine-parts#126] Is the edge `a`–`b` held by `region` — does the
787
+ * region's density bound (`MQ_MAX_EDGE` or `MQ_TRANSITION`) apply to it? The
788
+ * one definition both `measureMeshQuality` and `reduceMesh`'s refinement read.
789
+ *
790
+ * The region's active domain is its closed polygon and, when `transition > 0`,
791
+ * the band of that width outside it. An edge is held when it meets that domain,
792
+ * except for one case: its intersection with the domain is a **single point on
793
+ * the band's outer boundary** and the rest of the edge lies outside — an edge
794
+ * that only touches the band from outside is exempt.
795
+ *
796
+ * - An edge that meets the closed polygon is held, whatever the band.
797
+ * - With `transition: 0` there is no outer band, and a contact with the
798
+ * authored boundary is held under the closed-region rule — never exempt.
799
+ * - A positive-length intersection is held, including a segment lying along
800
+ * the band's outer boundary.
801
+ * - Two separate touches of the outer boundary are two points, not one, and
802
+ * the edge is held.
803
+ *
804
+ * "On the outer boundary" is within `BAND_CONTACT_TOLERANCE`. Each region is
805
+ * read on its own: an exemption from one never removes another's bound.
806
+ */
807
+ export function edgeIsHeldByRegion(a: readonly [number, number], b: readonly [number, number], region: RefinementRegion): boolean {
808
+ const poly: readonly Pt[] = region.polygon;
809
+ if (segmentMeetsPolygon(a, b, poly)) return true;
810
+ const transition = region.transition;
811
+ if (!(transition > 0)) return false;
812
+ const d = segmentToPolygon(a, b, poly);
813
+ if (d > transition) return false;
814
+ if (d < transition - BAND_CONTACT_TOLERANCE) return true;
815
+ if (runsAlongOuterBoundary(a, b, poly, transition)) return true;
816
+ // Where on the edge each polygon side is touched: one point, or several apart.
817
+ const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
818
+ let first = Infinity;
819
+ let last = -Infinity;
820
+ const n = poly.length;
821
+ for (let i = 0; i < n; i++) {
822
+ const c = poly[i];
823
+ const e = poly[(i + 1) % n];
824
+ if (segmentDistance(a, b, c, e) > transition) continue;
825
+ const t = nearestParameter(a, b, c, e);
826
+ first = Math.min(first, t);
827
+ last = Math.max(last, t);
828
+ }
829
+ return (last - first) * length > BAND_CONTACT_TOLERANCE;
830
+ }
831
+
711
832
  /** Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. */
712
833
  function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number } {
713
834
  let d = Infinity;
@@ -1201,9 +1322,12 @@ interface Edge {
1201
1322
  * graded band, only where the band has width) and `MQ_FILL_DISTANCE` (`h(R)`,
1202
1323
  * sampled, informational).
1203
1324
  *
1204
- * One bound per edge, the smallest applicable anywhere on it (P16): `L0` of
1205
- * every region whose closed polygon it meets, and `L0 + grade·d` of every band
1206
- * it lies in, `d` its distance from that region. A row's value is the edge whose
1325
+ * Which edges a region holds is `edgeIsHeldByRegion`'s answer and nothing
1326
+ * else's: an edge that only touches the band's outer boundary at one point,
1327
+ * from outside, is not held (spine-parts#126). One bound per edge, the smallest
1328
+ * applicable anywhere on it (P16): `L0` of every region whose closed polygon it
1329
+ * meets, and `L0 + grade·d` of every other region that holds it, `d` its
1330
+ * distance from that region. A row's value is the edge whose
1207
1331
  * length most exceeds its own bound — so the row fails exactly when some edge
1208
1332
  * it covers is over — and the row names that edge and that bound.
1209
1333
  */
@@ -1242,15 +1366,29 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1242
1366
  else refusals.set(region.name, `${refusal.code}: ${refusal.message}`);
1243
1367
  }
1244
1368
 
1245
- /** Every bound that applies to an edge, from every measurable region, and the smallest. */
1369
+ /**
1370
+ * Per measurable region, per edge (in `edges` order): the edge's distance
1371
+ * from the closed polygon when the region holds it (`edgeIsHeldByRegion`,
1372
+ * the one definition the refinement reads too), null when it does not.
1373
+ */
1374
+ const holds = new Map<RefinementRegion, Array<number | null>>();
1375
+ for (const region of live) {
1376
+ holds.set(
1377
+ region,
1378
+ edges.map((e) => (edgeIsHeldByRegion(points[e.a], points[e.b], region) ? segmentToPolygon(points[e.a], points[e.b], region.polygon) : null)),
1379
+ );
1380
+ }
1381
+ const edgeIndex = new Map(edges.map((e, i) => [e, i]));
1382
+
1383
+ /** Every bound that applies to an edge, from every measurable region that holds it, and the smallest. */
1246
1384
  const boundOf = (e: Edge): number | null => {
1247
1385
  let bound: number | null = null;
1386
+ const i = edgeIndex.get(e)!;
1248
1387
  for (const region of live) {
1249
- const d = segmentToPolygon(points[e.a], points[e.b], region.polygon);
1250
- let here: number | null = null;
1251
- if (d === 0) here = region.maxEdgeLength;
1252
- else if (region.transition > 0 && d <= region.transition) here = r6(region.maxEdgeLength + region.grade * d);
1253
- if (here !== null && (bound === null || here < bound)) bound = here;
1388
+ const d = holds.get(region)![i];
1389
+ if (d === null) continue;
1390
+ const here = d === 0 ? region.maxEdgeLength : r6(region.maxEdgeLength + region.grade * d);
1391
+ if (bound === null || here < bound) bound = here;
1254
1392
  }
1255
1393
  return bound;
1256
1394
  };
@@ -1280,7 +1418,8 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1280
1418
  continue;
1281
1419
  }
1282
1420
  const poly: Pt[] = region.polygon;
1283
- const inside = edges.filter((e) => segmentMeetsPolygon(points[e.a], points[e.b], poly));
1421
+ const held = holds.get(region)!;
1422
+ const inside = edges.filter((_, i) => held[i] === 0);
1284
1423
  const maxSpec = spec('MQ_MAX_EDGE', 'px', region.name);
1285
1424
  const inWorst = worstOf(inside);
1286
1425
  if (inWorst === null) {
@@ -1289,10 +1428,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1289
1428
  out.push(measuredRow(maxSpec, r6(inWorst.edge.length), { op: '<=', value: inWorst.bound }, { at: { edge: [inWorst.edge.a, inWorst.edge.b] } }, true));
1290
1429
  }
1291
1430
  if (region.transition > 0) {
1292
- const banded = edges.filter((e) => {
1293
- const d = segmentToPolygon(points[e.a], points[e.b], poly);
1294
- return d > 0 && d <= region.transition;
1295
- });
1431
+ const banded = edges.filter((_, i) => held[i] !== null && held[i]! > 0);
1296
1432
  const bandWorst = worstOf(banded);
1297
1433
  const transitionSpec = spec('MQ_TRANSITION', 'px', region.name);
1298
1434
  if (bandWorst === null) {
@@ -1443,7 +1579,7 @@ function effectiveJson(e: EffectiveSettings): Json {
1443
1579
  frame: frameJson(a.frame),
1444
1580
  maskSize: pair(a.maskSize),
1445
1581
  minArtSamples: a.minArtSamples,
1446
- regions: a.regions.map((r) => ({ name: r.name, minArtSamples: r.minArtSamples })),
1582
+ regions: a.regions.map((r): Json => (r.polygon === undefined ? { name: r.name, minArtSamples: r.minArtSamples } : { name: r.name, minArtSamples: r.minArtSamples, polygon: r.polygon.map(pair) })),
1447
1583
  })),
1448
1584
  sourceBounds: fitJson(e.sourceBounds),
1449
1585
  referenceArtFit: fitJson(e.referenceArtFit),
@@ -1498,6 +1634,7 @@ function rowJson(r: MeasureRow): Json {
1498
1634
  };
1499
1635
  }
1500
1636
  if (r.sampling !== undefined) out.sampling = { domain: r.sampling.domain, count: r.sampling.count };
1637
+ if (r.motion !== undefined) out.motion = motionDetailJson(r.motion);
1501
1638
  return out;
1502
1639
  }
1503
1640
 
@@ -1518,7 +1655,7 @@ function candidateJson(c: CandidateReport): Json {
1518
1655
  id: c.id,
1519
1656
  counts: countsJson(c.counts),
1520
1657
  geometry: c.geometry === null ? null : sectionJson(c.geometry),
1521
- motion: c.motion === null ? null : { ...sectionJson(c.motion), schedule: scheduleJson(c.motion.schedule) },
1658
+ motion: c.motion === null ? null : { ...sectionJson(c.motion), schedule: scheduleUsedJson(c.motion.schedule) },
1522
1659
  accepted: c.accepted,
1523
1660
  };
1524
1661
  if (c.perFrame !== undefined) out.perFrame = c.perFrame.map((p) => ({ code: p.code, frame: p.frame, value: p.value }));
@@ -1569,3 +1706,97 @@ export function writeMeshQualityReport(report: MeshQualityReport): string {
1569
1706
  };
1570
1707
  return `${JSON.stringify(doc, null, 2)}\n`;
1571
1708
  }
1709
+
1710
+ // ---------------------------------------------------------------------------
1711
+ // stage C — the motion section's own fields (issue #1230, `src/meshcompare.ts`)
1712
+ // ---------------------------------------------------------------------------
1713
+ //
1714
+ // Additive: nothing above reads these, a `measure` and a `reduce` never write
1715
+ // them, and every one is written only when present.
1716
+
1717
+ /** One group of frames a motion row was also taken over — a phase, or a role (P11). */
1718
+ export interface MotionReading {
1719
+ /** The group's worst value; null when no frame of the group drew the attachment. */
1720
+ value: number | null;
1721
+ /** The value against the row's bound, by the row's own rule; `undeclared` with no bound, `not-measurable` with no value. */
1722
+ state: MeasureState;
1723
+ /** The frame the group's worst value was taken at, by `FrameRef.id`. */
1724
+ frame: string | null;
1725
+ }
1726
+
1727
+ /**
1728
+ * How a motion row's value was taken over the schedule (stage C). The row's own
1729
+ * `value` is the worst over every frame measured; these keep apart what the
1730
+ * contract says must not be folded together.
1731
+ */
1732
+ export interface MotionRowDetail {
1733
+ /** Frames at which the attachment was drawn and measured, and the ids of those at which its slot drew something else or nothing. */
1734
+ frames: { measured: number; notDrawn: string[] };
1735
+ /**
1736
+ * §4 *Transition in time*: the row taken per sample phase — `grid` and `irr`
1737
+ * for the frames a rate generated, `null` for the setup frame and for frames
1738
+ * the caller gave as explicit times (no phase applies to those).
1739
+ */
1740
+ byPhase: Array<{ phase: 'grid' | 'irr' | null } & MotionReading>;
1741
+ /** When one phase passes and another fails: the worst frame of each, by id, and a sentence naming both. Null otherwise. */
1742
+ phasesDisagree: { pass: string; fail: string; sentence: string } | null;
1743
+ /** P11: the row per role. `heldOut` is null when no held-out frame was measured — no held-out claim is made. */
1744
+ byRole: { baseline: MotionReading | null; selection: MotionReading | null; heldOut: MotionReading | null };
1745
+ /**
1746
+ * Correction 4, world rows: the declared setup map of the attachment's slot
1747
+ * bone — its world 2×2 at the setup pose, `[a, b, c, d]` as the core poses it
1748
+ * from the bones' declared fields — and its two singular scales. Never a
1749
+ * single world-per-pixel ratio, and never fitted to output vertices.
1750
+ */
1751
+ setupMap?: { bone: string; linear: [number, number, number, number]; singularScales: [number, number] };
1752
+ /** §3: samples no non-degenerate UV triangle carries, listed by UV — in the reference's mesh and in this candidate's. */
1753
+ uncarried?: { reference: Array<[number, number]>; candidate: Array<[number, number]> };
1754
+ /** §3, `MQ_INVERSION` on a slot `invariants.deformMayFold` names: every reversed triangle with its frame — listed, never zeroed. */
1755
+ folds?: Array<{ triangle: number; frame: string }>;
1756
+ /** Triangles that have no area at the setup pose (the A39 band), so no sign and no stretch is read off them. */
1757
+ degenerateAtSetup?: number;
1758
+ }
1759
+
1760
+ /** What a motion section walked from the schedule it was given (`ScheduleUsed`). */
1761
+ export interface ScheduleWalked {
1762
+ /** Every frame the schedule names, in walk order, each with its role (P11). */
1763
+ walked: FrameRef[];
1764
+ /** The roles among those frames — what the section has evidence for. */
1765
+ roles: Array<FrameRef['role']>;
1766
+ /** P11: true only when at least one held-out frame, disjoint from `selection`, was walked. */
1767
+ heldOutClaim: boolean;
1768
+ /** P10: every walk resets physics at time 0 and steps from there, the same steps for the reference and every candidate. */
1769
+ reset: 'physics reset at time 0';
1770
+ /** Each walk: the animation, the phase (null for explicit times), and how many steps reached its last frame. */
1771
+ walks: Array<{ animation: string; phase: 'grid' | 'irr' | null; steps: number }>;
1772
+ }
1773
+
1774
+ function readingJson(r: MotionReading | null): Json {
1775
+ return r === null ? null : { value: r.value, state: r.state, frame: r.frame };
1776
+ }
1777
+
1778
+ function motionDetailJson(m: MotionRowDetail): Json {
1779
+ const out: { [key: string]: Json } = {
1780
+ frames: { measured: m.frames.measured, notDrawn: [...m.frames.notDrawn] },
1781
+ byPhase: m.byPhase.map((p) => ({ phase: p.phase, value: p.value, state: p.state, frame: p.frame })),
1782
+ phasesDisagree: m.phasesDisagree === null ? null : { pass: m.phasesDisagree.pass, fail: m.phasesDisagree.fail, sentence: m.phasesDisagree.sentence },
1783
+ byRole: { baseline: readingJson(m.byRole.baseline), selection: readingJson(m.byRole.selection), heldOut: readingJson(m.byRole.heldOut) },
1784
+ };
1785
+ if (m.setupMap !== undefined) out.setupMap = { bone: m.setupMap.bone, linear: [...m.setupMap.linear], singularScales: pair(m.setupMap.singularScales) };
1786
+ if (m.uncarried !== undefined) out.uncarried = { reference: m.uncarried.reference.map(pair), candidate: m.uncarried.candidate.map(pair) };
1787
+ if (m.folds !== undefined) out.folds = m.folds.map((f) => ({ triangle: f.triangle, frame: f.frame }));
1788
+ if (m.degenerateAtSetup !== undefined) out.degenerateAtSetup = m.degenerateAtSetup;
1789
+ return out;
1790
+ }
1791
+
1792
+ function scheduleUsedJson(s: ScheduleUsed): Json {
1793
+ const given = scheduleJson(s) as { [key: string]: Json };
1794
+ return {
1795
+ ...given,
1796
+ walked: s.walked.map(frameRefJson),
1797
+ roles: [...s.roles],
1798
+ heldOutClaim: s.heldOutClaim,
1799
+ reset: s.reset,
1800
+ walks: s.walks.map((w) => ({ animation: w.animation, phase: w.phase, steps: w.steps })),
1801
+ };
1802
+ }
package/src/meshreduce.ts CHANGED
@@ -42,9 +42,13 @@
42
42
  * to meet `L(R)`, and removing one is undoing the refinement.
43
43
  *
44
44
  * The refinement measures, takes the first failing region row in report order
45
- * (code, then region name) and splits that row's worst edge: at its midpoint
46
- * when the midpoint lies in the region or its band, otherwise at the point of
47
- * the edge inside them nearest the midpoint. A region no edge meets — one
45
+ * (code, then region name) and splits that row's worst edge. When an end of
46
+ * the edge lies outside the region and its band, the split is where the edge
47
+ * leaves the band, so the piece to that end only touches the band's outer
48
+ * boundary and `edgeIsHeldByRegion` — the one definition the rows read —
49
+ * exempts it (spine-parts#126). Otherwise the split is at the midpoint when
50
+ * the midpoint lies in the region or its band, else at the point of the edge
51
+ * inside them nearest the midpoint. A region no edge meets — one
48
52
  * inside a single triangle — gets the first vertex of its polygon inserted into
49
53
  * the triangle that holds it. Every inserted position is on the `r6` grid.
50
54
  *
@@ -79,6 +83,8 @@ import {
79
83
  checkHullOrder,
80
84
  } from './mesh.ts';
81
85
  import {
86
+ BAND_CONTACT_TOLERANCE,
87
+ edgeIsHeldByRegion,
82
88
  measureMeshQuality,
83
89
  type AttachmentRef,
84
90
  type ArtFitBounds,
@@ -159,6 +165,9 @@ const ON_BOUNDARY = 1e-9;
159
165
  /** How many halvings toward a point known to lie in a region's band the split search tries before it says none was found. */
160
166
  const SPLIT_SEARCH_STEPS = 40;
161
167
 
168
+ /** Iterations of the convex searches `offsetExit` runs along an edge: 2^-60 of an edge is below a double's resolution of it. */
169
+ const EXIT_SEARCH_STEPS = 60;
170
+
162
171
  function nameOf(ref: AttachmentRef): string {
163
172
  return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
164
173
  }
@@ -797,6 +806,64 @@ function splitPoint(a: Pt, b: Pt, region: RefinementRegion): Pt | null {
797
806
  return null;
798
807
  }
799
808
 
809
+ /**
810
+ * The parameter, from `inner` (0) to `outer` (1), of the point of the edge
811
+ * nearest `outer` that lies within `level` of `region`'s polygon — where the
812
+ * edge last leaves that offset of the polygon on its way to `outer` — or null
813
+ * when no point of it comes that near. `outer` lies further than `level`, so
814
+ * walking back from it the offset is first met where some polygon side is
815
+ * exactly `level` away: the largest such parameter over the sides. Each side's
816
+ * distance is convex along the edge, so its minimum is found by golden section
817
+ * and the last parameter within `level` by bisection from there towards `outer`.
818
+ */
819
+ function offsetExit(inner: Pt, outer: Pt, poly: readonly Pt[], level: number): number | null {
820
+ const at = (t: number): Pt => [inner[0] + (outer[0] - inner[0]) * t, inner[1] + (outer[1] - inner[1]) * t];
821
+ const g = (Math.sqrt(5) - 1) / 2;
822
+ let best: number | null = null;
823
+ for (let i = 0; i < poly.length; i++) {
824
+ const c = poly[i];
825
+ const e = poly[(i + 1) % poly.length];
826
+ const f = (t: number): number => distanceToSegment(at(t), c, e);
827
+ let lo = 0;
828
+ let hi = 1;
829
+ for (let k = 0; k < EXIT_SEARCH_STEPS; k++) {
830
+ const p = hi - g * (hi - lo);
831
+ const q = lo + g * (hi - lo);
832
+ if (f(p) <= f(q)) hi = q;
833
+ else lo = p;
834
+ }
835
+ let inside = (lo + hi) / 2;
836
+ if (f(inside) > level) continue;
837
+ let outside = 1;
838
+ for (let k = 0; k < EXIT_SEARCH_STEPS; k++) {
839
+ const mid = (inside + outside) / 2;
840
+ if (f(mid) <= level) inside = mid;
841
+ else outside = mid;
842
+ }
843
+ if (best === null || inside > best) best = inside;
844
+ }
845
+ return best;
846
+ }
847
+
848
+ /**
849
+ * [agreed, spine-parts#126] A split of `inner`–`outer` where it leaves
850
+ * `region`'s band, so that the piece to `outer` only touches the band's outer
851
+ * boundary and is exempt by `edgeIsHeldByRegion` — the one definition the
852
+ * measurement reads. The split is put half of `BAND_CONTACT_TOLERANCE` inside
853
+ * the outer boundary, then on the `r6` grid, so it lies inside the band (P16's
854
+ * checkable form) and within the tolerance of its edge. Null when there is no
855
+ * band, no point of the edge comes that near the polygon, or the point found
856
+ * is an end or fails either condition — each re-checked rather than assumed.
857
+ */
858
+ function outerSplit(inner: Pt, outer: Pt, region: RefinementRegion): Pt | null {
859
+ if (!(region.transition > 0)) return null;
860
+ const t = offsetExit(inner, outer, region.polygon, region.transition - BAND_CONTACT_TOLERANCE / 2);
861
+ if (t === null) return null;
862
+ const q: Pt = [r6(inner[0] + (outer[0] - inner[0]) * t), r6(inner[1] + (outer[1] - inner[1]) * t)];
863
+ if ((q[0] === inner[0] && q[1] === inner[1]) || (q[0] === outer[0] && q[1] === outer[1])) return null;
864
+ return inRegionOrBand(q, region) && !edgeIsHeldByRegion(q, outer, region) ? q : null;
865
+ }
866
+
800
867
  /** Add a vertex at `p` with what interpolation gives it, refusing it under a deform run (P18). */
801
868
  function addVertex(run: Run, p: Pt): number {
802
869
  const got = interpolate(p, run.input, run.boneRank, run.tally, run.who);
@@ -892,16 +959,35 @@ function refineRegions(run: Run): PhaseEnd {
892
959
  const [ra, rb] = target.worst!.at.edge!;
893
960
  const a = canon.order[ra];
894
961
  const b = canon.order[rb];
895
- // An end outside the region and its band further beyond the band than the edge's own bound: every
896
- // split keeps an edge from a vertex inside them to that end, so no insertion P16 allows can meet it.
962
+ // An end outside the region and its band: split where the edge leaves the band, so the piece to that
963
+ // end only touches the band's outer boundary and is exempt (spine-parts#126) — the same predicate the
964
+ // measurement reads decides it, so the next measurement agrees.
965
+ let exited = false;
966
+ for (const [inner, outer] of [[b, a], [a, b]] as const) {
967
+ if (inRegionOrBand(run.work.pos[outer], region)) continue;
968
+ const q = outerSplit(run.work.pos[inner], run.work.pos[outer], region);
969
+ if (q === null) continue;
970
+ splitEdge(run, a, b, q);
971
+ exited = true;
972
+ break;
973
+ }
974
+ if (exited) continue;
975
+ // An end outside the region and its band further beyond it than the edge's own bound, with no split on
976
+ // the band's outer boundary that frees the piece to it — always so with transition 0, where a contact
977
+ // with the authored boundary stays held: every split keeps a held edge from a vertex inside the region
978
+ // and its band to that end, so no insertion P16 allows can meet it.
897
979
  for (const [end, id] of [[ra, a], [rb, b]] as const) {
898
980
  const at = run.work.pos[id];
899
981
  if (inRegionOrBand(at, region)) continue;
900
982
  const beyond = distanceToBoundary(at, region.polygon) - region.transition;
901
983
  if (beyond > target.bound!.value) {
984
+ const why =
985
+ region.transition > 0
986
+ ? 'no point of the edge on the band\'s outer boundary leaves the piece to it exempt, so an edge from inside the region and its band to it stays over the bound'
987
+ : 'with no band, an edge from the region to it touches the authored boundary and stays held (spine-parts#126), so it stays over the bound';
902
988
  return {
903
989
  kind: 'stuck',
904
- constraint: `${rowName(target)} (vertex ${end} of edge ${ra}–${rb} lies ${r6(beyond)} px beyond region "${region.name}"'s ${region.transition} px band, further than the edge's bound ${target.bound!.value}: an edge from inside the region and its band to it stays over the bound, and the refinement inserts only inside them — P16)`,
990
+ constraint: `${rowName(target)} (vertex ${end} of edge ${ra}–${rb} lies ${r6(beyond)} px beyond region "${region.name}"'s ${region.transition} px band, further than the edge's bound ${target.bound!.value}: ${why}, and the refinement inserts only inside them — P16)`,
905
991
  };
906
992
  }
907
993
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The package's own metadata — its installed version and repository — read
3
+ * once from the `package.json` beside the code.
4
+ *
5
+ * Moved here unchanged from `src/cli/shared.ts` (issue #1230), which imports
6
+ * these back and re-exports every name it exported before, so no caller
7
+ * changed an import. They moved because the motion comparison
8
+ * (`src/meshcompare.ts`) records the version of the poser that ran
9
+ * (`MeshQualityReport.poser`, P2 of docs/MESH_REDUCTION.md), and a library
10
+ * module that read it through `src/cli/shared.ts` would load the whole CLI —
11
+ * the compiler, `check` and every command body — to read one string. One
12
+ * reader, in the one place both can reach.
13
+ *
14
+ * Imports nothing but `node:fs` and `node:path`: no clock, no runtime.
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ interface PackageMeta {
20
+ version?: string;
21
+ repository?: string | { url?: string };
22
+ }
23
+
24
+ let packageMeta: PackageMeta | null | undefined;
25
+
26
+ /**
27
+ * The directory `cli.ts` sits in: `package.json`, `skills/` and, in a
28
+ * checkout, `examples/` and `scripts/` are beside it, in the repository and
29
+ * once installed. One level above this file (`src/`) — the same directory
30
+ * `src/cli/shared.ts` reached two levels above itself before the reader moved.
31
+ */
32
+ export const PACKAGE_ROOT = join(import.meta.dir, '..');
33
+
34
+ /** `package.json` sits next to `cli.ts` both in the repo and once installed (`PACKAGE_ROOT`). */
35
+ export function readPackageMeta(): PackageMeta | null {
36
+ if (packageMeta === undefined) {
37
+ try {
38
+ packageMeta = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as PackageMeta;
39
+ } catch {
40
+ packageMeta = null;
41
+ }
42
+ }
43
+ return packageMeta;
44
+ }
45
+
46
+ export function readVersion(): string {
47
+ return readPackageMeta()?.version ?? 'unknown';
48
+ }