@rig-c/parts 1.13.0 → 1.14.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,6 +349,37 @@ 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[];
344
383
  }
345
384
 
346
385
  export interface ContourMesh {
@@ -399,6 +438,8 @@ function parameterProblems(part: string, mask: AlphaMask, p: ContourParams): Pro
399
438
  }
400
439
  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
440
  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)`);
441
+ 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`);
442
+ 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
443
  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
444
  ribProblems(part, p, out);
404
445
  const names = new Set<string>();
@@ -1733,23 +1774,36 @@ export function contourOutline(part: string, filled: Mask, threshold: number, to
1733
1774
  const grown = growSilhouette(filled, margin);
1734
1775
  const traced = margin === 0 ? silhouette : trace(grown.mask);
1735
1776
  if (Array.isArray(traced)) return traced;
1736
- const simplified = simplifyClosedPolygon(traced.outline, tolerance);
1777
+ const simple = simplifiedOutline(object, traced.outline, w, h, tolerance, margin);
1778
+ if ('problems' in simple) return simple.problems;
1779
+ return { outline: simple.outline, tracedVertices: traced.outline.length, grown, grownHolePixels: margin === 0 ? 0 : traced.holePixels };
1780
+ }
1781
+
1782
+ /**
1783
+ * A traced outline simplified at `tolerance` (`simplifyClosedPolygon`), clamped to the window, snapped to the
1784
+ * {@link GRID} and pruned (`prunePolygon`) — {@link contourOutline}'s step 3 — or the `CONTOUR_SELF_INTERSECTION`
1785
+ * that refuses it (step 4). `object` is the refusal's object.
1786
+ */
1787
+ function simplifiedOutline(object: string, traced: Array<[number, number]>, w: number, h: number, tolerance: number, margin: number): { outline: Array<[number, number]> } | { problems: Problem[] } {
1788
+ const simplified = simplifyClosedPolygon(traced, tolerance);
1737
1789
  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
1790
  const outline = prunePolygon(snapped);
1739
1791
  if (outline.length < 3 || signedArea(outline) <= 0 || findSelfIntersection(outline) !== null) {
1740
1792
  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
- ];
1793
+ return {
1794
+ problems: [
1795
+ {
1796
+ code: 'CONTOUR_SELF_INTERSECTION',
1797
+ object,
1798
+ detail:
1799
+ crossing !== null
1800
+ ? `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`
1801
+ : `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`,
1802
+ },
1803
+ ],
1804
+ };
1751
1805
  }
1752
- return { outline, tracedVertices: traced.outline.length, grown, grownHolePixels: margin === 0 ? 0 : traced.holePixels };
1806
+ return { outline };
1753
1807
  }
1754
1808
 
