@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/README.md +1 -1
- package/cli.ts +14 -8
- package/docs/AUTHORING.md +139 -34
- package/docs/MIGRATION.md +62 -0
- package/docs/STABILITY.md +4 -4
- package/package.json +1 -1
- package/src/build.ts +76 -17
- package/src/check.ts +496 -80
- package/src/config.ts +7 -1
- package/src/contour.ts +386 -14
- package/src/instruments.ts +6 -5
- package/src/meshdraft.ts +130 -9
- package/src/requirements.ts +68 -5
- package/src/rig.ts +4 -4
- package/src/scene.ts +2 -2
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
|
|
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
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
|
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
|
+
}
|
package/src/instruments.ts
CHANGED
|
@@ -191,12 +191,13 @@ export interface HalfTravels {
|
|
|
191
191
|
export type Toward = 'down' | 'up' | 'left' | 'right';
|
|
192
192
|
|
|
193
193
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
|
199
|
-
*
|
|
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';
|