rig-c 2.32.2 → 2.33.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-c",
3
- "version": "2.32.2",
3
+ "version": "2.33.0",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,7 +71,7 @@ import {
71
71
  } from './mesh.ts';
72
72
  import { areaBand, triangleAreas } from './areaband.ts';
73
73
  import { allocationContrast, boundaryNecessityOnce, deformLoad, gradeMax, minAngleP10, type AllocationArt, type AllocationPlant } from './meshallocation.ts';
74
- import { skinningEchoOf, skinningResidual, validateSkinning, type ReductionSkinning, type SkinningDetail, type SkinningEcho, type SkinningPlant, type SkinningResidualInput } from './meshskinning.ts';
74
+ import { skinningEchoOf, skinningResidual, sourceMeshDigest, validateSkinning, type ReductionSkinning, type SkinningDetail, type SkinningEcho, type SkinningPlant, type SkinningResidualInput } from './meshskinning.ts';
75
75
  import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type RegionEdgeReading, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
76
76
  import { cropToSpineY } from './transform.ts';
77
77
 
@@ -294,6 +294,45 @@ export interface MeshReductionInput {
294
294
  * mesh, `acceptedAt`, the termination and every other row are the call's without it (docs/MESH_REDUCTION.md §8).
295
295
  */
296
296
  motionAmplitude?: MotionAmplitude | null;
297
+ /**
298
+ * Named interior feature lines (issue #1326, docs/MESH_REDUCTION.md §11) — opt-in. Left out = no line is declared
299
+ * and the call is the one it was before the field existed, byte for byte. Set, each line's vertices may thin under
300
+ * its own `MQ_LINE_DEVIATION` bound: a line vertex is removed only by a step after which the line's row is within
301
+ * `maxDeviation` and its two kept neighbours are joined by an edge of the result; the triangulation post-pass flips
302
+ * no line edge; and, under `boundaryRuns`, a run of consecutive kept line vertices may be replaced by one chord as
303
+ * one step (`acceptedAt` kind `'line-run'`). An open line's two ends and a vertex two lines list are kept. The
304
+ * result's measurement carries one row per line, in declared order. Refused `REDUCE_INPUT_LINE`, before any work,
305
+ * when a line is not well formed against `source` (`validateLines`); `null` is refused like any value that is not
306
+ * a list. `protect.edges` keeps its meaning: a line held whole is listed there.
307
+ */
308
+ lines?: NamedLine[];
309
+ }
310
+
311
+ /**
312
+ * Issue #1326: an interior feature line of a source mesh — a chain of source vertices, each consecutive pair (and on a
313
+ * closed line the last–first pair) an edge of a source triangle. It lives on the reduction's and the measurement's
314
+ * input rather than on `SourceMesh`, so no field every measurement and comparison reads (and `sourceMeshDigest`
315
+ * hashes) changes.
316
+ */
317
+ export interface NamedLine {
318
+ /** The caller's name, echoed in the row and in every refusal; rigc reads nothing into it. Unique among the input's lines. */
319
+ name: string;
320
+ /** Source vertex indices in order along the line, each once. */
321
+ vertices: number[];
322
+ /** Closed: the last joins the first, and at least 3 vertices. Open: two ends, at least 2 vertices. */
323
+ closed: boolean;
324
+ /** The bound `MQ_LINE_DEVIATION` is held to, drawing px, finite, 0 or more. Required: there is no default and no declared-absent form. */
325
+ maxDeviation: number;
326
+ }
327
+
328
+ /**
329
+ * Issue #1326: the source a measurement reads its lines against — the mesh their indices are into, by the caller's id.
330
+ * The line's kept chain on the measured mesh is read by position: a line vertex is kept when the measured mesh has a
331
+ * vertex at its exact position, as every vertex a reduction keeps does (P19: a survivor is never resampled).
332
+ */
333
+ export interface LineSource {
334
+ id: string;
335
+ mesh: SourceMesh;
297
336
  }
298
337
 
299
338
  /**
@@ -328,11 +367,11 @@ export interface BoundaryRuns {
328
367
  export interface AcceptedOperation {
329
368
  /** The attempt number — `candidatesTried` as it stood when the operation was taken, 1-based. */
330
369
  step: number;
331
- /** A refinement insertion, one source vertex removed, or a boundary run replaced by one chord. */
332
- kind: 'insertion' | 'removal' | 'boundary-run';
333
- /** Vertices the operation inserted (an insertion: 1) or removed (a removal: 1; a boundary run: 2 or more). */
370
+ /** A refinement insertion, one source vertex removed, a boundary run replaced by one chord, or (issue #1326) a run of a line's kept vertices replaced by one chord. */
371
+ kind: 'insertion' | 'removal' | 'boundary-run' | 'line-run';
372
+ /** Vertices the operation inserted (an insertion: 1) or removed (a removal: 1; a boundary run or a line run: 2 or more). */
334
373
  count: number;
335
- /** The source indices it removed, in outline order for a run — each `null` in `indexMap`; empty for an insertion. */
374
+ /** The source indices it removed, in outline order for a boundary run and in the line's listed order for a line run — each `null` in `indexMap`; empty for an insertion. */
336
375
  sourceVertices: number[];
337
376
  }
338
377
 
@@ -390,6 +429,17 @@ export interface MeshMeasureInput {
390
429
  * full is refused `REDUCE_INPUT_MISSING` naming its path, before any work.
391
430
  */
392
431
  skinning?: SkinningResidualInput | null;
432
+ /**
433
+ * Issue #1326 (docs/MESH_REDUCTION.md §11): named lines over `lineSource`'s vertices — the same `NamedLine` list a
434
+ * reduction takes. Left out, no line row is written and the report is the one written without the field, byte for
435
+ * byte. Set, `lineSource` is required, and the report gains one required `MQ_LINE_DEVIATION` row per line, in
436
+ * declared order: the symmetric Hausdorff distance between the line's kept chain on this mesh and its polyline on
437
+ * `lineSource`. Refused `REDUCE_INPUT_LINE`, before any work, as a reduction refuses it; `null` is refused like any
438
+ * value that is not a list.
439
+ */
440
+ lines?: NamedLine[];
441
+ /** Issue #1326: the mesh `lines` index into, by the caller's id — required with `lines` and refused without it. Echoed by id and digest. */
442
+ lineSource?: LineSource;
393
443
  }
394
444
 
395
445
  /**
@@ -441,8 +491,8 @@ export type MeasureState = 'pass' | 'fail' | 'undeclared' | 'refused' | 'not-mea
441
491
  export interface MeasureRow {
442
492
  /** Stable code, e.g. `MQ_COVERAGE`. */
443
493
  code: string;
444
- /** The object measured: attachment always; region when the row is a region's. */
445
- object: { attachment: AttachmentRef; region: string | null };
494
+ /** The object measured: attachment always; region when the row is a region's; `line` on `MQ_LINE_DEVIATION` only (issue #1326), the line's name. */
495
+ object: { attachment: AttachmentRef; region: string | null; line?: string };
446
496
  state: MeasureState;
447
497
  /** The measured value: present on pass, fail and undeclared; null otherwise. */
448
498
  value: number | null;
@@ -696,6 +746,10 @@ export interface EffectiveSettings {
696
746
  skinning?: SkinningEcho | null;
697
747
  /** A comparison's `overBound`, echoed when the input set it (issue #1315); absent otherwise. */
698
748
  overBound?: OverBoundRequest;
749
+ /** A measurement's or a reduction's `lines`, echoed when the input set it (issue #1326); absent otherwise. */
750
+ lines?: NamedLine[];
751
+ /** A measurement's `lineSource`, by id and `sourceMeshDigest`, echoed when the input set it (issue #1326); absent otherwise. */
752
+ lineSource?: { id: string; digest: string };
699
753
  }
700
754
 
701
755
  export interface MeshCounts {
@@ -950,6 +1004,34 @@ function validateInput(input: MeshMeasureInput): void {
950
1004
  }
951
1005
  validateMotionAmplitude(who, input.motionAmplitude);
952
1006
  validateSkinning(who, input.skinning);
1007
+ validateMeasureLines(who, input);
1008
+ }
1009
+
1010
+ /**
1011
+ * Issue #1326: a measurement's `lines` and `lineSource`, refused `REDUCE_INPUT_LINE` before any work — the two together
1012
+ * or neither, the source well formed enough to index, then the lines against it (`validateLines`).
1013
+ */
1014
+ function validateMeasureLines(who: string, input: MeshMeasureInput): void {
1015
+ const { lines, lineSource } = input;
1016
+ if (lines === undefined) {
1017
+ if (lineSource !== undefined) refuse('REDUCE_INPUT_LINE', `${who}: lineSource is set and lines is left out; required lines with it, or neither — a source no line reads is not an input`);
1018
+ return;
1019
+ }
1020
+ const mesh: unknown = isObject(lineSource) ? lineSource.mesh : undefined;
1021
+ const ok =
1022
+ isObject(lineSource) &&
1023
+ typeof lineSource.id === 'string' &&
1024
+ lineSource.id !== '' &&
1025
+ isObject(mesh) &&
1026
+ Array.isArray(mesh.points) &&
1027
+ mesh.points.every(isPoint) &&
1028
+ Array.isArray(mesh.triangles) &&
1029
+ mesh.triangles.every((v: unknown) => Number.isInteger(v)) &&
1030
+ Number.isInteger(mesh.hull);
1031
+ if (!ok) {
1032
+ refuse('REDUCE_INPUT_LINE', `${who}: lineSource is ${lineSource === undefined ? 'missing (undefined)' : JSON.stringify(isObject(lineSource) ? { id: lineSource.id, mesh: isObject(mesh) ? '{…}' : mesh } : lineSource)}; required { id: a non-empty string, mesh: the SourceMesh the lines index into } whenever lines is set`);
1033
+ }
1034
+ validateLines(who, 'lines', lines, lineSource!.mesh);
953
1035
  }
