@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/README.md +1 -1
- package/cli.ts +7 -2
- package/docs/AUTHORING.md +134 -30
- package/docs/STABILITY.md +4 -4
- package/package.json +1 -1
- package/src/build.ts +68 -11
- package/src/check.ts +417 -77
- package/src/config.ts +7 -1
- package/src/contour.ts +350 -14
- package/src/instruments.ts +6 -5
- package/src/meshdraft.ts +107 -8
- package/src/requirements.ts +67 -5
- package/src/rig.ts +3 -3
- 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,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
|
|
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
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
|
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
|
+
}
|
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';
|