rig-c 2.32.2 β 2.34.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 +2 -1
- package/src/core/entry.ts +33 -0
- package/src/meshquality.ts +328 -21
- package/src/meshreduce.ts +327 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-c",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.34.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": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"./mesh": "./src/mesh.ts",
|
|
18
18
|
"./errors": "./src/errors.ts",
|
|
19
19
|
"./meshcompare": "./src/meshcompare.ts",
|
|
20
|
+
"./core": "./src/core/entry.ts",
|
|
20
21
|
"./cli": "./cli.ts",
|
|
21
22
|
"./package.json": "./package.json",
|
|
22
23
|
"./*.ts": "./*.ts",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rig-c/core` β the named entry a player with no Spine runtime poses the
|
|
3
|
+
* model document through (issue #1275).
|
|
4
|
+
*
|
|
5
|
+
* A dependant was observed importing these symbols from the core's own files
|
|
6
|
+
* through the pattern courtesy (`rig-c/src/core/raw`), which RELEASING.md
|
|
7
|
+
* *The import surface* says is not promised. This module re-exports exactly
|
|
8
|
+
* the observed list, so the core's files keep their names and may move: the
|
|
9
|
+
* entry moves with them, and a symbol leaves only as a breaking change.
|
|
10
|
+
*
|
|
11
|
+
* π It re-exports and does nothing else. What it reaches by value is the
|
|
12
|
+
* core and nothing outside `src/core/` β the core links no `node:` module and
|
|
13
|
+
* no runtime, and its imports of `../model.ts` are type imports β so a bundler
|
|
14
|
+
* targeting a browser takes this entry's closure without a node polyfill.
|
|
15
|
+
* `CUR123` holds the closure, by name; the install smoke bundles it for a
|
|
16
|
+
* browser and holds the bundle to zero `node:` strings.
|
|
17
|
+
*
|
|
18
|
+
* β οΈ A symbol is listed here only because a dependant was seen using it.
|
|
19
|
+
* The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` row for
|
|
20
|
+
* `./core`, and a symbol added here and not there is not promised.
|
|
21
|
+
*/
|
|
22
|
+
export { readModel, underSkin, underNoSkin, CoreInputError, CORE_DOCUMENT_SPEC, CORE_DOCUMENT_SPECS, CORE_BLEND_MODES, activeBones } from './index.ts';
|
|
23
|
+
export type { CompiledDocument, CoreSlotRow, CoreBlendMode } from './index.ts';
|
|
24
|
+
export { poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setupBounds } from './raw.ts';
|
|
25
|
+
export type { RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
|
|
26
|
+
export { poseWalkSetup, poseLoopingWalk } from './walk.ts';
|
|
27
|
+
export type { WalkPose } from './walk.ts';
|
|
28
|
+
export { documentPageLookup, drawnRegions, regionPageUvs, meshPageUvs, readUvSequences } from './uvs.ts';
|
|
29
|
+
export type { DrawnRegion, UvPage, UvRegion, UvSource } from './uvs.ts';
|
|
30
|
+
export { clipThrough, clipShapeOf, convexWhy, REGION_UVS, REGION_TRIANGLES } from './clipping.ts';
|
|
31
|
+
export type { ClipShape, ClipResult } from './clipping.ts';
|
|
32
|
+
export { eventsFired } from './events.ts';
|
|
33
|
+
export type { ModelPage, ModelPageRegion } from '../model.ts';
|
package/src/meshquality.ts
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
1273
|
-
|
|
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
|
-
|
|
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(
|
|
1305
|
-
for (let j = 0; j <
|
|
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 <
|
|
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
|
-
|
|
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 <
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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'] =
|
|
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
|
}
|