954
1036
 
955
1037
  /**
@@ -986,6 +1068,161 @@ export function validateMotionAmplitude(who: string, amplitude: unknown): void {
986
1068
  });
987
1069
  }
988
1070
 
1071
+ /**
1072
+ * Issue #1326 (docs/MESH_REDUCTION.md §11): `lines` refused `REDUCE_INPUT_LINE` before any work, against the mesh the
1073
+ * indices are into — `MeshReductionInput.source`, or `MeshMeasureInput.lineSource.mesh`. `field` is the path the
1074
+ * messages name. Each refusal names the line (its index and name), the index or pair, and the rule, in this order:
1075
+ * the list and each line's shape (a non-empty name, `closed` a boolean, `maxDeviation` a finite number 0 or more —
1076
+ * left out and `null` each named as itself, since neither is a bound), its vertex count (closed 3 or more, open 2 or
1077
+ * more), each index a source vertex, no vertex twice, each consecutive pair (closed: and the last–first) an edge of a
1078
+ * source triangle, no line vertex whose position another source vertex shares (the measurement reads a kept vertex by
1079
+ * its position); then names unique across the lines; then each segment inside the source's outline (its hull
1080
+ * polygon, the boundary counting as inside); then no two segments meeting other than at a vertex both list — two
1081
+ * lines that cross list the crossing as one shared vertex, and two that run together list every vertex of the run.
1082
+ * Nothing is resolved here or later: a crossing is the caller's to list. Shared by the reduction and the measurement,
1083
+ * so both refuse one value in the same words; merely exported, as `validateMotionAmplitude` is.
1084
+ */
1085
+ export function validateLines(who: string, field: string, lines: unknown, mesh: { points: ReadonlyArray<readonly [number, number]>; triangles: readonly number[]; hull: number }): void {
1086
+ function no(message: string): never {
1087
+ return refuse('REDUCE_INPUT_LINE', `${who}: ${message}`);
1088
+ }
1089
+ if (!Array.isArray(lines)) no(`${field} is ${JSON.stringify(lines)}; required a list of { name, vertices, closed, maxDeviation }, or the field left out (issue #1326: no line is declared)`);
1090
+ const list = lines as unknown[];
1091
+ const n = mesh.points.length;
1092
+ const edges = new Set<number>();
1093
+ for (let t = 0; t + 2 < mesh.triangles.length; t += 3) {
1094
+ for (let k = 0; k < 3; k++) {
1095
+ const a = mesh.triangles[t + k];
1096
+ const b = mesh.triangles[t + ((k + 1) % 3)];
1097
+ edges.add(a < b ? a * n + b : b * n + a);
1098
+ }
1099
+ }
1100
+ const at = new Map<string, number[]>();
1101
+ mesh.points.forEach((p, v) => {
1102
+ const key = `${p[0]},${p[1]}`;
1103
+ const here = at.get(key);
1104
+ if (here === undefined) at.set(key, [v]);
1105
+ else here.push(v);
1106
+ });
1107
+ const named: NamedLine[] = [];
1108
+ list.forEach((line, i) => {
1109
+ const it = `${field}[${i}]`;
1110
+ if (!isObject(line)) no(`${it} is ${JSON.stringify(line)}; required { name, vertices, closed, maxDeviation }`);
1111
+ if (typeof line.name !== 'string' || line.name === '') no(`${it}.name is ${JSON.stringify(line.name)}; required a non-empty string, unique among the lines — the row and every refusal name the line by it`);
1112
+ const said = `${it} ("${line.name}")`;
1113
+ if (typeof line.closed !== 'boolean') no(`${said}.closed is ${JSON.stringify(line.closed)}; required true (the last vertex joins the first) or false (two ends)`);
1114
+ const bound = line.maxDeviation;
1115
+ if (bound === undefined) no(`${said}.maxDeviation is missing (undefined); required a finite number of drawing px, 0 or more — the bound MQ_LINE_DEVIATION is held to, which has no default`);
1116
+ if (bound === null) no(`${said}.maxDeviation is null; required a finite number of drawing px, 0 or more — a line declares its own bound and has no declared-absent form (a line held whole is protect.edges; a line with no bound is not declared)`);
1117
+ if (!isFiniteNumber(bound) || bound < 0) no(`${said}.maxDeviation is ${typeof bound === 'number' ? String(bound) : JSON.stringify(bound)}; required a finite number of drawing px, 0 or more`);
1118
+ const vs = line.vertices;
1119
+ if (!Array.isArray(vs)) no(`${said}.vertices is ${JSON.stringify(vs)}; required a list of source vertex indices in order along the line`);
1120
+ const vertices = vs as unknown[];
1121
+ const least = line.closed ? 3 : 2;
1122
+ if (vertices.length < least) no(`${said} is ${line.closed ? 'closed' : 'open'} with ${vertices.length} vertex(es); required at least ${least} — ${line.closed ? 'a closed line is a polygon' : 'an open line has two ends'}`);
1123
+ vertices.forEach((v, k) => {
1124
+ if (!Number.isInteger(v) || (v as number) < 0 || (v as number) >= n) no(`${said}.vertices[${k}] is ${JSON.stringify(v)}; required a source vertex index in 0..${n - 1}`);
1125
+ });
1126
+ const ids = vertices as number[];
1127
+ const first = new Map<number, number>();
1128
+ ids.forEach((v, k) => {
1129
+ const was = first.get(v);
1130
+ if (was !== undefined) no(`${said} lists vertex ${v} twice (vertices[${was}] and vertices[${k}]); required each vertex once — a line that touches itself is refused, never split`);
1131
+ first.set(v, k);
1132
+ });
1133
+ const pairs = line.closed ? ids.length : ids.length - 1;
1134
+ for (let k = 0; k < pairs; k++) {
1135
+ const a = ids[k];
1136
+ const b = ids[(k + 1) % ids.length];
1137
+ if (!edges.has(a < b ? a * n + b : b * n + a)) {
1138
+ no(`${said}: the pair ${a}–${b} (vertices[${k}], vertices[${(k + 1) % ids.length}]) is not an edge of a source triangle; required each consecutive pair${line.closed ? ', and the last–first pair,' : ''} to be one — the same test protect.edges applies`);
1139
+ }
1140
+ }
1141
+ for (const v of ids) {
1142
+ const p = mesh.points[v];
1143
+ const all = at.get(`${p[0]},${p[1]}`)!;
1144
+ if (all.length > 1) no(`${said}: vertex ${v} at (${p[0]}, ${p[1]}) shares its position with source vertex ${all.find((u) => u !== v)}; required a line vertex at a position no other source vertex has — a measurement reads a kept line vertex by its position`);
1145
+ }
1146
+ named.push({ name: line.name, vertices: ids, closed: line.closed, maxDeviation: bound as number });
1147
+ });
1148
+ const byName = new Map<string, number>();
1149
+ named.forEach((line, i) => {
1150
+ const was = byName.get(line.name);
1151
+ if (was !== undefined) no(`${field}[${i}] is named "${line.name}", as ${field}[${was}] is; required one name per line — its row is keyed by it`);
1152
+ byName.set(line.name, i);
1153
+ });
1154
+ const hull = mesh.points.slice(0, Math.max(0, mesh.hull));
1155
+ type Segment = { line: number; a: number; b: number; lo: [number, number]; hi: [number, number] };
1156
+ const segments: Segment[] = [];
1157
+ named.forEach((line, i) => {
1158
+ const pairs = line.closed ? line.vertices.length : line.vertices.length - 1;
1159
+ for (let k = 0; k < pairs; k++) {
1160
+ const a = line.vertices[k];
1161
+ const b = line.vertices[(k + 1) % line.vertices.length];
1162
+ if (hull.length >= 3) {
1163
+ const off = segmentLeavesPolygon(mesh.points[a], mesh.points[b], hull);
1164
+ if (off !== null) no(`${field}[${i}] ("${line.name}"): the segment ${a}–${b} leaves the source's outline at (${r6(off[0])}, ${r6(off[1])}); required every segment inside the hull polygon — a hull vertex on a line is allowed, a segment off the art is not`);
1165
+ }
1166
+ const p = mesh.points[a];
1167
+ const q = mesh.points[b];
1168
+ segments.push({ line: i, a, b, lo: [Math.min(p[0], q[0]), Math.min(p[1], q[1])], hi: [Math.max(p[0], q[0]), Math.max(p[1], q[1])] });
1169
+ }
1170
+ });
1171
+ const slack = 1e-9;
1172
+ for (let s = 0; s < segments.length; s++) {
1173
+ const u = segments[s];
1174
+ for (let t = s + 1; t < segments.length; t++) {
1175
+ const w = segments[t];
1176
+ // A shared endpoint is a vertex both lines list (each segment's ends are its own line's vertices): a meeting there is the crossing or the run, declared.
1177
+ if (u.a === w.a || u.a === w.b || u.b === w.a || u.b === w.b) continue;
1178
+ if (u.hi[0] < w.lo[0] - slack || w.hi[0] < u.lo[0] - slack || u.hi[1] < w.lo[1] - slack || w.hi[1] < u.lo[1] - slack) continue;
1179
+ if (segmentsMeet(mesh.points[u.a], mesh.points[u.b], mesh.points[w.a], mesh.points[w.b])) {
1180
+ const lu = named[u.line];
1181
+ const lw = named[w.line];
1182
+ no(
1183
+ `${field}[${u.line}] ("${lu.name}")'s segment ${u.a}–${u.b} meets ${u.line === w.line ? 'its own' : `${field}[${w.line}] ("${lw.name}")'s`} segment ${w.a}–${w.b} other than at a vertex both list; required two lines that cross to list the crossing as one shared source vertex, and a line not to cross itself — rig-c resolves no crossing`,
1184
+ );
1185
+ }
1186
+ }
1187
+ }
1188
+ }
1189
+
1190
+ /**
1191
+ * Issue #1326: where the segment p–q leaves the closed polygon `poly` (the boundary counting as inside), or null when
1192
+ * it does not — the ends tested, then the midpoint of every piece between the parameters at which it meets an edge or
1193
+ * passes a polygon vertex, so a segment that leaves through a notch and comes back is found whether or not it crosses
1194
+ * an edge properly.
1195
+ */
1196
+ function segmentLeavesPolygon(p: Pt, q: Pt, poly: readonly Pt[]): Pt | null {
1197
+ if (!inClosedPolygon(p, poly)) return p;
1198
+ if (!inClosedPolygon(q, poly)) return q;
1199
+ const dx = q[0] - p[0];
1200
+ const dy = q[1] - p[1];
1201
+ const len2 = dx * dx + dy * dy;
1202
+ if (len2 === 0) return null;
1203
+ const ts = [0, 1];
1204
+ const along = (r: Pt): number => ((r[0] - p[0]) * dx + (r[1] - p[1]) * dy) / len2;
1205
+ const m = poly.length;
1206
+ for (let i = 0; i < m; i++) {
1207
+ const c = poly[i];
1208
+ const d = poly[(i + 1) % m];
1209
+ if (distanceToSegment(c, p, q) <= ON_BOUNDARY) ts.push(along(c));
1210
+ if (!segmentsMeet(p, q, c, d)) continue;
1211
+ const ex = d[0] - c[0];
1212
+ const ey = d[1] - c[1];
1213
+ const den = dx * ey - dy * ex;
1214
+ if (den !== 0) ts.push(((c[0] - p[0]) * ey - (c[1] - p[1]) * ex) / den);
1215
+ }
1216
+ const cuts = ts.filter((t) => t >= 0 && t <= 1).sort((a, b) => a - b);
1217
+ for (let k = 0; k + 1 < cuts.length; k++) {
1218
+ if (cuts[k + 1] - cuts[k] <= 1e-12) continue;
1219
+ const t = (cuts[k] + cuts[k + 1]) / 2;
1220
+ const mid: Pt = [p[0] + dx * t, p[1] + dy * t];
1221
+ if (!inClosedPolygon(mid, poly)) return mid;
1222
+ }
1223
+ return null;
1224
+ }
1225
+
989
1226
  // ---------------------------------------------------------------------------
