@rig-c/parts 1.13.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -328,6 +328,11 @@ export interface ContourSpec {
328
328
  budget?: number;
329
329
  /** The largest island, in art pixels, that may be left out of the mesh; absent leaves none out. */
330
330
  stray?: number;
331
+ /**
332
+ * Issue #326: `true` meshes every island past those `stray` leaves out and joins them into one outline by bridges
333
+ * (`src/contour.ts`); absent, a second island refuses as it always has. There is no `false`.
334
+ */
335
+ bridges?: true;
331
336
  /** Local deformation regions; absent is none. */
332
337
  regions?: ContourRegionSpec[];
333
338
  }
@@ -1837,7 +1842,7 @@ function checkRibs(c: Check, at: string, m: Record<string, Json>, chains: Map<st
1837
1842
  * the grid every region number must sit on.
1838
1843
  */
1839
1844
  function checkContour(c: Check, at: string, v: Json, bones: Set<string>): void {
1840
- const o = c.object(at, v, ['tolerance', 'margin', 'spacing'], ['maxEdge', 'budget', 'stray', 'regions']);
1845
+ const o = c.object(at, v, ['tolerance', 'margin', 'spacing'], ['maxEdge', 'budget', 'stray', 'bridges', 'regions']);
1841
1846
  if (o === null) return;
1842
1847
  if ('tolerance' in o) c.number(`${at}.tolerance`, o.tolerance, 'non-negative');
1843
1848
  if ('margin' in o && c.number(`${at}.margin`, o.margin, 'non-negative') && (o.margin as number) > 0 && (o.margin as number) < 1) {
@@ -1847,6 +1852,7 @@ function checkContour(c: Check, at: string, v: Json, bones: Set<string>): void {
1847
1852
  if ('maxEdge' in o) c.number(`${at}.maxEdge`, o.maxEdge, 'positive');
1848
1853
  if ('budget' in o) c.int(`${at}.budget`, o.budget, 3);
1849
1854
  if ('stray' in o) c.int(`${at}.stray`, o.stray, 0);
1855
+ if ('bridges' in o && o.bridges !== true) c.fail('CONFIG_FIELD_TYPE', `${at}.bridges`, `is ${show(o.bridges)}; true is required (every island joined into one outline by bridges, issue #326) — remove the field for the one-island rule`);
1850
1856
  if (!('regions' in o) || !c.array(`${at}.regions`, o.regions, false)) return;
1851
1857
  const names = new Set<string>();
1852
1858
  (o.regions as Json[]).forEach((rv, i) => {
package/src/contour.ts CHANGED
@@ -21,7 +21,9 @@
21
21
  * at or under that figure are taken out of the art before the trace, and
22
22
  * the report says how many and how many pixels (`strayIslands`,
23
23
  * `strayPixels`). A hole is filled, as rigc's trace and the lattice both
24
- * fill it, and its area is reported (`filledHolePixels`).
24
+ * fill it, and its area is reported (`filledHolePixels`). Or the caller
25
+ * declares `bridges` (issue #326), and every island is meshed and the
26
+ * pieces joined into one outline by bridges ({@link bridgedMesh}).
25
27
  * 3. **The outline** ({@link contourOutline}; issue #106 replaced rigc's
26
28
  * `offsetPolygon`, which folded the outline over any notch narrower than
27
29
  * twice the margin): the silhouette F — the kept island with its holes
@@ -145,7 +147,7 @@
145
147
  import { type Problem } from './errors.ts';
146
148
  import { edgeLength } from './length.ts';
147
149
  import { counterClockwiseInSpineWorld } from './mesh.ts';
148
- import { connectedComponents, fillHoles, type Mask } from './raster/index.ts';
150
+ import { type Components, connectedComponents, fillHoles, type Mask } from './raster/index.ts';
149
151
  import {
150
152
  type AlphaMask,
151
153
  checkHullOrder,
@@ -241,6 +243,12 @@ export interface ContourParams {
241
243
  * the settled rule says; there is no default. See {@link strayIslands}.
242
244
  */
243
245
  stray?: number;
246
+ /**
247
+ * Issue #326: `true` meshes every island the art holds (past those `stray` leaves out) and joins them into the one
248
+ * outline rig-c takes by bridges ({@link bridgedMesh}). Absent, a second island refuses as the settled rule says,
249
+ * and the mesh is byte for byte the one it was before the field existed; there is no `false`.
250
+ */
251
+ bridges?: true;
244
252
  /** Refinement regions, in the order their points are placed. */
245
253
  regions: readonly ContourRegion[];
246
254
  /**
@@ -341,8 +349,62 @@ export interface ContourReport {
341
349
  ribs?: ContourRib[];
342
350
  /** Only when `maxEdge` was declared (issue #265): the outline vertices the cap added. Absent otherwise, so a mesh without it reports what it always reported. */
343
351
  outlineCut?: number;
352
+ /**
353
+ * Only when `bridges` was declared (issue #326): the islands kept, the grown silhouettes traced and the bridges
354
+ * that join them. Absent otherwise, so a mesh without it reports what it always reported. Under `bridges` the fit
355
+ * figures above (`coveredArtPixels`, `overshoot`) are read on the silhouettes' triangles alone: a bridge covers no
356
+ * art by construction, and its area is `meshArea`'s and is reported here per bridge.
357
+ */
358
+ bridges?: ContourBridges;
359
+ }
360
+
361
+ /** One bridge (issue #326): the quad that joins a grown silhouette to the outline of the ones joined before it. */
362
+ export interface ContourBridge {
363
+ /** The silhouette joined, by its index in {@link ContourBridges.units} (1 or more: the outline starts as unit 0's). */
364
+ unit: number;
365
+ /** The quad's two crossing edges, px: the joined outline's vertex to the unit's, and the unit's back to the joined outline's next. */
366
+ span: [number, number];
367
+ /** The quad's area, px² (exact on the grid), drawn over no art. */
368
+ area: number;
369
+ /** The quad's vertices in the mesh as written: the joined outline's a and a′, the unit's b and b′ (the outline runs a → b … b′ → a′). */
370
+ vertices: [number, number, number, number];
371
+ /** The pair's rank among every pair of a joined outline vertex and a vertex of a unit still apart, nearest first (1: the nearest pair was taken). */
372
+ pair: number;
373
+ }
374
+
375
+ /** What a bridged mesh was made of (issue #326). */
376
+ export interface ContourBridges {
377
+ /** The art px of every island kept, largest first. */
378
+ islands: number[];
379
+ /** The grown silhouettes traced, numbered by art px (most first, then raster order): the art px of each one's islands, largest first. */
380
+ units: number[][];
381
+ /** One per unit but unit 0, in the order they were joined. */
382
+ joins: ContourBridge[];
383
+ /**
384
+ * Only when one was left out (HQ's ruling on #326): each faint island — every pixel's alpha under
385
+ * {@link FAINT_ALPHA} — largest first. Its pixels stay in the image and are not meshed, so not drawn, as `stray`
386
+ * leaves dust; `artPixels` does not count them.
387
+ */
388
+ faint?: ContourFaintIsland[];
344
389
  }
345
390
 
391
+ /** A faint island a bridged mesh left out (issue #326): its art px, its brightest pixel's alpha, and its box in part-image px `[x, y, w, h]`. */
392
+ export interface ContourFaintIsland {
393
+ art: number;
394
+ maxAlpha: number;
395
+ box: [number, number, number, number];
396
+ }
397
+
398
+ /**
399
+ * Issue #326, HQ's ruling: under `bridges`, an island whose every pixel has alpha below this is faint, left out and
400
+ * named; any island with one pixel at or above it is visible and bridged, whatever the bridge costs. 64/255 is a
401
+ * quarter of full opacity — a faint pixel moves the composite by at most 63/255 of the colour beneath it — and it
402
+ * sits below the 128 that assemble and `check` read as covered (`FRONT_ALPHA`, `COVERED_ALPHA`, check's coverage),
403
+ * so nothing those readings count as drawn is left out. Read over what a bridged draft reaches on the public figures
404
+ * (the four examples and rig-c/trailer 6e2de45), the faint islands peak at 11–17 and the visible ones at 255.
405
+ */
406
+ export const FAINT_ALPHA = 64;
407
+
346
408
  export interface ContourMesh {
347
409
  /** Part-image px, y down, every value a multiple of 1/{@link GRID}; the first `hull` are the outline, in walk order. */
348
410
  vertices: Array<[number, number]>;
@@ -399,6 +461,8 @@ function parameterProblems(part: string, mask: AlphaMask, p: ContourParams): Pro
399
461
  }
400
462
  if (p.budget !== undefined && (!Number.isInteger(p.budget) || p.budget < 3)) bad('budget', `is ${p.budget}; a whole number of vertices, 3 or more, is required`);
401
463
  if (p.fitConnectivity !== undefined && p.fitConnectivity !== 4 && p.fitConnectivity !== 8) bad('fitConnectivity', `is ${JSON.stringify(p.fitConnectivity)}; 4 or 8 is required (how the background is flooded when overshoot is measured)`);
464
+ if (p.bridges !== undefined && p.bridges !== true) bad('bridges', `is ${JSON.stringify(p.bridges)}; true is required — remove the field for the one-island rule`);
465
+ if (p.bridges === true && (p.ribs ?? []).length > 0) bad('bridges', `is declared beside ribs on ${(p.ribs ?? []).map((r) => r.chain).join(', ')}; the two are not combined — a rib is placed across one outline, and a bridged mesh's outline runs round several silhouettes — so declare one of them`);
402
466
  if (p.stray !== undefined && (!Number.isInteger(p.stray) || p.stray < 0)) bad('stray', `is ${p.stray}; a whole number of art pixels, 0 or more, is required (the largest island that may be left out)`);
403
467
  ribProblems(part, p, out);
404
468
  const names = new Set<string>();
@@ -1733,23 +1797,36 @@ export function contourOutline(part: string, filled: Mask, threshold: number, to
1733
1797
  const grown = growSilhouette(filled, margin);
1734
1798
  const traced = margin === 0 ? silhouette : trace(grown.mask);
1735
1799
  if (Array.isArray(traced)) return traced;
1736
- const simplified = simplifyClosedPolygon(traced.outline, tolerance);
1800
+ const simple = simplifiedOutline(object, traced.outline, w, h, tolerance, margin);
1801
+ if ('problems' in simple) return simple.problems;
1802
+ return { outline: simple.outline, tracedVertices: traced.outline.length, grown, grownHolePixels: margin === 0 ? 0 : traced.holePixels };
1803
+ }
1804
+
1805
+ /**
1806
+ * A traced outline simplified at `tolerance` (`simplifyClosedPolygon`), clamped to the window, snapped to the
1807
+ * {@link GRID} and pruned (`prunePolygon`) — {@link contourOutline}'s step 3 — or the `CONTOUR_SELF_INTERSECTION`
1808
+ * that refuses it (step 4). `object` is the refusal's object.
1809
+ */
1810
+ function simplifiedOutline(object: string, traced: Array<[number, number]>, w: number, h: number, tolerance: number, margin: number): { outline: Array<[number, number]> } | { problems: Problem[] } {
1811
+ const simplified = simplifyClosedPolygon(traced, tolerance);
1737
1812
  const snapped = simplified.map(([x, y]) => [snap(Math.min(w, Math.max(0, x))) / GRID, snap(Math.min(h, Math.max(0, y))) / GRID] as [number, number]);
1738
1813
  const outline = prunePolygon(snapped);
1739
1814
  if (outline.length < 3 || signedArea(outline) <= 0 || findSelfIntersection(outline) !== null) {
1740
1815
  const crossing = outline.length < 3 ? null : findSelfIntersection(outline);
1741
- return [
1742
- {
1743
- code: 'CONTOUR_SELF_INTERSECTION',
1744
- object,
1745
- detail:
1746
- crossing !== null
1747
- ? `after margin ${margin} px and tolerance ${tolerance} px its simplified outline's edge ${crossing[0]} meets edge ${crossing[1]}; an outline that touches itself nowhere is required — lower the tolerance`
1748
- : `after margin ${margin} px and tolerance ${tolerance} px its outline has ${outline.length} vertices enclosing ${outline.length < 3 ? 0 : signedArea(outline)} px² (clockwise on screen is positive); 3 or more enclosing a positive area are required — lower the tolerance`,
1749
- },
1750
- ];
1816
+ return {
1817
+ problems: [
1818
+ {
1819
+ code: 'CONTOUR_SELF_INTERSECTION',
1820
+ object,
1821
+ detail:
1822
+ crossing !== null
1823
+ ? `after margin ${margin} px and tolerance ${tolerance} px its simplified outline's edge ${crossing[0]} meets edge ${crossing[1]}; an outline that touches itself nowhere is required — lower the tolerance`
1824
+ : `after margin ${margin} px and tolerance ${tolerance} px its outline has ${outline.length} vertices enclosing ${outline.length < 3 ? 0 : signedArea(outline)} px² (clockwise on screen is positive); 3 or more enclosing a positive area are required — lower the tolerance`,
1825
+ },
1826
+ ],
1827
+ };
1751
1828
  }
1752
- return { outline, tracedVertices: traced.outline.length, grown, grownHolePixels: margin === 0 ? 0 : traced.holePixels };
1829
+ return { outline };
1753
1830
  }
1754
1831
 
1755
1832
  /**
@@ -1769,6 +1846,7 @@ export function contourMesh(part: string, mask: AlphaMask, params: ContourParams
1769
1846
  if (islands === 0) {
1770
1847
  return [{ code: 'CONTOUR_PART_HAS_ART', object, detail: `its ${w}x${h} image has no pixel with alpha above ${threshold}; a mesh needs at least one art pixel` }];
1771
1848
  }
1849
+ if (params.bridges === true) return bridgedMesh(part, mask, params, comp);
1772
1850
  const split = strayIslands(comp.stats.map((s) => s.area), params.stray);
1773
1851
  if (split.refused !== null) {
1774
1852
  const sizes = comp.stats.slice(1).map((s) => `${s.area} px at (${s.left}, ${s.top})`);
@@ -1937,3 +2015,297 @@ export function strayIslands(areas: readonly number[], stray: number | undefined
1937
2015
  }
1938
2016
  return { keep, leave, refused: null };
1939
2017
  }
2018
+
2019
+ // ---------------------------------------------------------------------------
2020
+ // several islands joined by bridges (issue #326)
2021
+ // ---------------------------------------------------------------------------
2022
+
2023
+ /** One grown silhouette of a bridged mesh: its outline (grid units, walk order), its kept interior points and its own triangles (local indices, wound as the triangulation builds them). */
2024
+ interface BridgeUnit {
2025
+ hx: number[];
2026
+ hy: number[];
2027
+ kept: Candidate[];
2028
+ tri: number[];
2029
+ islands: number[];
2030
+ }
2031
+
2032
+ /**
2033
+ * The bridged mesh (issue #326, Stage B; `bridges: true`). Stage A (`docs/evidence/island-draft.md`) measured the
2034
+ * join this builds by hand: rig-c reads one closed outline per mesh, and a bridge is what a join costs.
2035
+ *
2036
+ * 1. **Islands**, in this order. Dust first: an island that is not the largest (nor tied with it) and holds `stray`
2037
+ * art px or fewer is left out as the one-island rule leaves it out. Then faint islands: one that is not the
2038
+ * largest and whose every pixel has alpha under {@link FAINT_ALPHA} is left out the same way and named in
2039
+ * `bridges.faint`. Every other island is kept, and step 3 bridges it whatever its bridge costs — overdraw is
2040
+ * reported (`joins[].area`), never a reason to drop art.
2041
+ * 2. **Silhouettes, in the trace.** The kept islands with their holes filled grow together by the margin
2042
+ * ({@link growSilhouette}, its pinch fill included), and each 4-connected piece of the grown set is one unit,
2043
+ * traced on its own. Islands closer than about twice the margin grow into one unit and are traced as one
2044
+ * silhouette; nothing is painted between them. A unit is traced, simplified, capped, refined in regions and
2045
+ * triangulated exactly as a one-island mesh is (steps 3–5 of the module header).
2046
+ * 3. **Bridges.** The units are numbered by art px (most first, then raster order). The outline starts as unit
2047
+ * 0's; then, while a unit is apart, it takes the nearest pair of a vertex a on the joined outline and a vertex b
2048
+ * of a unit still apart (squared distance on the grid, then the unit's number, a's position, b's index) whose
2049
+ * quad holds: the joined outline runs a → b round the unit to b′ → a′, the quad (a, b, b′, a′) is two triangles
2050
+ * wound as the mesh's, it crosses no outline edge of the joined outline or of any unit still apart and holds no
2051
+ * other vertex, and the join passes {@link contourTopologyProblems}. Nearest first, so a unit is not joined
2052
+ * across one still apart. Each bridge adds two triangles and keeps `2V − hull − 2`. When no pair holds,
2053
+ * `CONTOUR_BRIDGE` names the silhouettes joined and apart (selftest `CT44` plants one: a tolerance that cuts an
2054
+ * outline across its notch and through the unit sitting in it).
2055
+ * 4. **Readings.** Topology and the budget read the whole mesh; coverage and overshoot read the units' triangles
2056
+ * only (a bridge spans the gap between islands by design, so it covers no art and reaches past it — its area
2057
+ * is reported per bridge, `bridges.joins[].area`, and in `meshArea`). The three interior edges each bridge
2058
+ * makes (its diagonal and the two outline edges it opens) are not asked to be Delaunay, as an outline edge
2059
+ * is not.
2060
+ *
2061
+ * The F-level pinch guard of {@link contourOutline} is not run: a pinch between two islands is two islands, which
2062
+ * the growth joins (its pinch fill) or the bridge check reads as outlines that touch.
2063
+ */
2064
+ function bridgedMesh(part: string, mask: AlphaMask, params: ContourParams, comp: Components): ContourMesh | Problem[] {
2065
+ const object = `contour mesh "${part}"`;
2066
+ const { width: w, height: h } = mask;
2067
+ const { threshold, tolerance, margin } = params;
2068
+ const n = comp.count - 1;
2069
+ let largest = 0;
2070
+ for (let l = 1; l <= n; l++) largest = Math.max(largest, comp.stats[l].area);
2071
+ const leave: number[] = [];
2072
+ for (let l = 1; l <= n; l++) if (comp.stats[l].area !== largest && params.stray !== undefined && comp.stats[l].area <= params.stray) leave.push(l);
2073
+ // Issue #326 (HQ's ruling): after dust, a faint island — every pixel's alpha under FAINT_ALPHA — is left out and
2074
+ // named; every other island is bridged whatever its bridge costs. The largest island (or a tie for it) is never left out.
2075
+ const peak = new Array<number>(n + 1).fill(0);
2076
+ for (let i = 0; i < comp.labels.length; i++) if (comp.labels[i] > 0) peak[comp.labels[i]] = Math.max(peak[comp.labels[i]], mask.alpha[i]);
2077
+ const faint: number[] = [];
2078
+ for (let l = 1; l <= n; l++) if (comp.stats[l].area !== largest && !leave.includes(l) && peak[l] < FAINT_ALPHA) faint.push(l);
2079
+ let meshed = mask;
2080
+ let strayPixels = 0;
2081
+ if (leave.length > 0 || faint.length > 0) {
2082
+ const out = new Set([...leave, ...faint]);
2083
+ const alpha = new Uint8Array(mask.alpha);
2084
+ for (let i = 0; i < alpha.length; i++) if (out.has(comp.labels[i])) alpha[i] = 0;
2085
+ meshed = { width: w, height: h, alpha };
2086
+ for (const l of leave) strayPixels += comp.stats[l].area;
2087
+ }
2088
+ const keptIslands = new Set<number>();
2089
+ for (let l = 1; l <= n; l++) if (!leave.includes(l) && !faint.includes(l)) keptIslands.add(l);
2090
+ const left: ContourFaintIsland[] = faint
2091
+ .map((l) => ({ art: comp.stats[l].area, maxAlpha: peak[l], box: [comp.stats[l].left, comp.stats[l].top, comp.stats[l].width, comp.stats[l].height] as [number, number, number, number] }))
2092
+ .sort((a, b) => b.art - a.art || a.box[1] - b.box[1] || a.box[0] - b.box[0]);
2093
+
2094
+ const island = artMask(meshed, threshold);
2095
+ const filled = fillHoles(island);
2096
+ let filledHolePixels = 0;
2097
+ for (let i = 0; i < filled.data.length; i++) if (filled.data[i] && !island.data[i]) filledHolePixels++;
2098
+ const grown = growSilhouette(filled, margin);
2099
+ const gc = connectedComponents(grown.mask, 4);
2100
+ // Each grown piece and the kept islands in it (an island inside another's hole is filled into that one's piece).
2101
+ const members = new Map<number, number[]>();
2102
+ for (let i = 0; i < comp.labels.length; i++) {
2103
+ const l = comp.labels[i];
2104
+ if (l === 0 || !keptIslands.has(l)) continue;
2105
+ const g = gc.labels[i];
2106
+ const list = members.get(g) ?? [];
2107
+ if (!list.includes(l)) list.push(l);
2108
+ members.set(g, list);
2109
+ }
2110
+ const order = [...members.keys()]
2111
+ .map((g) => ({ g, px: (members.get(g) as number[]).reduce((t, l) => t + comp.stats[l].area, 0) }))
2112
+ .sort((p, q) => q.px - p.px || p.g - q.g);
2113
+
2114
+ const candidates = interiorCandidates(w, h, params).filter((c) => c.x >= 0 && c.y >= 0 && c.x <= w * GRID && c.y <= h * GRID);
2115
+ const units: BridgeUnit[] = [];
2116
+ let tracedVertices = 0;
2117
+ let grownHolePixels = 0;
2118
+ let outlineCut = 0;
2119
+ for (const [k, { g, px }] of order.entries()) {
2120
+ const at = `${object} (silhouette ${k + 1} of ${order.length}, ${px} art px)`;
2121
+ const data = new Uint8Array(w * h);
2122
+ for (let i = 0; i < data.length; i++) data[i] = gc.labels[i] === g ? 1 : 0;
2123
+ let traced: ReturnType<typeof traceAlphaOutline>;
2124
+ try {
2125
+ traced = traceAlphaOutline({ width: w, height: h, alpha: data }, 1);
2126
+ } catch (err) {
2127
+ if (!(err instanceof MeshError)) throw err;
2128
+ return [{ code: 'CONTOUR_TRACE', object: at, detail: `rig-c's traceAlphaOutline refused the grown silhouette at alpha above ${threshold}: ${err.message}` }];
2129
+ }
2130
+ const simple = simplifiedOutline(at, traced.outline, w, h, tolerance, margin);
2131
+ if ('problems' in simple) return simple.problems;
2132
+ tracedVertices += traced.outline.length;
2133
+ grownHolePixels += margin === 0 ? 0 : traced.holePixels;
2134
+ const capped = params.maxEdge === undefined ? simple.outline : capOutlineEdges(simple.outline, params.maxEdge);
2135
+ outlineCut += capped.length - simple.outline.length;
2136
+ const outline = outlineInRegions(capped, params.regions);
2137
+ const hx = outline.map((q) => snap(q[0]));
2138
+ const hy = outline.map((q) => snap(q[1]));
2139
+ const kept = keepPoints(hx, hy, candidates);
2140
+ const mesh = new Triangulation([...hx, ...kept.map((c) => c.x)], [...hy, ...kept.map((c) => c.y)]);
2141
+ const ears = earClip(outline);
2142
+ for (let t = 0; t < ears.length; t += 3) mesh.set(t / 3, ears[t], ears[t + 1], ears[t + 2]);
2143
+ mesh.legalizeAll();
2144
+ for (let q = 0; q < kept.length; q++) mesh.insert(outline.length + q);
2145
+ mesh.legalizeAll();
2146
+ units.push({ hx, hy, kept, tri: [...mesh.tri], islands: (members.get(g) as number[]).map((l) => comp.stats[l].area).sort((a, b) => b - a) });
2147
+ }
2148
+
2149
+ // The join: `ring` is the outline as [unit, hull index] in walk order; it starts as unit 0's.
2150
+ type Ref = readonly [number, number];
2151
+ let ring: Ref[] = units[0].hx.map((_x, i) => [0, i] as const);
2152
+ const quads: Array<[Ref, Ref, Ref, Ref]> = [];
2153
+ const joins: Array<Omit<ContourBridge, 'vertices'>> = [];
2154
+ const X = (r: Ref): number => units[r[0]].hx[r[1]];
2155
+ const Y = (r: Ref): number => units[r[0]].hy[r[1]];
2156
+ const ori = (p: Ref, q: Ref, r: Ref): number => orient(X(p), Y(p), X(q), Y(q), X(r), Y(r));
2157
+ /** The mesh of the units joined so far: vertices (px) in ring order then each joined unit's interior points, and its triangles, wound as the triangulation's. */
2158
+ const assemble = (r: readonly Ref[], joined: readonly number[], q: ReadonlyArray<readonly [Ref, Ref, Ref, Ref]>): { vertices: Array<[number, number]>; tri: number[]; own: number; bridgeEdges: Array<[number, number]> } => {
2159
+ const index = new Map<string, number>();
2160
+ r.forEach((ref, i) => index.set(`${ref[0]}:${ref[1]}`, i));
2161
+ const vertices = r.map((ref) => [X(ref) / GRID, Y(ref) / GRID] as [number, number]);
2162
+ const tri: number[] = [];
2163
+ for (const u of joined) {
2164
+ const U = units[u];
2165
+ const base = vertices.length;
2166
+ for (const c of U.kept) vertices.push([c.x / GRID, c.y / GRID]);
2167
+ const H = U.hx.length;
2168
+ for (const v of U.tri) tri.push(v < H ? (index.get(`${u}:${v}`) as number) : base + v - H);
2169
+ }
2170
+ const own = tri.length;
2171
+ const bridgeEdges: Array<[number, number]> = [];
2172
+ for (const [a, a2, b, bp] of q) {
2173
+ const [ia, ia2, ib, ibp] = [a, a2, b, bp].map((ref) => index.get(`${ref[0]}:${ref[1]}`) as number);
2174
+ tri.push(ia, ib, ibp, ia, ibp, ia2);
2175
+ bridgeEdges.push([ia, ibp], [ia, ia2], [ib, ibp]);
2176
+ }
2177
+ return { vertices, tri, own, bridgeEdges };
2178
+ };
2179
+ const joinedUnits = [0];
2180
+ while (joinedUnits.length < units.length) {
2181
+ const R = ring.length;
2182
+ const waiting = units.map((_u, k) => k).filter((k) => !joinedUnits.includes(k));
2183
+ // Every pair of a joined outline vertex and a waiting unit's: nearest first, then the waiting unit, the ring position, the unit's vertex.
2184
+ const pairs: Array<[number, number, number, number]> = [];
2185
+ for (const k of waiting) {
2186
+ const B = units[k];
2187
+ for (let p = 0; p < R; p++) {
2188
+ for (let j = 0; j < B.hx.length; j++) {
2189
+ const dx = X(ring[p]) - B.hx[j];
2190
+ const dy = Y(ring[p]) - B.hy[j];
2191
+ pairs.push([dx * dx + dy * dy, k, p, j]);
2192
+ }
2193
+ }
2194
+ }
2195
+ pairs.sort((s, t) => s[0] - t[0] || s[1] - t[1] || s[2] - t[2] || s[3] - t[3]);
2196
+ // Every outline edge of every unit, for the crossing test: the joined ring's and every waiting unit's.
2197
+ const edges: Array<[Ref, Ref]> = [];
2198
+ for (let i = 0; i < R; i++) edges.push([ring[i], ring[(i + 1) % R]]);
2199
+ for (const u of waiting) for (let i = 0; i < units[u].hx.length; i++) edges.push([[u, i], [u, (i + 1) % units[u].hx.length]]);
2200
+ const others: Ref[] = [];
2201
+ for (let u = 0; u < units.length; u++) for (let i = 0; i < units[u].hx.length; i++) others.push([u, i]);
2202
+ const same = (p: Ref, q: Ref): boolean => p[0] === q[0] && p[1] === q[1];
2203
+ let taken: { k: number; p: number; j: number; rank: number; ring: Ref[] } | null = null;
2204
+ for (let rank = 0; rank < pairs.length && taken === null; rank++) {
2205
+ const [, k, p, j] = pairs[rank];
2206
+ const HB = units[k].hx.length;
2207
+ const a = ring[p];
2208
+ const a2 = ring[(p + 1) % R];
2209
+ const b: Ref = [k, j];
2210
+ const bp: Ref = [k, (j - 1 + HB) % HB];
2211
+ if (ori(a, b, bp) <= 0 || ori(a, bp, a2) <= 0) continue;
2212
+ const crosses = (s: Ref, t: Ref): boolean =>
2213
+ edges.some(([e0, e1]) => !same(e0, s) && !same(e1, s) && !same(e0, t) && !same(e1, t) && segmentsMeet(X(s), Y(s), X(t), Y(t), X(e0), Y(e0), X(e1), Y(e1)));
2214
+ if (crosses(a, b) || crosses(bp, a2)) continue;
2215
+ const inside = (v: Ref, t0: Ref, t1: Ref, t2: Ref): boolean => ori(t0, t1, v) >= 0 && ori(t1, t2, v) >= 0 && ori(t2, t0, v) >= 0;
2216
+ if (others.some((v) => !same(v, a) && !same(v, a2) && !same(v, b) && !same(v, bp) && (inside(v, a, b, bp) || inside(v, a, bp, a2)))) continue;
2217
+ const next: Ref[] = [...ring.slice(0, p + 1)];
2218
+ for (let s = 0; s < HB; s++) next.push([k, (j + s) % HB]);
2219
+ next.push(...ring.slice(p + 1));
2220
+ const trial = assemble(next, [...joinedUnits, k], [...quads, [a, a2, b, bp]]);
2221
+ if (contourTopologyProblems(part, trial.vertices, counterClockwiseInSpineWorld(canonicalTriangles(trial.tri)), next.length).length > 0) continue;
2222
+ taken = { k, p, j, rank, ring: next };
2223
+ }
2224
+ if (taken === null) {
2225
+ const px = (k: number): number => units[k].islands.reduce((t, x) => t + x, 0);
2226
+ return [
2227
+ {
2228
+ code: 'CONTOUR_BRIDGE',
2229
+ object,
2230
+ detail: `its ${units.length} grown silhouettes cannot all be joined: of the ${pairs.length} pairs of a vertex on the outline joined so far (${R} vertices round ${joinedUnits.map(px).join(' + ')} art px) and one on a silhouette still apart (${waiting.map(px).join(', ')} art px), none gives a bridge quad wound as the mesh that crosses no outline, holds no other vertex and leaves one closed outline; a bridge is required, because rig-c takes one closed outline per mesh — lower the tolerance (a simplified outline can cut across a neighbour), or keep the lattice mode for this part`,
2231
+ },
2232
+ ];
2233
+ }
2234
+ const { k } = taken;
2235
+ const HB = units[k].hx.length;
2236
+ const a = ring[taken.p];
2237
+ const a2 = ring[(taken.p + 1) % R];
2238
+ const b: Ref = [k, taken.j];
2239
+ const bp: Ref = [k, (taken.j - 1 + HB) % HB];
2240
+ const len = (s: Ref, t: Ref): number => r6(Math.sqrt((X(s) - X(t)) ** 2 + (Y(s) - Y(t)) ** 2) / GRID);
2241
+ const twice = ori(a, b, bp) + ori(a, bp, a2);
2242
+ joins.push({ unit: k, span: [len(a, b), len(bp, a2)], area: twice / 2 / (GRID * GRID), pair: taken.rank + 1 });
2243
+ quads.push([a, a2, b, bp]);
2244
+ ring = taken.ring;
2245
+ joinedUnits.push(k);
2246
+ }
2247
+
2248
+ const all = assemble(ring, joinedUnits, quads);
2249
+ const vertices = all.vertices;
2250
+ const triangles = counterClockwiseInSpineWorld(canonicalTriangles(all.tri));
2251
+ const ownTriangles = counterClockwiseInSpineWorld(canonicalTriangles(all.tri.slice(0, all.own)));
2252
+ const H = ring.length;
2253
+ const problems = contourTopologyProblems(part, vertices, triangles, H);
2254
+ const fit = contourFit(part, meshed, threshold, { margin, tolerance }, vertices, ownTriangles, params.fitConnectivity ?? 4);
2255
+ problems.push(...fit.problems);
2256
+ if (params.budget !== undefined && vertices.length > params.budget) {
2257
+ problems.push({
2258
+ code: 'CONTOUR_BUDGET',
2259
+ object,
2260
+ detail: `has ${vertices.length} vertices (${H} on the outline, ${vertices.length - H} inside); the declared budget is ${params.budget} — nothing is thinned to fit, so raise the spacing or the tolerance, or the budget`,
2261
+ });
2262
+ }
2263
+ if (problems.length > 0) return problems;
2264
+
2265
+ const index = new Map<string, number>();
2266
+ ring.forEach((ref, i) => index.set(`${ref[0]}:${ref[1]}`, i));
2267
+ const at = (r: Ref): number => index.get(`${r[0]}:${r[1]}`) as number;
2268
+ const bySource = new Map<string, number>();
2269
+ for (const r of params.regions) bySource.set(`region "${r.name}"`, 0);
2270
+ bySource.set('background', 0);
2271
+ for (const u of joinedUnits) for (const c of units[u].kept) bySource.set(c.source, (bySource.get(c.source) as number) + 1);
2272
+ const hx = ring.map(X);
2273
+ const hy = ring.map(Y);
2274
+ const meshArea = unitsToPx2(twiceAreaUnits(hx, hy));
2275
+ return {
2276
+ vertices,
2277
+ triangles,
2278
+ hull: H,
2279
+ mask: meshed,
2280
+ report: {
2281
+ boundaryVertices: H,
2282
+ interiorVertices: vertices.length - H,
2283
+ interiorBySource: [...bySource].map(([source, v]) => ({ source, vertices: v })),
2284
+ triangles: triangles.length / 3,
2285
+ tracedVertices,
2286
+ artPixels: fit.artPixels,
2287
+ coveredArtPixels: fit.coveredArt,
2288
+ coverage: fit.coverage,
2289
+ overshoot: fit.overshoot,
2290
+ overshootBound: fit.overshootBound,
2291
+ ...(params.fitConnectivity === undefined ? {} : { fitConnectivity: params.fitConnectivity }),
2292
+ meshArea,
2293
+ enclosedTransparentArea: meshArea - fit.coveredArt,
2294
+ filledHolePixels,
2295
+ ...triangleQuality(vertices, triangles),
2296
+ nonDelaunayEdges: delaunayViolations(vertices, triangles, all.bridgeEdges),
2297
+ strayIslands: leave.length,
2298
+ strayPixels,
2299
+ grownPixels: grown.grownPixels,
2300
+ pinchFilledPixels: grown.pinchFilledPixels,
2301
+ grownHolePixels,
2302
+ ...(params.maxEdge === undefined ? {} : { outlineCut }),
2303
+ bridges: {
2304
+ islands: [...keptIslands].map((l) => comp.stats[l].area).sort((a, b) => b - a),
2305
+ units: units.map((u) => u.islands),
2306
+ joins: joins.map((j, i) => ({ ...j, vertices: quads[i].map(at) as [number, number, number, number] })),
2307
+ ...(left.length === 0 ? {} : { faint: left }),
2308
+ },
2309
+ },
2310
+ };
2311
+ }
@@ -191,12 +191,13 @@ export interface HalfTravels {
191
191
  export type Toward = 'down' | 'up' | 'left' | 'right';
192
192
 
193
193
  /**
194
- * Where a chain's tip is from its root (issue #231): the vector from its first
195
- * link's origin to its last link's at the setup pose, in the world (y up),
196
- * read along whichever axis it runs further on — `down`/`up` when |dy| > |dx|,
194
+ * Which way a vector in the world (y up) runs (issue #231), read along
195
+ * whichever axis it runs further on — `down`/`up` when |dy| > |dx|,
197
196
  * `left`/`right` when |dx| > |dy|. Null when it runs as far on both, which
198
- * includes a chain whose links share one origin. `check` holds a part the
199
- * scene declares `rises` to it: the chain carrying it must read `up`.
197
+ * includes the zero vector. `check` holds a part the scene declares `rises` to
198
+ * it: the last link of the chain carrying it, at the setup pose, must read
199
+ * `up` (issue #322; until then the vector was the first link's origin to the
200
+ * last's).
200
201
  */
201
202
  export function chainToward(dx: number, dy: number): Toward | null {
202
203
  if (Math.abs(dy) > Math.abs(dx)) return dy < 0 ? 'down' : 'up';