1755
1809
  /**
@@ -1769,6 +1823,7 @@ export function contourMesh(part: string, mask: AlphaMask, params: ContourParams
1769
1823
  if (islands === 0) {
1770
1824
  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
1825
  }
1826
+ if (params.bridges === true) return bridgedMesh(part, mask, params, comp);
1772
1827
  const split = strayIslands(comp.stats.map((s) => s.area), params.stray);
1773
1828
  if (split.refused !== null) {
1774
1829
  const sizes = comp.stats.slice(1).map((s) => `${s.area} px at (${s.left}, ${s.top})`);
@@ -1937,3 +1992,284 @@ export function strayIslands(areas: readonly number[], stray: number | undefined
1937
1992
  }
1938
1993
  return { keep, leave, refused: null };
1939
1994
  }
1995
+
1996
+ // ---------------------------------------------------------------------------
1997
+ // several islands joined by bridges (issue #326)
1998
+ // ---------------------------------------------------------------------------
1999
+
2000
+ /** 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). */
2001
+ interface BridgeUnit {
2002
+ hx: number[];
2003
+ hy: number[];
2004
+ kept: Candidate[];
2005
+ tri: number[];
2006
+ islands: number[];
2007
+ }
2008
+
2009
+ /**
2010
+ * The bridged mesh (issue #326, Stage B; `bridges: true`). Stage A (`docs/evidence/island-draft.md`) measured the
2011
+ * join this builds by hand: rig-c reads one closed outline per mesh, and a bridge is what a join costs.
2012
+ *
2013
+ * 1. **Islands.** Every 4-connected island of the art is kept, but an island that is not the largest (nor tied
2014
+ * with it) and holds `stray` art px or fewer, which is left out as the one-island rule leaves it out.
2015
+ * 2. **Silhouettes, in the trace.** The kept islands with their holes filled grow together by the margin
2016
+ * ({@link growSilhouette}, its pinch fill included), and each 4-connected piece of the grown set is one unit,
2017
+ * traced on its own. Islands closer than about twice the margin grow into one unit and are traced as one
2018
+ * silhouette; nothing is painted between them. A unit is traced, simplified, capped, refined in regions and
2019
+ * triangulated exactly as a one-island mesh is (steps 3–5 of the module header).
2020
+ * 3. **Bridges.** The units are numbered by art px (most first, then raster order). The outline starts as unit
2021
+ * 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
2022
+ * of a unit still apart (squared distance on the grid, then the unit's number, a's position, b's index) whose
2023
+ * quad holds: the joined outline runs a → b round the unit to b′ → a′, the quad (a, b, b′, a′) is two triangles
2024
+ * wound as the mesh's, it crosses no outline edge of the joined outline or of any unit still apart and holds no
2025
+ * other vertex, and the join passes {@link contourTopologyProblems}. Nearest first, so a unit is not joined
2026
+ * across one still apart. Each bridge adds two triangles and keeps `2V − hull − 2`. When no pair holds,
2027
+ * `CONTOUR_BRIDGE` names the silhouettes joined and apart (selftest `CT44` plants one: a tolerance that cuts an
2028
+ * outline across its notch and through the unit sitting in it).
2029
+ * 4. **Readings.** Topology and the budget read the whole mesh; coverage and overshoot read the units' triangles
2030
+ * only (a bridge spans the gap between islands by design, so it covers no art and reaches past it — its area
2031
+ * is reported per bridge, `bridges.joins[].area`, and in `meshArea`). The three interior edges each bridge
2032
+ * makes (its diagonal and the two outline edges it opens) are not asked to be Delaunay, as an outline edge
2033
+ * is not.
2034
+ *
2035
+ * The F-level pinch guard of {@link contourOutline} is not run: a pinch between two islands is two islands, which
2036
+ * the growth joins (its pinch fill) or the bridge check reads as outlines that touch.
2037
+ */
2038
+ function bridgedMesh(part: string, mask: AlphaMask, params: ContourParams, comp: Components): ContourMesh | Problem[] {
2039
+ const object = `contour mesh "${part}"`;
2040
+ const { width: w, height: h } = mask;
2041
+ const { threshold, tolerance, margin } = params;
2042
+ const n = comp.count - 1;
2043
+ let largest = 0;
2044
+ for (let l = 1; l <= n; l++) largest = Math.max(largest, comp.stats[l].area);
2045
+ const leave: number[] = [];
2046
+ 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);
2047
+ let meshed = mask;
2048
+ let strayPixels = 0;
2049
+ if (leave.length > 0) {
2050
+ const out = new Set(leave);
2051
+ const alpha = new Uint8Array(mask.alpha);
2052
+ for (let i = 0; i < alpha.length; i++) if (out.has(comp.labels[i])) alpha[i] = 0;
2053
+ meshed = { width: w, height: h, alpha };
2054
+ for (const l of leave) strayPixels += comp.stats[l].area;
2055
+ }
2056
+ const keptIslands = new Set<number>();
2057
+ for (let l = 1; l <= n; l++) if (!leave.includes(l)) keptIslands.add(l);
2058
+
2059
+ const island = artMask(meshed, threshold);
2060
+ const filled = fillHoles(island);
2061
+ let filledHolePixels = 0;
2062
+ for (let i = 0; i < filled.data.length; i++) if (filled.data[i] && !island.data[i]) filledHolePixels++;
2063
+ const grown = growSilhouette(filled, margin);
2064
+ const gc = connectedComponents(grown.mask, 4);
2065
+ // Each grown piece and the kept islands in it (an island inside another's hole is filled into that one's piece).
2066
+ const members = new Map<number, number[]>();
2067
+ for (let i = 0; i < comp.labels.length; i++) {
2068
+ const l = comp.labels[i];
2069
+ if (l === 0 || !keptIslands.has(l)) continue;
2070
+ const g = gc.labels[i];
2071
+ const list = members.get(g) ?? [];
2072
+ if (!list.includes(l)) list.push(l);
2073
+ members.set(g, list);
2074
+ }
2075
+ const order = [...members.keys()]
2076
+ .map((g) => ({ g, px: (members.get(g) as number[]).reduce((t, l) => t + comp.stats[l].area, 0) }))
2077
+ .sort((p, q) => q.px - p.px || p.g - q.g);
2078
+
2079
+ const candidates = interiorCandidates(w, h, params).filter((c) => c.x >= 0 && c.y >= 0 && c.x <= w * GRID && c.y <= h * GRID);
2080
+ const units: BridgeUnit[] = [];
2081
+ let tracedVertices = 0;
2082
+ let grownHolePixels = 0;
2083
+ let outlineCut = 0;
2084
+ for (const [k, { g, px }] of order.entries()) {
2085
+ const at = `${object} (silhouette ${k + 1} of ${order.length}, ${px} art px)`;
2086
+ const data = new Uint8Array(w * h);
2087
+ for (let i = 0; i < data.length; i++) data[i] = gc.labels[i] === g ? 1 : 0;
2088
+ let traced: ReturnType<typeof traceAlphaOutline>;
2089
+ try {
2090
+ traced = traceAlphaOutline({ width: w, height: h, alpha: data }, 1);
2091
+ } catch (err) {
2092
+ if (!(err instanceof MeshError)) throw err;
2093
+ return [{ code: 'CONTOUR_TRACE', object: at, detail: `rig-c's traceAlphaOutline refused the grown silhouette at alpha above ${threshold}: ${err.message}` }];
2094
+ }
2095
+ const simple = simplifiedOutline(at, traced.outline, w, h, tolerance, margin);
2096
+ if ('problems' in simple) return simple.problems;
2097
+ tracedVertices += traced.outline.length;
2098
+ grownHolePixels += margin === 0 ? 0 : traced.holePixels;
2099
+ const capped = params.maxEdge === undefined ? simple.outline : capOutlineEdges(simple.outline, params.maxEdge);
2100
+ outlineCut += capped.length - simple.outline.length;
2101
+ const outline = outlineInRegions(capped, params.regions);
2102
+ const hx = outline.map((q) => snap(q[0]));
2103
+ const hy = outline.map((q) => snap(q[1]));
2104
+ const kept = keepPoints(hx, hy, candidates);
2105
+ const mesh = new Triangulation([...hx, ...kept.map((c) => c.x)], [...hy, ...kept.map((c) => c.y)]);
2106
+ const ears = earClip(outline);
2107
+ for (let t = 0; t < ears.length; t += 3) mesh.set(t / 3, ears[t], ears[t + 1], ears[t + 2]);
2108
+ mesh.legalizeAll();
2109
+ for (let q = 0; q < kept.length; q++) mesh.insert(outline.length + q);
2110
+ mesh.legalizeAll();
2111
+ 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) });
2112
+ }
2113
+
2114
+ // The join: `ring` is the outline as [unit, hull index] in walk order; it starts as unit 0's.
2115
+ type Ref = readonly [number, number];
2116
+ let ring: Ref[] = units[0].hx.map((_x, i) => [0, i] as const);
2117
+ const quads: Array<[Ref, Ref, Ref, Ref]> = [];
2118
+ const joins: Array<Omit<ContourBridge, 'vertices'>> = [];
2119
+ const X = (r: Ref): number => units[r[0]].hx[r[1]];
2120
+ const Y = (r: Ref): number => units[r[0]].hy[r[1]];
2121
+ const ori = (p: Ref, q: Ref, r: Ref): number => orient(X(p), Y(p), X(q), Y(q), X(r), Y(r));
2122
+ /** 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. */
2123
+ 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]> } => {
2124
+ const index = new Map<string, number>();
2125
+ r.forEach((ref, i) => index.set(`${ref[0]}:${ref[1]}`, i));
2126
+ const vertices = r.map((ref) => [X(ref) / GRID, Y(ref) / GRID] as [number, number]);
2127
+ const tri: number[] = [];
2128
+ for (const u of joined) {
2129
+ const U = units[u];
2130
+ const base = vertices.length;
2131
+ for (const c of U.kept) vertices.push([c.x / GRID, c.y / GRID]);
2132
+ const H = U.hx.length;
2133
+ for (const v of U.tri) tri.push(v < H ? (index.get(`${u}:${v}`) as number) : base + v - H);
2134
+ }
2135
+ const own = tri.length;
2136
+ const bridgeEdges: Array<[number, number]> = [];
2137
+ for (const [a, a2, b, bp] of q) {
2138
+ const [ia, ia2, ib, ibp] = [a, a2, b, bp].map((ref) => index.get(`${ref[0]}:${ref[1]}`) as number);
2139
+ tri.push(ia, ib, ibp, ia, ibp, ia2);
2140
+ bridgeEdges.push([ia, ibp], [ia, ia2], [ib, ibp]);
2141
+ }
2142
+ return { vertices, tri, own, bridgeEdges };
2143
+ };
2144
+ const joinedUnits = [0];
2145
+ while (joinedUnits.length < units.length) {
2146
+ const R = ring.length;
2147
+ const waiting = units.map((_u, k) => k).filter((k) => !joinedUnits.includes(k));
2148
+ // 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.
2149
+ const pairs: Array<[number, number, number, number]> = [];
2150
+ for (const k of waiting) {
2151
+ const B = units[k];
2152
+ for (let p = 0; p < R; p++) {
2153
+ for (let j = 0; j < B.hx.length; j++) {
2154
+ const dx = X(ring[p]) - B.hx[j];
2155
+ const dy = Y(ring[p]) - B.hy[j];
2156
+ pairs.push([dx * dx + dy * dy, k, p, j]);
2157
+ }
2158
+ }
2159
+ }
2160
+ pairs.sort((s, t) => s[0] - t[0] || s[1] - t[1] || s[2] - t[2] || s[3] - t[3]);
2161
+ // Every outline edge of every unit, for the crossing test: the joined ring's and every waiting unit's.
2162
+ const edges: Array<[Ref, Ref]> = [];
2163
+ for (let i = 0; i < R; i++) edges.push([ring[i], ring[(i + 1) % R]]);
2164
+ 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]]);
2165
+ const others: Ref[] = [];
2166
+ for (let u = 0; u < units.length; u++) for (let i = 0; i < units[u].hx.length; i++) others.push([u, i]);
2167
+ const same = (p: Ref, q: Ref): boolean => p[0] === q[0] && p[1] === q[1];
2168
+ let taken: { k: number; p: number; j: number; rank: number; ring: Ref[] } | null = null;
2169
+ for (let rank = 0; rank < pairs.length && taken === null; rank++) {
2170
+ const [, k, p, j] = pairs[rank];
2171
+ const HB = units[k].hx.length;
2172
+ const a = ring[p];
2173
+ const a2 = ring[(p + 1) % R];
2174
+ const b: Ref = [k, j];
2175
+ const bp: Ref = [k, (j - 1 + HB) % HB];
2176
+ if (ori(a, b, bp) <= 0 || ori(a, bp, a2) <= 0) continue;
2177
+ const crosses = (s: Ref, t: Ref): boolean =>
2178
+ 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)));
2179
+ if (crosses(a, b) || crosses(bp, a2)) continue;
2180
+ 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;
2181
+ 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;
2182
+ const next: Ref[] = [...ring.slice(0, p + 1)];
2183
+ for (let s = 0; s < HB; s++) next.push([k, (j + s) % HB]);
2184
+ next.push(...ring.slice(p + 1));
2185
+ const trial = assemble(next, [...joinedUnits, k], [...quads, [a, a2, b, bp]]);
2186
+ if (contourTopologyProblems(part, trial.vertices, counterClockwiseInSpineWorld(canonicalTriangles(trial.tri)), next.length).length > 0) continue;
2187
+ taken = { k, p, j, rank, ring: next };
2188
+ }
2189
+ if (taken === null) {
2190
+ const px = (k: number): number => units[k].islands.reduce((t, x) => t + x, 0);
2191
+ return [
2192
+ {
2193
+ code: 'CONTOUR_BRIDGE',
2194
+ object,
2195
+ 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`,
2196
+ },
2197
+ ];
2198
+ }
2199
+ const { k } = taken;
2200
+ const HB = units[k].hx.length;
2201
+ const a = ring[taken.p];
2202
+ const a2 = ring[(taken.p + 1) % R];
2203
+ const b: Ref = [k, taken.j];
2204
+ const bp: Ref = [k, (taken.j - 1 + HB) % HB];
2205
+ const len = (s: Ref, t: Ref): number => r6(Math.sqrt((X(s) - X(t)) ** 2 + (Y(s) - Y(t)) ** 2) / GRID);
2206
+ const twice = ori(a, b, bp) + ori(a, bp, a2);
2207
+ joins.push({ unit: k, span: [len(a, b), len(bp, a2)], area: twice / 2 / (GRID * GRID), pair: taken.rank + 1 });
2208
+ quads.push([a, a2, b, bp]);
2209
+ ring = taken.ring;
2210
+ joinedUnits.push(k);
2211
+ }
2212
+
2213
+ const all = assemble(ring, joinedUnits, quads);
2214
+ const vertices = all.vertices;
2215
+ const triangles = counterClockwiseInSpineWorld(canonicalTriangles(all.tri));
2216
+ const ownTriangles = counterClockwiseInSpineWorld(canonicalTriangles(all.tri.slice(0, all.own)));
2217
+ const H = ring.length;
2218
+ const problems = contourTopologyProblems(part, vertices, triangles, H);
2219
+ const fit = contourFit(part, meshed, threshold, { margin, tolerance }, vertices, ownTriangles, params.fitConnectivity ?? 4);
2220
+ problems.push(...fit.problems);
2221
+ if (params.budget !== undefined && vertices.length > params.budget) {
2222
+ problems.push({
2223
+ code: 'CONTOUR_BUDGET',
2224
+ object,
2225
+ 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`,
2226
+ });
2227
+ }
2228
+ if (problems.length > 0) return problems;
2229
+
2230
+ const index = new Map<string, number>();
2231
+ ring.forEach((ref, i) => index.set(`${ref[0]}:${ref[1]}`, i));
2232
+ const at = (r: Ref): number => index.get(`${r[0]}:${r[1]}`) as number;
2233
+ const bySource = new Map<string, number>();
2234
+ for (const r of params.regions) bySource.set(`region "${r.name}"`, 0);
2235
+ bySource.set('background', 0);
2236
+ for (const u of joinedUnits) for (const c of units[u].kept) bySource.set(c.source, (bySource.get(c.source) as number) + 1);
2237
+ const hx = ring.map(X);
2238
+ const hy = ring.map(Y);
2239
+ const meshArea = unitsToPx2(twiceAreaUnits(hx, hy));
2240
+ return {
2241
+ vertices,
2242
+ triangles,
2243
+ hull: H,
2244
+ mask: meshed,
2245
+ report: {
2246
+ boundaryVertices: H,
2247
+ interiorVertices: vertices.length - H,
2248
+ interiorBySource: [...bySource].map(([source, v]) => ({ source, vertices: v })),
2249
+ triangles: triangles.length / 3,
2250
+ tracedVertices,
2251
+ artPixels: fit.artPixels,
2252
+ coveredArtPixels: fit.coveredArt,
2253
+ coverage: fit.coverage,
2254
+ overshoot: fit.overshoot,
2255
+ overshootBound: fit.overshootBound,
2256
+ ...(params.fitConnectivity === undefined ? {} : { fitConnectivity: params.fitConnectivity }),
2257
+ meshArea,
2258
+ enclosedTransparentArea: meshArea - fit.coveredArt,
2259
+ filledHolePixels,
2260
+ ...triangleQuality(vertices, triangles),
2261
+ nonDelaunayEdges: delaunayViolations(vertices, triangles, all.bridgeEdges),
2262
+ strayIslands: leave.length,
2263
+ strayPixels,
2264
+ grownPixels: grown.grownPixels,
2265
+ pinchFilledPixels: grown.pinchFilledPixels,
2266
+ grownHolePixels,
2267
+ ...(params.maxEdge === undefined ? {} : { outlineCut }),
2268
+ bridges: {
2269
+ islands: [...keptIslands].map((l) => comp.stats[l].area).sort((a, b) => b - a),
2270
+ units: units.map((u) => u.islands),
2271
+ joins: joins.map((j, i) => ({ ...j, vertices: quads[i].map(at) as [number, number, number, number] })),
2272
+ },
2273
+ },
2274
+ };
2275
+ }
@@ -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';