990
1227
  // plane geometry
991
1228
  // ---------------------------------------------------------------------------
@@ -1269,12 +1506,16 @@ export function regionEdgeBound(a: readonly [number, number], b: readonly [numbe
1269
1506
  return bound;
1270
1507
  }
1271
1508
 
1272
- /** Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. */
1273
- function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number } {
1509
+ /**
1510
+ * Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. `closed: false`
1511
+ * (issue #1326) reads an open polyline — no closing edge; one point is its own degenerate edge.
1512
+ */
1513
+ function pointToBoundary(p: Pt, poly: readonly Pt[], closed = true): { d: number; edge: number } {
1274
1514
  let d = Infinity;
1275
1515
  let edge = 0;
1276
1516
  const n = poly.length;
1277
- for (let i = 0; i < n; i++) {
1517
+ const edges = closed || n === 1 ? n : n - 1;
1518
+ for (let i = 0; i < edges; i++) {
1278
1519
  const v = distanceToSegment(p, poly[i], poly[(i + 1) % n]);
1279
1520
  if (v < d) {
1280
1521
  d = v;
@@ -1295,26 +1536,32 @@ function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number
1295
1536
  * of those maxima bounds it from above. A piece whose bound cannot beat the best
1296
1537
  * value already found is dropped; the rest are halved. No sampling density is
1297
1538
  * chosen, so nothing here can miss a maximum between two samples.
1539
+ *
1540
+ * `aClosed` / `bClosed` false (issue #1326, `MQ_LINE_DEVIATION`) read that side as an open polyline: its edges are
1541
+ * the consecutive pairs and no closing one, and a single point is its own degenerate edge. Both true is the outline
1542
+ * rows' reading, edge for edge as before the flags.
1298
1543
  */
1299
- function directedHausdorff(a: readonly Pt[], b: readonly Pt[], carried: ((p: Pt) => Float64Array) | null = null): { d: number; edge: number; at: Pt } {
1544
+ function directedHausdorff(a: readonly Pt[], b: readonly Pt[], carried: ((p: Pt) => Float64Array) | null = null, aClosed = true, bClosed = true): { d: number; edge: number; at: Pt } {
1300
1545
  const m = b.length;
1546
+ const mEdges = bClosed || m === 1 ? m : m - 1;
1301
1547
  const toEach =
1302
1548
  carried ??
1303
1549
  ((p: Pt): Float64Array => {
1304
- const out = new Float64Array(m);
1305
- for (let j = 0; j < m; j++) out[j] = distanceToSegment(p, b[j], b[(j + 1) % m]);
1550
+ const out = new Float64Array(mEdges);
1551
+ for (let j = 0; j < mEdges; j++) out[j] = distanceToSegment(p, b[j], b[(j + 1) % m]);
1306
1552
  return out;
1307
1553
  });
1308
1554
  const minOf = (g: Float64Array): number => {
1309
1555
  let v = Infinity;
1310
- for (let j = 0; j < m; j++) v = Math.min(v, g[j]);
1556
+ for (let j = 0; j < mEdges; j++) v = Math.min(v, g[j]);
1311
1557
  return v;
1312
1558
  };
1313
1559
  let best = -1;
1314
1560
  let bestEdge = 0;
1315
1561
  let bestAt: Pt = a[0];
1316
1562
  const n = a.length;
1317
- for (let i = 0; i < n; i++) {
1563
+ const nEdges = aClosed || n === 1 ? n : n - 1;
1564
+ for (let i = 0; i < nEdges; i++) {
1318
1565
  const p0 = a[i];
1319
1566
  const p1 = a[(i + 1) % n];
1320
1567
  const at = (t: number): Pt => [p0[0] + (p1[0] - p0[0]) * t, p0[1] + (p1[1] - p0[1]) * t];
@@ -1333,7 +1580,7 @@ function directedHausdorff(a: readonly Pt[], b: readonly Pt[], carried: ((p: Pt)
1333
1580
  while (stack.length > 0) {
1334
1581
  const piece = stack.pop()!;
1335
1582
  let upper = Infinity;
1336
- for (let j = 0; j < m; j++) upper = Math.min(upper, Math.max(piece.g0[j], piece.g1[j]));
1583
+ for (let j = 0; j < mEdges; j++) upper = Math.min(upper, Math.max(piece.g0[j], piece.g1[j]));
1337
1584
  if (upper <= best + HAUSDORFF_TOLERANCE || piece.depth >= 64) continue;
1338
1585
  const tm = (piece.t0 + piece.t1) / 2;
1339
1586
  const gm = toEach(at(tm));
@@ -1351,11 +1598,54 @@ function directedHausdorff(a: readonly Pt[], b: readonly Pt[], carried: ((p: Pt)
1351
1598
  * taken on when the worst point is the candidate's, else the candidate edge
1352
1599
  * nearest the reference's worst point.
1353
1600
  */
1354
- function hausdorff(candidate: readonly Pt[], reference: readonly Pt[], carried: { forward: (p: Pt) => Float64Array; backward: (p: Pt) => Float64Array } | null = null): { d: number; candidateEdge: number } {
1355
- const forward = directedHausdorff(candidate, reference, carried?.forward ?? null);
1356
- const backward = directedHausdorff(reference, candidate, carried?.backward ?? null);
1601
+ function hausdorff(candidate: readonly Pt[], reference: readonly Pt[], carried: { forward: (p: Pt) => Float64Array; backward: (p: Pt) => Float64Array } | null = null, closed = true): { d: number; candidateEdge: number } {
1602
+ const forward = directedHausdorff(candidate, reference, carried?.forward ?? null, closed, closed);
1603
+ const backward = directedHausdorff(reference, candidate, carried?.backward ?? null, closed, closed);
1357
1604
  if (forward.d >= backward.d) return { d: forward.d, candidateEdge: forward.edge };
1358
- return { d: backward.d, candidateEdge: pointToBoundary(backward.at, candidate).edge };
1605
+ return { d: backward.d, candidateEdge: pointToBoundary(backward.at, candidate, closed).edge };
1606
+ }
1607
+
1608
+ /**
1609
+ * Issue #1326: `MQ_LINE_DEVIATION`'s distance — the symmetric Hausdorff distance between a line's kept chain and its
1610
+ * source polyline, by the outline rows' own function (`hausdorff`, exact to `HAUSDORFF_TOLERANCE`), a closed line as
1611
+ * two polygons and an open one as two open polylines, with the chain edge the worst value belongs to. The reduction
1612
+ * reads it on the chain an attempt would leave before it builds anything, and the row reads it on the measured mesh:
1613
+ * one function of the same points in the same order, so the two are the same number.
1614
+ */
1615
+ export function lineDeviation(kept: ReadonlyArray<readonly [number, number]>, reference: ReadonlyArray<readonly [number, number]>, closed: boolean): { d: number; chainEdge: number } {
1616
+ const hd = hausdorff(kept, reference, null, closed);
1617
+ return { d: hd.d, chainEdge: hd.candidateEdge };
1618
+ }
1619
+
1620
+ /**
1621
+ * Issue #1326: one `MQ_LINE_DEVIATION` row per line, in declared order. `reference` holds the points the lines' indices
1622
+ * are into; `keptAt(v)` is the measured mesh's index of line vertex v, or null when the mesh does not keep it. The row:
1623
+ * `object.line` the name and `object.region` null, drawing px, bound `<= maxDeviation`, required; the value the
1624
+ * distance on the `r6` grid; the worst sample the chain edge it belongs to (as the measured mesh's indices; one kept
1625
+ * vertex is its own `vertex`), nothing when the value is 0; `not-measurable` when the mesh keeps no vertex of the line.
1626
+ * Merely exported — the reduction reads its steps' rows through it, so no row is written twice.
1627
+ */
1628
+ export function lineDeviationRows(attachment: AttachmentRef, lines: readonly NamedLine[], reference: ReadonlyArray<readonly [number, number]>, keptAt: (v: number) => number | null): MeasureRow[] {
1629
+ return lines.map((line): MeasureRow => {
1630
+ const object = { attachment, region: null, line: line.name };
1631
+ const chain = line.vertices.filter((v) => keptAt(v) !== null);
1632
+ if (chain.length === 0) {
1633
+ const reason = `attachment ${nameOf(attachment)}: line "${line.name}" has no vertex the measured mesh keeps (none of its ${line.vertices.length} source positions is a vertex of it), so there is no chain to measure`;
1634
+ return { code: 'MQ_LINE_DEVIATION', object, state: 'not-measurable', value: null, bound: null, unit: 'px', worst: null, reason };
1635
+ }
1636
+ const hd = lineDeviation(
1637
+ chain.map((v) => reference[v]),
1638
+ line.vertices.map((v) => reference[v]),
1639
+ line.closed,
1640
+ );
1641
+ const value = r6(hd.d);
1642
+ const bound = { op: '<=' as const, value: line.maxDeviation };
1643
+ let worst: WorstSample = NOTHING_WORSE;
1644
+ if (hd.d !== 0) {
1645
+ worst = chain.length === 1 ? { at: { vertex: keptAt(chain[0])! } } : { at: { edge: [keptAt(chain[hd.chainEdge])!, keptAt(chain[(hd.chainEdge + 1) % chain.length])!] } };
1646
+ }
1647
+ return { code: 'MQ_LINE_DEVIATION', object, state: judged(value, bound), value, bound, unit: 'px', worst, reason: null };
1648
+ });
1359
1649
  }
1360
1650
 
1361
1651
  /** A point as a key: both coordinates' shortest round-trip decimals, a negative zero told from a positive one. */
@@ -1852,6 +2142,19 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1852
2142
  rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${traced.refused}`, false));
1853
2143
  }
1854
2144
 
2145
+ // --- named lines (issue #1326): only when the input declared them, one row per line in declared order ---------
2146
+ // `compareRows` ties them (one code, region null, no connectivity) and the sort is stable, so the order is the input's.
2147
+ if (input.lines !== undefined) {
2148
+ const lineSourceMesh = input.lineSource!.mesh;
2149
+ const byPosition = new Map<string, number>();
2150
+ points.forEach((p, v) => {
2151
+ const key = `${p[0]},${p[1]}`;
2152
+ if (!byPosition.has(key)) byPosition.set(key, v);
2153
+ });
2154
+ const keptAt = (v: number): number | null => byPosition.get(`${lineSourceMesh.points[v][0]},${lineSourceMesh.points[v][1]}`) ?? null;
2155
+ for (const row of lineDeviationRows(attachment, input.lines, lineSourceMesh.points, keptAt)) rows.push({ row, required: true });
2156
+ }
2157
+
1855
2158
  // --- triangles: sign, degeneracy, angle --------------------------------------------------
1856
2159
  const world: number[] = [];
1857
2160
  for (const [x, y] of points) world.push(x, cropToSpineY(y, frame.height));
@@ -2398,6 +2701,8 @@ function effectiveOf(input: MeshMeasureInput, skinningPlant: SkinningPlant | nul
2398
2701
  budget: null,
2399
2702
  ...(input.motionAmplitude === undefined ? {} : { motionAmplitude: input.motionAmplitude }),
2400
2703
  ...(input.skinning === undefined ? (skinningPlant === 'echo-when-unset' ? { skinning: null } : {}) : { skinning: skinningEchoOf(input.skinning) }),
2704
+ ...(input.lines === undefined ? {} : { lines: input.lines }),
2705
+ ...(input.lineSource === undefined ? {} : { lineSource: { id: input.lineSource.id, digest: sourceMeshDigest(input.lineSource.mesh) } }),
2401
2706
  };
2402
2707
  }
2403
2708
 
@@ -2498,6 +2803,8 @@ function effectiveJson(e: EffectiveSettings): Json {
2498
2803
  ...(e.removalOrder === undefined ? {} : { removalOrder: e.removalOrder }),
2499
2804
  ...(e.skinning === undefined ? {} : { skinning: skinningEchoJson(e.skinning) }),
2500
2805
  ...(e.overBound === undefined ? {} : { overBound: { maxSamples: e.overBound.maxSamples, triangles: e.overBound.triangles } }),
2806
+ ...(e.lines === undefined ? {} : { lines: e.lines.map((l): Json => ({ name: l.name, vertices: [...l.vertices], closed: l.closed, maxDeviation: l.maxDeviation })) }),
2807
+ ...(e.lineSource === undefined ? {} : { lineSource: { id: e.lineSource.id, digest: e.lineSource.digest } }),
2501
2808
  };
2502
2809
  }
2503
2810
 
@@ -2521,7 +2828,7 @@ function worstJson(w: WorstSample | null): Json {
2521
2828
  function rowJson(r: MeasureRow): Json {
2522
2829
  const out: { [key: string]: Json } = {
2523
2830
  code: r.code,
2524
- object: { attachment: attachmentJson(r.object.attachment), region: r.object.region },
2831
+ object: r.object.line === undefined ? { attachment: attachmentJson(r.object.attachment), region: r.object.region } : { attachment: attachmentJson(r.object.attachment), region: r.object.region, line: r.object.line },
2525
2832
  state: r.state,
2526
2833
  value: r.value,
2527
2834
  bound: r.bound === null ? null : { op: r.bound.op, value: r.bound.value },
package/src/meshreduce.ts CHANGED
@@ -140,6 +140,8 @@ import {
140
140
  measureMeshQualityStep,
141
141
  measureMeshQualityWith,
142
142
  validateMotionAmplitude,
143
+ lineDeviationRows,
144
+ validateLines,
143
145
  type AcceptedOperation,
144
146
  type AttachmentRef,
145
147
  type ArtFitBounds,
@@ -152,6 +154,7 @@ import {
152
154
  type MeshQualityReport,
153
155
  type MeshReductionInput,
154
156
  type MotionAmplitude,
157
+ type NamedLine,
155
158
  type RefinementRegion,
156
159
  type ReductionChanges,
157
160
  type Retriangulation,
@@ -349,6 +352,12 @@ function validateReduction(input: MeshReductionInput, plant: ReductionPlant | nu
349
352
  const src = input.source;
350
353
  if (!isObject(src) || !Array.isArray(src.points)) refuse('REDUCE_INPUT_MISSING', `${who}: source is not { points, uvs, triangles, hull, weights }`);
351
354
  const n = src.points.length;
355
+ // Issue #1326: refused before any work, in the measurement's words. A source too malformed to index is left to the
356
+ // admission measurement, which refuses it by its own code before anything reads a line.
357
+ if ('lines' in input && input.lines !== undefined) {
358
+ const readable = src.points.every((q) => Array.isArray(q) && q.length === 2 && isFiniteNumber(q[0]) && isFiniteNumber(q[1])) && Array.isArray(src.triangles) && src.triangles.every((v) => Number.isInteger(v)) && Number.isInteger(src.hull);
359
+ if (readable || !Array.isArray(input.lines)) validateLines(who, 'lines', input.lines, src);
360
+ }
352
361
 
353
362
  const p = input.protect;
354
363
  if (p === undefined || p === null || !isObject(p)) refuse('REDUCE_INPUT_MISSING', `${who}: protect is ${JSON.stringify(p)}; required a ProtectedFeatures — a reduction has no default protection (P20)`);
@@ -950,6 +959,17 @@ interface Run {
950
959
  skinning: SkinningRun | null;
951
960
  /** Issue #1295: the controls' faults and inspector, or null. */
952
961
  hooks: SkinningHooks | null;
962
+ /** Issue #1326: the declared lines, or null when the input left `lines` out. */
963
+ lines: LineRun | null;
964
+ }
965
+
966
+ /** Issue #1326: the input's lines as the steps read them. */
967
+ interface LineRun {
968
+ lines: NamedLine[];
969
+ /** Source vertex → the indices of the lines that list it, ascending. */
970
+ of: Map<number, number[]>;
971
+ /** Per line, source vertex → its position in the line's list. */
972
+ at: Array<Map<number, number>>;
953
973
  }
954
974
 
955
975
  /**
@@ -975,7 +995,7 @@ export type SkinningVetoPlant = CarryPlant | 'full-recompute' | 'post-pass-unche
975
995
 
976
996
  /** One decision the residual took part in, as a control reads it (issue #1295). */
977
997
  export interface SkinningInspection {
978
- phase: 'insertion' | 'removal' | 'boundary-run' | 'delaunay';
998
+ phase: 'insertion' | 'removal' | 'boundary-run' | 'line-run' | 'delaunay';
979
999
  /** `candidatesTried` when it was decided. */
980
1000
  step: number;
981
1001
  /** Whether the working mesh now holds the trial (an insertion always does). */
@@ -1017,6 +1037,8 @@ function measureAgainstTargets(run: Run, mesh: SourceMesh, id: string, final = f
1017
1037
  preset: input.preset,
1018
1038
  ...amplitudeOf(run.input, run.plant, final),
1019
1039
  ...(final ? skinningOfResult(run) : {}),
1040
+ // Issue #1326: the result's own measurement carries the line rows; every step reads them beside it (`lineRowsOf`).
1041
+ ...(final && input.lines !== undefined ? { lines: input.lines, lineSource: { id: 'source', mesh: input.source } } : {}),
1020
1042
  };
1021
1043
  return run.stepRasters === null ? measureMeshQualityWith(measureInput, run.rasters) : measureMeshQualityStep(measureInput, run.stepRasters);
1022
1044
  }
@@ -1109,10 +1131,10 @@ function amplitudeOf(input: MeshReductionInput, plant: ReductionPlant | null, fi
1109
1131
  }
1110
1132
 
1111
1133
  /** The order constraints are named in when several fail on one step: structure, then shape, then art, then density. */
1112
- const BLOCKING_ORDER = ['MQ_ORIENTATION', 'MQ_DEGENERATE', 'MQ_BOUNDARY_DEVIATION', 'MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT', 'MQ_MIN_ANGLE', 'MQ_MAX_EDGE', 'MQ_TRANSITION'];
1134
+ const BLOCKING_ORDER = ['MQ_ORIENTATION', 'MQ_DEGENERATE', 'MQ_BOUNDARY_DEVIATION', 'MQ_LINE_DEVIATION', 'MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT', 'MQ_MIN_ANGLE', 'MQ_MAX_EDGE', 'MQ_TRANSITION'];
1113
1135
 
1114
1136
  function rowName(row: MeasureRow): string {
1115
- const region = row.object.region === null ? '' : `[${row.object.region}]`;
1137
+ const region = row.object.region !== null ? `[${row.object.region}]` : row.object.line === undefined ? '' : `[line ${row.object.line}]`;
1116
1138
  if (row.state === 'fail') return `${row.code}${region}: ${row.value} against ${row.bound!.op} ${row.bound!.value}`;
1117
1139
  return `${row.code}${region}: ${row.state} — ${row.reason ?? ''}`;
1118
1140
  }
@@ -1123,8 +1145,11 @@ function rowName(row: MeasureRow): string {
1123
1145
  * art bound declared `null` is not (`artBoundAbsent`, issue #1254); the
1124
1146
  * overshoot row gated is the 8-connected one (P12).
1125
1147
  */
1126
- function firstBlockingRow(report: MeshQualityReport, artFit: ArtFitBounds): MeasureRow | null {
1127
- const rows = report.candidates[0]?.geometry?.rows ?? [];
1148
+ function firstBlockingRow(report: MeshQualityReport, artFit: ArtFitBounds, lineRows: readonly MeasureRow[] = []): MeasureRow | null {
1149
+ // Issue #1326: a step's line rows are read beside the measurement's (`lineRowsOf`), never by it — the rows of the lines
1150
+ // the step touches; every other line's row is unchanged and passed when its last step was taken.
1151
+ const measured = report.candidates[0]?.geometry?.rows ?? [];
1152
+ const rows = lineRows.length === 0 ? measured : [...measured, ...lineRows];
1128
1153
  const required = (r: MeasureRow): boolean => {
1129
1154
  if (artBoundAbsent(artFit, r)) return false;
1130
1155
  if (r.code === 'MQ_OVERSHOOT') return r.art?.connectivity === 8;
@@ -1426,7 +1451,7 @@ type PhaseEnd =
1426
1451
  * unreplayed run held after that step.
1427
1452
  */
1428
1453
  function accept(run: Run, kind: AcceptedOperation['kind'], sourceVertices: number[]): boolean {
1429
- if (kind === 'boundary-run' && run.plant === 'run-split-per-vertex') {
1454
+ if ((kind === 'boundary-run' || kind === 'line-run') && run.plant === 'run-split-per-vertex') {
1430
1455
  for (const v of sourceVertices) run.acceptedAt.push({ step: run.steps, kind: 'removal', count: 1, sourceVertices: [v] });
1431
1456
  } else {
1432
1457
  run.acceptedAt.push({ step: run.steps, kind, count: kind === 'insertion' ? 1 : sourceVertices.length, sourceVertices: [...sourceVertices] });
@@ -1460,7 +1485,18 @@ export type ReductionPlant =
1460
1485
  | RetriangulationPlant
1461
1486
  | OrderPlant
1462
1487
  | AmplitudePlant
1463
- | RefinementPlant;
1488
+ | RefinementPlant
1489
+ | LinePlant;
1490
+
1491
+ /**
1492
+ * Issue #1326's faults in the line mechanism, for the `mesh-quality` suite's controls: the line rows read by no step
1493
+ * (neither before the structure nor beside the measurement); the hole a line vertex leaves ear-clipped whole rather
1494
+ * than along the line's chord, so only the edge check after it can refuse an unjoined chain; that check skipped as
1495
+ * well; the post-pass flipping line edges; a vertex two lines list left removable; the line-run sweep counting a
1496
+ * step at every start, whether or not it tries a run there; and the rows read before the structure half a pixel
1497
+ * short of each line's bound, so they refuse steps the rows would take.
1498
+ */
1499
+ export type LinePlant = 'line-rows-unread' | 'line-chord-unconstrained' | 'line-join-unchecked' | 'line-flips-unguarded' | 'line-crossings-unkept' | 'line-sweep-counts-steps' | 'line-early-short';
1464
1500
 
1465
1501
  /**
1466
1502
  * Issue #1311's faults in the refinement: the insertion its rule chose written without asking whether it writes a
@@ -1501,7 +1537,7 @@ export type AmplitudePlant =
1501
1537
  /** One removal-phase attempt as a control reads it (issue #1279): what was tried, and what refused it or null when taken. */
1502
1538
  export interface AttemptRecord {
1503
1539
  step: number;
1504
- kind: 'removal' | 'boundary-run';
1540
+ kind: 'removal' | 'boundary-run' | 'line-run';
1505
1541
  sourceVertices: number[];
1506
1542
  /** Null when taken; else reads the name of what refused it — measuring the attempt in full when the floor decided it. Read it only inside the observer, while the working mesh is the one the attempt was tried on. */
1507
1543
  refusedBy: (() => string) | null;
@@ -1618,7 +1654,7 @@ type Removal = { triangles: Array<[number, number, number]>; added: () => Array<
1618
1654
  * One removal made on `work`'s triangles: the triangles after it — every triangle not touching `v` as it was, the
1619
1655
  * same tuple, then the hole's new ones, `fresh` — or the structural reason it cannot be made.
1620
1656
  */
1621
- function removalStep(work: Work, v: number): { triangles: Array<[number, number, number]>; fresh: Array<[number, number, number]> } | { blocked: string } {
1657
+ function removalStep(work: Work, v: number, chords: readonly Chord[] = []): { triangles: Array<[number, number, number]>; fresh: Array<[number, number, number]> } | { blocked: string } {
1622
1658
  const star = work.triangles.filter((t) => t.includes(v));
1623
1659
  const next = new Map<number, number>();
1624
1660
  const incoming = new Set<number>();
@@ -1645,7 +1681,23 @@ function removalStep(work: Work, v: number): { triangles: Array<[number, number,
1645
1681
  if (ring.length !== (boundary ? next.size + 1 : next.size)) return { blocked: `retriangulation: vertex ${v}'s triangles do not form one fan` };
1646
1682
  const rest = work.triangles.filter((t) => !t.includes(v));
1647
1683
  const fresh: Array<[number, number, number]> = [];
1648
- if (ring.length >= 3) {
1684
+ if (ring.length >= 3 && chords.length > 0) {
1685
+ const poly = ring.map((id): [number, number] => [work.pos[id][0], work.pos[id][1]]);
1686
+ if (findSelfIntersection(poly) !== null) return { blocked: `retriangulation: the hole vertex ${v} leaves is not a strictly simple polygon` };
1687
+ const pieces = chordPieces(work, v, ring, poly, chords);
1688
+ if ('blocked' in pieces) return pieces;
1689
+ for (const piece of pieces.pieces) {
1690
+ const pp = piece.map((id): [number, number] => [work.pos[id][0], work.pos[id][1]]);
1691
+ let tris: number[];
1692
+ try {
1693
+ tris = earClip(pp);
1694
+ } catch (err) {
1695
+ if (!(err instanceof MeshError)) throw err;
1696
+ return { blocked: `retriangulation: the hole vertex ${v} leaves does not ear-clip on the side of a line's chord (${err.message})` };
1697
+ }
1698
+ for (let i = 0; i < tris.length; i += 3) fresh.push([piece[tris[i]], piece[tris[i + 1]], piece[tris[i + 2]]]);
1699
+ }
1700
+ } else if (ring.length >= 3) {
1649
1701
  const poly = ring.map((id): [number, number] => [work.pos[id][0], work.pos[id][1]]);
1650
1702
  if (findSelfIntersection(poly) !== null) return { blocked: `retriangulation: the hole vertex ${v} leaves is not a strictly simple polygon` };
1651
1703
  let tris: number[];
@@ -1662,9 +1714,54 @@ function removalStep(work: Work, v: number): { triangles: Array<[number, number,
1662
1714
  return { triangles: [...rest, ...fresh], fresh };
1663
1715
  }
1664
1716
 
1717
+ /**
1718
+ * Issue #1326: a line's chord through a removed vertex — its kept neighbours along the line, `a` before and `c` after
1719
+ * in listed order, which the hole the vertex leaves has to join by an edge.
1720
+ */
1721
+ interface Chord {
1722
+ a: number;
1723
+ c: number;
1724
+ line: string;
1725
+ }
1726
+
1727
+ /** Issue #1326: the chords a removal of `v` must keep, the vertices in `gone` already removed by the same operation; null when no line is declared. */
1728
+ type ChordsFor = ((v: number, gone: ReadonlySet<number>) => Chord[]) | null;
1729
+
1730
+ /**
1731
+ * Issue #1326: the hole `v` leaves cut along each chord in turn, so that each chord is a side of a piece and the pieces'
1732
+ * ear-clipped triangles hold it as an edge — or the line refusal naming the chord when it is not a diagonal of the
1733
+ * hole: its ends are not in one piece, or a piece it makes is not strictly simple or does not turn as the hole turns
1734
+ * (the chord leaves the hole, or runs through a vertex of it). A chord that is already a side is left as it is.
1735
+ */
1736
+ function chordPieces(work: Work, v: number, ring: readonly number[], poly: ReadonlyArray<readonly [number, number]>, chords: readonly Chord[]): { pieces: number[][] } | { blocked: string } {
1737
+ const turn = Math.sign(signedArea(poly.map((q): [number, number] => [q[0], q[1]])));
1738
+ const unjoinable = (ch: Chord): { blocked: string } => ({
1739
+ blocked: `line "${ch.line}" (a): the kept line vertices ${ch.a}–${ch.c} on either side of vertex ${v} cannot be joined — the chord is not a diagonal of the hole ${v} leaves`,
1740
+ });
1741
+ const pieces: number[][] = [[...ring]];
1742
+ for (const ch of chords) {
1743
+ const k = pieces.findIndex((pc) => pc.includes(ch.a) && pc.includes(ch.c));
1744
+ if (k === -1) return unjoinable(ch);
1745
+ const pc = pieces[k];
1746
+ const ia = pc.indexOf(ch.a);
1747
+ const ic = pc.indexOf(ch.c);
1748
+ const lo = Math.min(ia, ic);
1749
+ const hi = Math.max(ia, ic);
1750
+ if (hi - lo === 1 || (lo === 0 && hi === pc.length - 1)) continue;
1751
+ const one = pc.slice(lo, hi + 1);
1752
+ const two = [...pc.slice(hi), ...pc.slice(0, lo + 1)];
1753
+ for (const piece of [one, two]) {
1754
+ const pp = piece.map((id): [number, number] => [work.pos[id][0], work.pos[id][1]]);
1755
+ if (Math.sign(signedArea(pp)) !== turn || findSelfIntersection(pp) !== null) return unjoinable(ch);
1756
+ }
1757
+ pieces.splice(k, 1, one, two);
1758
+ }
1759
+ return { pieces };
1760
+ }
1761
+
1665
1762
  /** `removalStep` with the edges it adds: those of the hole's new triangles no triangle before it holds, in their order. */
1666
- function removalOf(work: Work, v: number, audit: EdgeSetAudit | null = null): Removal {
1667
- const step = removalStep(work, v);
1763
+ function removalOf(work: Work, v: number, audit: EdgeSetAudit | null = null, chordsFor: ChordsFor = null): Removal {
1764
+ const step = removalStep(work, v, chordsFor === null ? [] : chordsFor(v, new Set()));
1668
1765
  if ('blocked' in step) return step;
1669
1766
  const old = work.triangles;
1670
1767
  const n = work.alive.length;
@@ -1705,7 +1802,7 @@ function loadOrder(run: Run): { order: number[]; load: Map<number, number> } {
1705
1802
  const order: number[] = [];
1706
1803
  for (let v = 0; v < work.nSource; v++) {
1707
1804
  if (!work.alive[v] || run.protectedVertices.has(v)) continue;
1708
- const step = removalOf(work, v, run.edgeAudit);
1805
+ const step = removalOf(work, v, run.edgeAudit, chordsForRun(run, [v]));
1709
1806
  let most = 0;
1710
1807
  if ('blocked' in step) most = Infinity;
1711
1808
  else for (const [a, b] of step.added()) most = Math.max(most, edgeLoad(work, a, b));
@@ -1728,16 +1825,18 @@ function loadOrder(run: Run): { order: number[]; load: Map<number, number> } {
1728
1825
  * structural reason one of the removals cannot be made. The outline loses the run and gains the chord from the
1729
1826
  * vertex before it to the vertex after it; nothing is measured in between.
1730
1827
  */
1731
- function runRemovalOf(work: Work, vertices: readonly number[], audit: EdgeSetAudit | null = null): Removal {
1828
+ function runRemovalOf(work: Work, vertices: readonly number[], audit: EdgeSetAudit | null = null, chordsFor: ChordsFor = null): Removal {
1732
1829
  const was = work.triangles;
1733
1830
  // Issue #1307: each removal's own added edges are never read here, so none is built; what the run adds is read off
1734
1831
  // the triangles its removals made. A triangle of `after` that no removal made is the same tuple as one of `was`
1735
1832
  // (`removalStep` keeps every untouched triangle as it was), so every edge it holds is old and it adds nothing.
1736
1833
  const made = new Set<readonly number[]>();
1737
1834
  let after: Array<[number, number, number]>;
1835
+ const gone = new Set<number>();
1738
1836
  try {
1739
1837
  for (const v of vertices) {
1740
- const step = removalStep(work, v);
1838
+ const step = removalStep(work, v, chordsFor === null ? [] : chordsFor(v, gone));
1839
+ gone.add(v);
1741
1840
  if ('blocked' in step) return step;
1742
1841
  for (const t of step.fresh) made.add(t);
1743
1842
  work.triangles = step.triangles;
@@ -1840,6 +1939,40 @@ function removeVertices(run: Run): PhaseEnd {
1840
1939
  }
1841
1940
  }
1842
1941
  }
1942
+ // Issue #1326: line runs, under the same opt-in — each line in declared order, each start in its listed order, the
1943
+ // runs of 2 to `maxVertices` consecutive kept line vertices from it, longest first, never an open line's end or a
1944
+ // protected vertex, never leaving a closed line fewer than three. The first taken ends that start's sweep.
1945
+ if (runs !== undefined && run.lines !== null) {
1946
+ for (const line of run.lines.lines) {
1947
+ for (const s of line.vertices) {
1948
+ if (run.plant === 'line-sweep-counts-steps') run.steps++;
1949
+ if (!work.alive[s] || run.protectedVertices.has(s)) continue;
1950
+ const chain = line.vertices.filter((v) => work.alive[v]);
1951
+ const at = chain.indexOf(s);
1952
+ const most = line.closed ? Math.min(runs.maxVertices, chain.length - 3) : Math.min(runs.maxVertices, chain.length - 1 - at);
1953
+ for (let r = most; r >= 2; r--) {
1954
+ const members: number[] = [];
1955
+ for (let i = 0; i < r; i++) {
1956
+ const v = chain[(at + i) % chain.length];
1957
+ if (run.protectedVertices.has(v)) break;
1958
+ members.push(v);
1959
+ }
1960
+ if (members.length < r) continue;
1961
+ if (run.steps >= input.budget.maxCandidates) return { kind: 'budget' };
1962
+ run.steps++;
1963
+ tried++;
1964
+ const block = tryOperation(run, members, false, 'line-run');
1965
+ if (run.observe !== null) run.observe({ step: run.steps, kind: 'line-run', sourceVertices: [...members], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function' && block.decidedByFloor, pass, predictedLoad: null });
1966
+ if (block === null) {
1967
+ taken++;
1968
+ if (accept(run, 'line-run', members)) return { kind: 'replayed' };
1969
+ break;
1970
+ }
1971
+ lastBlock = { refusal: block, what: `removing source vertices ${members.join(', ')} of line "${line.name}" as one line run` };
1972
+ }
1973
+ }
1974
+ }
1975
+ }
1843
1976
  // Issue #1283: with the load order the pass's singles are ranked once, here, over the mesh the runs left; without
1844
1977
  // it they are every source index ascending, exactly as before the field.
1845
1978
  const ranked = byLoad ? loadOrder(run) : null;
@@ -1863,7 +1996,8 @@ function removeVertices(run: Run): PhaseEnd {
1863
1996
  if (taken === 0) {
1864
1997
  // A pass that took nothing left the working mesh as it found it, so a refusal the floor decided is measured
1865
1998
  // now in the state it was decided in.
1866
- const constraint = tried === 0 ? 'protect: every surviving source vertex is protected (protect.hull, protect.vertices, protect.edges, protect.regionBoundaries or a weightJump edge)' : lastName();
1999
+ const kept = run.lines === null ? '' : ", or an open line's end or a vertex two lines list";
2000
+ const constraint = tried === 0 ? `protect: every surviving source vertex is protected (protect.hull, protect.vertices, protect.edges, protect.regionBoundaries or a weightJump edge${kept})` : lastName();
1867
2001
  return { kind: 'stuck', constraint };
1868
2002
  }
1869
2003
  }
@@ -1982,7 +2116,15 @@ export interface FloorAudit {
1982
2116
  */
1983
2117
  function structureOf(run: Run, vertices: readonly number[]): string | { canon: Canonical; undo: () => void } {
1984
2118
  const { work, input } = run;
1985
- const step = vertices.length === 1 ? removalOf(work, vertices[0], run.edgeAudit) : runRemovalOf(work, vertices, run.edgeAudit);
2119
+ // Issue #1326: what a line asks of the structure — enough of it kept, its chord through each removed vertex an edge
2120
+ // of the hole's triangulation, and every consecutive pair of its kept vertices an edge after the step.
2121
+ const touched = run.lines === null ? [] : touchedLines(run, vertices);
2122
+ if (touched.length > 0) {
2123
+ const structural = lineStructure(run, vertices, touched);
2124
+ if (structural !== null) return structural;
2125
+ }
2126
+ const chordsFor = touched.length === 0 ? null : chordsForRun(run, vertices);
2127
+ const step = vertices.length === 1 ? removalOf(work, vertices[0], run.edgeAudit, chordsFor) : runRemovalOf(work, vertices, run.edgeAudit, chordsFor);
1986
2128
  if ('blocked' in step) return step.blocked;
1987
2129
  const jump = input.protect.weightJump;
1988
2130
  if (jump !== null) {
@@ -2012,6 +2154,24 @@ function structureOf(run: Run, vertices: readonly number[]): string | { canon: C
2012
2154
  }
2013
2155
  }
2014
2156
  }
2157
+ if (touched.length > 0 && run.plant !== 'line-join-unchecked') {
2158
+ const n = work.alive.length;
2159
+ const edges = new Set<number>();
2160
+ for (const t of work.triangles) for (let k = 0; k < 3; k++) edges.add(pairKey(t[k], t[(k + 1) % 3], n));
2161
+ for (const li of touched) {
2162
+ const line = run.lines!.lines[li];
2163
+ const chain = line.vertices.filter((v) => work.alive[v]);
2164
+ const pairs = line.closed ? chain.length : chain.length - 1;
2165
+ for (let k = 0; k < pairs; k++) {
2166
+ const a = chain[k];
2167
+ const b = chain[(k + 1) % chain.length];
2168
+ if (!edges.has(pairKey(a, b, n))) {
2169
+ undo();
2170
+ return `line "${line.name}" (a): the consecutive kept line vertices ${a}–${b} are no longer joined by an edge`;
2171
+ }
2172
+ }
2173
+ }
2174
+ }
2015
2175
  try {
2016
2176
  return { canon: canonicalise(work, run.sourceTurn, run.boneRank), undo };
2017
2177
  } catch (err) {
@@ -2074,7 +2234,7 @@ function auditFloors(audit: FloorAudit, run: Run, vertices: readonly number[], e
2074
2234
  * before the structure is built (`earlyFloor`); an attempt it refuses is not built at all unless an observer or the
2075
2235
  * floor audit asks what the path before would have decided.
2076
2236
  */
2077
- function tryOperation(run: Run, vertices: readonly number[], full: boolean): Refusal | null {
2237
+ function tryOperation(run: Run, vertices: readonly number[], full: boolean, kind: AttemptRecord['kind'] = vertices.length > 1 ? 'boundary-run' : 'removal'): Refusal | null {
2078
2238
  const { input } = run;
2079
2239
  const floored = !full && run.plant !== 'measure-every-candidate' && !(vertices.length > 1 && run.plant === 'run-skips-rows');
2080
2240
  const audit = run.floorAudit;
@@ -2100,18 +2260,40 @@ function tryOperation(run: Run, vertices: readonly number[], full: boolean): Ref
2100
2260
  return refusal;
2101
2261
  }
2102
2262
  }
2263
+ // Issue #1326: the rows of the lines the operation touches, read before any structure is built — they read nothing
2264
+ // but the line vertices' positions, which the operation fixes, so this is the row itself and not a bound on it, and
2265
+ // a refusal here is the one the measurement would make. Read, the refusal names what the full path names.
2266
+ const touched = run.lines === null || run.plant === 'line-rows-unread' ? [] : touchedLines(run, vertices);
2267
+ let lineRows: MeasureRow[] = [];
2268
+ if (floored && touched.length > 0) {
2269
+ const structural = lineStructure(run, vertices, touched);
2270
+ if (structural !== null) return structural;
2271
+ lineRows = lineRowsOf(run, vertices, touched);
2272
+ const short = run.plant === 'line-early-short' ? 0.5 : 0;
2273
+ if (lineRows.some((r) => r.state !== 'pass' || (short > 0 && r.value! > r.bound!.value - short))) {
2274
+ const members = [...vertices];
2275
+ const refusal = (() => {
2276
+ const measured = tryOperation(run, members, true, kind);
2277
+ if (measured === null) throw new Error(`a line row refused removing ${members.join(', ')}, which the full path accepts`);
2278
+ return refusalText(measured);
2279
+ }) as FloorRefusal;
2280
+ refusal.decidedByFloor = true;
2281
+ return refusal;
2282
+ }
2283
+ }
2103
2284
  const built = structureOf(run, vertices);
2104
2285
  if (typeof built === 'string') return built;
2105
2286
  const { canon, undo } = built;
2106
2287
  if (audit !== null && early !== null) auditFloors(audit, run, vertices, early, deviationFloor(run, canon.mesh, vertices), false);
2107
- const phase: SkinningInspection['phase'] = vertices.length > 1 ? 'boundary-run' : 'removal';
2288
+ const phase: SkinningInspection['phase'] = kind;
2108
2289
  if (vertices.length > 1 && run.plant === 'run-skips-rows') {
2109
2290
  followSkinning(run, phase);
2110
2291
  return null;
2111
2292
  }
2112
2293
  const measured = measureAgainstTargets(run, canon.mesh, 'candidate');
2294
+ if (touched.length > 0 && lineRows.length === 0) lineRows = lineRowsOf(run, vertices, touched);
2113
2295
  const blocking =
2114
- firstBlockingRow(measured, run.input.targets.artFit) ??
2296
+ firstBlockingRow(measured, run.input.targets.artFit, lineRows) ??
2115
2297
  (run.plant === 'amplitude-gates-steps' ? ((measured.candidates[0]?.geometry?.rows ?? []).find((r) => (r.code === 'MQ_DEFORM_LOAD' || r.code === 'MQ_ALLOCATION_CONTRAST') && r.state === 'undeclared') ?? null) : null);
2116
2298
  if (blocking !== null) {
2117
2299
  undo();
@@ -2201,6 +2383,14 @@ function delaunayFlips(run: Run, plant: ReductionPlant | null): FlipResult {
2201
2383
  const alive = work.alive.reduce((n, a) => n + (a ? 1 : 0), 0);
2202
2384
  const maxFlips = (alive * (alive - 1)) / 2;
2203
2385
  const guarded = new Set(run.protectedEdges.map(([a, b]) => edgeKey(a, b)));
2386
+ // Issue #1326: every edge between consecutive kept line vertices is held as a protected edge is — the pass flips none.
2387
+ if (run.lines !== null && plant !== 'line-flips-unguarded') {
2388
+ for (const line of run.lines.lines) {
2389
+ const chain = line.vertices.filter((v) => work.alive[v]);
2390
+ const pairs = line.closed ? chain.length : chain.length - 1;
2391
+ for (let k = 0; k < pairs; k++) guarded.add(edgeKey(chain[k], chain[(k + 1) % chain.length]));
2392
+ }
2393
+ }
2204
2394
  const jump = input.protect.weightJump;
2205
2395
  const regions = plant === 'flips-ignore-regions' ? [] : input.targets.regions;
2206
2396
  const heldCache = new Map<string, boolean>();
@@ -2497,8 +2687,10 @@ function reduceValidated(
2497
2687
  stepRasters: steps,
2498
2688
  skinning: null,
2499
2689
  hooks,
2690
+ lines: null,
2500
2691
  };
2501
2692
  run.skinning = skinningRunOf(run, skinningAdmitted);
2693
+ run.lines = lineRunOf(run);
2502
2694
 
2503
2695
  // Issue #1268: a replay ends in its own termination, never as an exhausted budget; a stop the run never reaches
2504
2696
  // leaves the run's own termination, which then carries `stopAfterAccepted` saying so.
@@ -2731,5 +2923,120 @@ function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sou
2731
2923
  ...(amplitude === undefined ? {} : { motionAmplitude: amplitude }),
2732
2924
  ...(input.retriangulate === undefined ? {} : { retriangulate: input.retriangulate }),
2733
2925
  ...(input.removalOrder === undefined ? {} : { removalOrder: input.removalOrder }),
2926
+ ...(input.lines === undefined ? {} : { lines: input.lines }),
2927
+ };
2928
+ }
2929
+
2930
+ // ---------------------------------------------------------------------------
2931
+ // named lines (issue #1326, docs/MESH_REDUCTION.md §11)
2932
+ // ---------------------------------------------------------------------------
2933
+
2934
+ /**
2935
+ * The input's lines as the steps read them, or null when `lines` was left out. An open line's two ends and every
2936
+ * vertex two lines list — a crossing or the end of a shared run — are added to the protected vertices: an end has one
2937
+ * neighbour on the line, so no chord can stand for it, and removing a shared vertex would make two chains disagree
2938
+ * about where they meet. Every other line vertex stays a candidate, held by the steps to its line.
2939
+ */
2940
+ function lineRunOf(run: Run): LineRun | null {
2941
+ const declared = run.input.lines;
2942
+ if (declared === undefined) return null;
2943
+ const of = new Map<number, number[]>();
2944
+ const at = declared.map((line, li) => {
2945
+ const here = new Map<number, number>();
2946
+ line.vertices.forEach((v, k) => {
2947
+ here.set(v, k);
2948
+ const list = of.get(v);
2949
+ if (list === undefined) of.set(v, [li]);
2950
+ else list.push(li);
2951
+ });
2952
+ return here;
2953
+ });
2954
+ for (const line of declared) {
2955
+ if (!line.closed) {
2956
+ run.protectedVertices.add(line.vertices[0]);
2957
+ run.protectedVertices.add(line.vertices[line.vertices.length - 1]);
2958
+ }
2959
+ }
2960
+ if (run.plant !== 'line-crossings-unkept') for (const [v, list] of of) if (list.length > 1) run.protectedVertices.add(v);
2961
+ return { lines: declared, of, at };
2962
+ }
2963
+
2964
+ /** The lines an operation removing `vertices` touches — those listing any of them — ascending, which is declared order. */
2965
+ function touchedLines(run: Run, vertices: readonly number[]): number[] {
2966
+ const out = new Set<number>();
2967
+ for (const v of vertices) for (const li of run.lines!.of.get(v) ?? []) out.add(li);
2968
+ return [...out].sort((a, b) => a - b);
2969
+ }
2970
+
2971
+ /** What a line asks before its row: a closed line keeps three vertices and an open one its ends. Null when the operation leaves both. */
2972
+ function lineStructure(run: Run, vertices: readonly number[], touched: readonly number[]): string | null {
2973
+ const { work } = run;
2974
+ for (const li of touched) {
2975
+ const line = run.lines!.lines[li];
2976
+ const kept = line.vertices.filter((v) => work.alive[v] && !vertices.includes(v)).length;
2977
+ if (line.closed && kept < 3) return `line "${line.name}": removing ${vertices.join(', ')} would leave ${kept} of its vertices kept; required at least 3 on a closed line`;
2978
+ if (!line.closed && (vertices.includes(line.vertices[0]) || vertices.includes(line.vertices[line.vertices.length - 1]))) {
2979
+ return `line "${line.name}": removing ${vertices.join(', ')} would remove an end of the open line; required both ends kept`;
2980
+ }
2981
+ }
2982
+ return null;
2983
+ }
2984
+
2985
+ /**
2986
+ * The rows of the touched lines on the chain the operation leaves — the measurement's own rows (`lineDeviationRows`),
2987
+ * over the source's points by source index: a line vertex is kept when it is alive and not removed by the operation.
2988
+ */
2989
+ function lineRowsOf(run: Run, vertices: readonly number[], touched: readonly number[]): MeasureRow[] {
2990
+ const { work } = run;
2991
+ const gone = new Set(vertices);
2992
+ return lineDeviationRows(
2993
+ run.input.attachment,
2994
+ touched.map((li) => run.lines!.lines[li]),
2995
+ run.input.source.points,
2996
+ (v) => (work.alive[v] && !gone.has(v) ? v : null),
2997
+ );
2998
+ }
2999
+
3000
+ /**
3001
+ * The chords a removal must keep (`ChordsFor`), or null when no declared line is touched or a plant takes the chords
3002
+ * away: for each line listing `v`, its kept neighbours before and after `v` in listed order (wrapping on a closed
3003
+ * line), skipping what the operation has already removed. A side with no kept neighbour — an open line's end — has
3004
+ * no chord.
3005
+ */
3006
+ function chordsForRun(run: Run, vertices: readonly number[]): ChordsFor {
3007
+ if (run.lines === null || run.plant === 'line-chord-unconstrained' || run.plant === 'line-join-unchecked') return null;
3008
+ if (!vertices.some((v) => run.lines!.of.has(v))) return null;
3009
+ const { work } = run;
3010
+ const lines = run.lines;
3011
+ return (v, gone) => {
3012
+ const out: Chord[] = [];
3013
+ for (const li of lines.of.get(v) ?? []) {
3014
+ const line = lines.lines[li];
3015
+ const list = line.vertices;
3016
+ const k = lines.at[li].get(v)!;
3017
+ const keptAt = (i: number): boolean => work.alive[list[i]] && !gone.has(list[i]) && list[i] !== v;
3018
+ let a = -1;
3019
+ let c = -1;
3020
+ for (let step = 1; step < list.length; step++) {
3021
+ const i = k - step;
3022
+ if (i < 0 && !line.closed) break;
3023
+ const j = (i + list.length) % list.length;
3024
+ if (keptAt(j)) {
3025
+ a = list[j];
3026
+ break;
3027
+ }
3028
+ }
3029
+ for (let step = 1; step < list.length; step++) {
3030
+ const i = k + step;
3031
+ if (i >= list.length && !line.closed) break;
3032
+ const j = i % list.length;
3033
+ if (keptAt(j)) {
3034
+ c = list[j];
3035
+ break;
3036
+ }
3037
+ }
3038
+ if (a !== -1 && c !== -1 && a !== c) out.push({ a, c, line: line.name });
3039
+ }
3040
+ return out;
2734
3041
  };
2735
3042
  }