spine-rigc 0.8.0 → 0.9.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 +125 -716
- package/cli.ts +118 -7
- package/docs/AUTHORING.md +524 -97
- package/docs/SPEC_COVERAGE.md +27 -12
- package/package.json +4 -1
- package/src/check.ts +552 -29
- package/src/compile.ts +674 -48
- package/src/mesh.ts +832 -3
- package/src/render.ts +300 -7
- package/src/rig.ts +448 -20
- package/src/transform.ts +13 -0
- package/src/types.ts +82 -7
- package/src/validate.ts +347 -38
- package/tools/contact.ts +126 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/png_probe.mjs +72 -0
package/src/mesh.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Procedural mesh geometry.
|
|
2
|
+
* Procedural mesh geometry — three builders, two different jobs.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* `buildRingMesh` and `buildRibbonMesh` build the shape a DEFORMATION asks for,
|
|
5
|
+
* and the long note below is theirs: what is pinned, what may move, how
|
|
6
|
+
* authority falls off. `buildContourMesh` builds the shape the ART already is —
|
|
7
|
+
* it traces a part's own alpha and triangulates it, and its own section further
|
|
8
|
+
* down says why that makes it a shape rather than a deformation model.
|
|
9
|
+
*
|
|
10
|
+
* ## The ring and the ribbon
|
|
11
|
+
*
|
|
12
|
+
* The shape they build is the one a deformable aperture asks for: **the rim is
|
|
5
13
|
* nailed down and only the inside moves.** For a face cut that ring is the mouth
|
|
6
14
|
* aperture, and the rim already exists as data — the mask polygon in the cut
|
|
7
15
|
* manifest is exactly the contour where the generated part fades into untouched
|
|
@@ -63,8 +71,11 @@ export interface MeshVertexWeight {
|
|
|
63
71
|
weight: number;
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
/** Which builder in this file made a mesh's geometry. */
|
|
75
|
+
export type MeshKind = 'ring' | 'ribbon' | 'contour';
|
|
76
|
+
|
|
66
77
|
export interface MeshGeometry {
|
|
67
|
-
kind:
|
|
78
|
+
kind: MeshKind;
|
|
68
79
|
/** Vertex positions in part-local pixels, y down. */
|
|
69
80
|
points: Array<[number, number]>;
|
|
70
81
|
/** Normalised region UVs, v measured from the top edge. */
|
|
@@ -75,6 +86,8 @@ export interface MeshGeometry {
|
|
|
75
86
|
weights: MeshVertexWeight[][];
|
|
76
87
|
/** Hull vertex count — emitted as `hull`, which the loader doubles. */
|
|
77
88
|
hullVertices: number;
|
|
89
|
+
/** What the contour builder measured about its own fit. Only `contour` has one. */
|
|
90
|
+
contour?: ContourReport;
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
/**
|
|
@@ -392,6 +405,822 @@ export function buildRibbonMesh(input: RibbonSpecInput): MeshGeometry {
|
|
|
392
405
|
return { kind: 'ribbon', points, uvs, triangles, weights, hullVertices: 2 * rows };
|
|
393
406
|
}
|
|
394
407
|
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// contour — a mesh cut to the part's own alpha silhouette
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
//
|
|
412
|
+
// The ring and the ribbon build a shape the DEFORMATION wants. This one builds
|
|
413
|
+
// the shape the ART already is: trace the alpha mask, simplify the outline,
|
|
414
|
+
// push it out by a margin, triangulate. What it is for is the case those two
|
|
415
|
+
// cannot express — a part whose outline is the interesting thing, where a
|
|
416
|
+
// rectangle of region is either too much geometry (a whole quad of transparent
|
|
417
|
+
// pixels to blend) or too little (no vertices to move where the silhouette is).
|
|
418
|
+
//
|
|
419
|
+
// 🚨 **It is geometry, not a deformation model.** Every vertex is pinned to the
|
|
420
|
+
// slot bone at weight 1, so a contour mesh at rest draws what the region drew
|
|
421
|
+
// and a bone cannot bend it. What it gains over a region is a real outline and
|
|
422
|
+
// real triangles: a `deform` timeline has somewhere to push, `hull` states the
|
|
423
|
+
// silhouette other tools can read, and the shape is measured rather than
|
|
424
|
+
// declared. Bone-driven interior motion is what `ring` is for, and authored
|
|
425
|
+
// `weights` is what an editor's own auto-weighting arrives as.
|
|
426
|
+
//
|
|
427
|
+
// Every claim this builder makes about its own output it MEASURES:
|
|
428
|
+
// `measureContourFit` rasterises the emitted triangles against the very mask
|
|
429
|
+
// they were traced from and the build is refused unless the triangles cover
|
|
430
|
+
// `CONTOUR_MIN_COVERAGE` of the art and stay within the margin the author asked
|
|
431
|
+
// for. A mesh that clips the art is the one failure mode that cannot be seen in
|
|
432
|
+
// the numbers of a skeleton file, so it is not left to a reader to notice.
|
|
433
|
+
|
|
434
|
+
/** One part's alpha channel, as the tracer wants it. */
|
|
435
|
+
export interface AlphaMask {
|
|
436
|
+
width: number;
|
|
437
|
+
height: number;
|
|
438
|
+
/** One byte per pixel, row major, y down. `width * height` long. */
|
|
439
|
+
alpha: Uint8Array;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export interface ContourSpecInput {
|
|
443
|
+
mask: AlphaMask;
|
|
444
|
+
/** Alpha at or above this counts as art. 1 means "any pixel that is not fully transparent". */
|
|
445
|
+
threshold: number;
|
|
446
|
+
/** Douglas-Peucker tolerance, in part-local pixels. */
|
|
447
|
+
tolerance: number;
|
|
448
|
+
/** How far the outline is pushed out past the traced silhouette, in pixels. */
|
|
449
|
+
margin: number;
|
|
450
|
+
/** Refuse rather than emit more outline vertices than this. */
|
|
451
|
+
maxVertices: number;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** What the contour builder measured while building — reported, not asserted in prose. */
|
|
455
|
+
export interface ContourReport {
|
|
456
|
+
/** Pixels at or above the threshold. */
|
|
457
|
+
artPixels: number;
|
|
458
|
+
/** Pixels in the largest 4-connected island of art. */
|
|
459
|
+
islandPixels: number;
|
|
460
|
+
/** How many 4-connected islands of art the mask holds. */
|
|
461
|
+
islands: number;
|
|
462
|
+
/** Transparent pixels the traced outline encloses — inside the mesh, drawing nothing. */
|
|
463
|
+
holePixels: number;
|
|
464
|
+
/** Corner-lattice vertices the trace produced, before simplification. */
|
|
465
|
+
tracedVertices: number;
|
|
466
|
+
/** Fraction of art pixels the emitted triangles cover, 0..1. */
|
|
467
|
+
coverage: number;
|
|
468
|
+
/** Furthest a covered pixel sits outside the filled silhouette, in pixels. */
|
|
469
|
+
overshoot: number;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The share of the art the triangles must cover, or the build is refused.
|
|
474
|
+
*
|
|
475
|
+
* Not 1. The outline is simplified, and a simplification that could never cut a
|
|
476
|
+
* corner is not a simplification — so the guarantee is a stated fraction and the
|
|
477
|
+
* measured number goes in the report, rather than an exactness nobody can hold.
|
|
478
|
+
*/
|
|
479
|
+
export const CONTOUR_MIN_COVERAGE = 0.995;
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* How far a mitred corner may travel, as a multiple of `margin`.
|
|
483
|
+
*
|
|
484
|
+
* A sharp spike's angle bisector runs away to infinity — `margin / sin(θ/2)` —
|
|
485
|
+
* and an unlimited miter turns a 5-degree point into a vertex hundreds of pixels
|
|
486
|
+
* off the art. Clamping the travel gives that corner a slightly cut tip instead,
|
|
487
|
+
* which the coverage measurement then either accepts or refuses.
|
|
488
|
+
*/
|
|
489
|
+
export const CONTOUR_MITER_LIMIT = 4;
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The furthest a contour mesh CAN reach past the filled silhouette, in pixels,
|
|
493
|
+
* derived rather than chosen.
|
|
494
|
+
*
|
|
495
|
+
* Three terms, and each one is a step of the build: the offset moves a vertex at
|
|
496
|
+
* most `margin * CONTOUR_MITER_LIMIT` (the miter clamp is what makes that a
|
|
497
|
+
* bound at all), simplification can bow an edge `tolerance` outward, and a pixel
|
|
498
|
+
* whose CENTRE lands inside a triangle can sit up to one pixel from the shape's
|
|
499
|
+
* true edge. So a build that exceeds this is not an author's margin being
|
|
500
|
+
* generous — it is this file's arithmetic being wrong, and `buildContourMesh`
|
|
501
|
+
* refuses it in those words.
|
|
502
|
+
*
|
|
503
|
+
* ⭐ It is a ceiling, not a forecast. The number a given part actually measures
|
|
504
|
+
* is in its `ContourReport.overshoot`, which is where to read "how closely does
|
|
505
|
+
* this mesh hug this art": the selftest's blob measures 3.16px against a ceiling
|
|
506
|
+
* of 10.50px at the same settings.
|
|
507
|
+
*/
|
|
508
|
+
export function contourOvershootBound(margin: number, tolerance: number): number {
|
|
509
|
+
return margin * CONTOUR_MITER_LIMIT + tolerance + 1;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Cracks run clockwise on screen (y down): +x, +y, -x, -y. */
|
|
513
|
+
const CRACK_DIRS: ReadonlyArray<readonly [number, number]> = [
|
|
514
|
+
[1, 0],
|
|
515
|
+
[0, 1],
|
|
516
|
+
[-1, 0],
|
|
517
|
+
[0, -1],
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
/** Perpendicular distance from `p` to the segment `a`-`b`, clamped to its ends. */
|
|
521
|
+
function distanceToSegment(
|
|
522
|
+
p: readonly [number, number],
|
|
523
|
+
a: readonly [number, number],
|
|
524
|
+
b: readonly [number, number],
|
|
525
|
+
): number {
|
|
526
|
+
const dx = b[0] - a[0];
|
|
527
|
+
const dy = b[1] - a[1];
|
|
528
|
+
const len = dx * dx + dy * dy;
|
|
529
|
+
if (len === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
|
|
530
|
+
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len;
|
|
531
|
+
t = Math.max(0, Math.min(1, t));
|
|
532
|
+
return Math.hypot(p[0] - (a[0] + dx * t), p[1] - (a[1] + dy * t));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Douglas-Peucker over one open polyline, returning the indices it keeps.
|
|
537
|
+
*
|
|
538
|
+
* Iterative rather than recursive: a 4000-vertex staircase (a 1000x1000 part
|
|
539
|
+
* traced on the corner lattice) recurses deeper than is comfortable, and the
|
|
540
|
+
* stack version is the same algorithm with the frames written down.
|
|
541
|
+
*/
|
|
542
|
+
function simplifyRun(points: Array<[number, number]>, from: number, to: number, tolerance: number): number[] {
|
|
543
|
+
const keep = new Uint8Array(to - from + 1);
|
|
544
|
+
keep[0] = 1;
|
|
545
|
+
keep[to - from] = 1;
|
|
546
|
+
const stack: Array<[number, number]> = [[from, to]];
|
|
547
|
+
while (stack.length) {
|
|
548
|
+
const [lo, hi] = stack.pop()!;
|
|
549
|
+
if (hi <= lo + 1) continue;
|
|
550
|
+
let worst = -1;
|
|
551
|
+
let worstAt = -1;
|
|
552
|
+
for (let i = lo + 1; i < hi; i++) {
|
|
553
|
+
const d = distanceToSegment(points[i], points[lo], points[hi]);
|
|
554
|
+
if (d > worst) {
|
|
555
|
+
worst = d;
|
|
556
|
+
worstAt = i;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (worst <= tolerance || worstAt < 0) continue;
|
|
560
|
+
keep[worstAt - from] = 1;
|
|
561
|
+
stack.push([lo, worstAt], [worstAt, hi]);
|
|
562
|
+
}
|
|
563
|
+
const out: number[] = [];
|
|
564
|
+
for (let i = 0; i < keep.length; i++) if (keep[i]) out.push(from + i);
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Douglas-Peucker over a CLOSED ring.
|
|
570
|
+
*
|
|
571
|
+
* The algorithm is defined for a polyline with two fixed ends, and a ring has
|
|
572
|
+
* none — so the ring is cut at its first vertex and at the vertex furthest from
|
|
573
|
+
* it, and the two arcs are simplified independently. Cutting at the diameter
|
|
574
|
+
* rather than at an arbitrary second point is what keeps the two arcs from being
|
|
575
|
+
* a long one and a stub, whose simplification would then be lopsided.
|
|
576
|
+
*/
|
|
577
|
+
export function simplifyClosedPolygon(poly: Array<[number, number]>, tolerance: number): Array<[number, number]> {
|
|
578
|
+
const n = poly.length;
|
|
579
|
+
if (n < 4 || !(tolerance > 0)) return poly.slice();
|
|
580
|
+
let far = 1;
|
|
581
|
+
let farD = -1;
|
|
582
|
+
for (let i = 1; i < n; i++) {
|
|
583
|
+
const d = Math.hypot(poly[i][0] - poly[0][0], poly[i][1] - poly[0][1]);
|
|
584
|
+
if (d > farD) {
|
|
585
|
+
farD = d;
|
|
586
|
+
far = i;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const rotated = [...poly.slice(0), poly[0]]; // one open run 0..far, one far..n
|
|
590
|
+
const first = simplifyRun(rotated, 0, far, tolerance);
|
|
591
|
+
const second = simplifyRun(rotated, far, n, tolerance);
|
|
592
|
+
const out: Array<[number, number]> = [];
|
|
593
|
+
for (const i of first) out.push(poly[i]);
|
|
594
|
+
for (const i of second.slice(1, second.length - 1)) out.push(poly[i % n]);
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Push a simple polygon out along its own vertex bisectors.
|
|
600
|
+
*
|
|
601
|
+
* ⭐ The traced outline runs on the corner lattice, so it already encloses every
|
|
602
|
+
* art pixel WHOLE and a margin of 0 clips nothing. What the margin is actually
|
|
603
|
+
* for is the simplification: Douglas-Peucker moves a vertex up to `tolerance` in
|
|
604
|
+
* either direction, and the inward half of that is a bite out of the art. So the
|
|
605
|
+
* useful setting is `margin >= tolerance`, and the coverage measurement is what
|
|
606
|
+
* says whether a given pair got there.
|
|
607
|
+
*
|
|
608
|
+
* `poly` must be clockwise on screen (positive `signedArea` in y-down pixels),
|
|
609
|
+
* which is what the tracer produces; outward is then to the left of travel.
|
|
610
|
+
*/
|
|
611
|
+
export function offsetPolygon(poly: Array<[number, number]>, margin: number): Array<[number, number]> {
|
|
612
|
+
const n = poly.length;
|
|
613
|
+
if (margin === 0) return poly.map(([x, y]) => [x, y] as [number, number]);
|
|
614
|
+
const out: Array<[number, number]> = [];
|
|
615
|
+
for (let i = 0; i < n; i++) {
|
|
616
|
+
const prev = poly[(i + n - 1) % n];
|
|
617
|
+
const here = poly[i];
|
|
618
|
+
const next = poly[(i + 1) % n];
|
|
619
|
+
const e1 = unit(here[0] - prev[0], here[1] - prev[1]);
|
|
620
|
+
const e2 = unit(next[0] - here[0], next[1] - here[1]);
|
|
621
|
+
// Outward normal of an edge running (dx, dy) on a clockwise screen polygon.
|
|
622
|
+
const n1: [number, number] = [e1[1], -e1[0]];
|
|
623
|
+
const n2: [number, number] = [e2[1], -e2[0]];
|
|
624
|
+
let bx = n1[0] + n2[0];
|
|
625
|
+
let by = n1[1] + n2[1];
|
|
626
|
+
let len = Math.hypot(bx, by);
|
|
627
|
+
if (len < 1e-9) {
|
|
628
|
+
// A 180-degree turn: the two edges double back, so there is no bisector.
|
|
629
|
+
// The incoming edge's own normal is the only direction that is outward for
|
|
630
|
+
// both, and a spike this thin is a candidate for the coverage refusal.
|
|
631
|
+
bx = n1[0];
|
|
632
|
+
by = n1[1];
|
|
633
|
+
len = 1;
|
|
634
|
+
}
|
|
635
|
+
bx /= len;
|
|
636
|
+
by /= len;
|
|
637
|
+
const project = Math.max(bx * n1[0] + by * n1[1], 1 / CONTOUR_MITER_LIMIT);
|
|
638
|
+
out.push([here[0] + (bx * margin) / project, here[1] + (by * margin) / project]);
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function unit(dx: number, dy: number): [number, number] {
|
|
644
|
+
const len = Math.hypot(dx, dy);
|
|
645
|
+
return len < 1e-12 ? [0, 0] : [dx / len, dy / len];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Drop repeated and exactly collinear vertices, so no ear can have zero area. */
|
|
649
|
+
export function prunePolygon(poly: Array<[number, number]>): Array<[number, number]> {
|
|
650
|
+
const dedup: Array<[number, number]> = [];
|
|
651
|
+
for (const p of poly) {
|
|
652
|
+
const last = dedup[dedup.length - 1];
|
|
653
|
+
if (last && Math.abs(last[0] - p[0]) < 1e-9 && Math.abs(last[1] - p[1]) < 1e-9) continue;
|
|
654
|
+
dedup.push([p[0], p[1]]);
|
|
655
|
+
}
|
|
656
|
+
while (dedup.length > 1) {
|
|
657
|
+
const a = dedup[0];
|
|
658
|
+
const b = dedup[dedup.length - 1];
|
|
659
|
+
if (Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9) dedup.pop();
|
|
660
|
+
else break;
|
|
661
|
+
}
|
|
662
|
+
// Collinear runs, repeatedly: removing one vertex can make its neighbours
|
|
663
|
+
// collinear in turn, and a single pass would leave those behind.
|
|
664
|
+
let changed = true;
|
|
665
|
+
while (changed && dedup.length > 3) {
|
|
666
|
+
changed = false;
|
|
667
|
+
for (let i = 0; i < dedup.length; i++) {
|
|
668
|
+
const p = dedup[(i + dedup.length - 1) % dedup.length];
|
|
669
|
+
const h = dedup[i];
|
|
670
|
+
const q = dedup[(i + 1) % dedup.length];
|
|
671
|
+
const cross = (h[0] - p[0]) * (q[1] - h[1]) - (h[1] - p[1]) * (q[0] - h[0]);
|
|
672
|
+
if (Math.abs(cross) < 1e-9) {
|
|
673
|
+
dedup.splice(i, 1);
|
|
674
|
+
changed = true;
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return dedup;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Do two segments share a point? Touching counts — an ear needs strict simplicity. */
|
|
683
|
+
function segmentsMeet(
|
|
684
|
+
a: readonly [number, number],
|
|
685
|
+
b: readonly [number, number],
|
|
686
|
+
c: readonly [number, number],
|
|
687
|
+
d: readonly [number, number],
|
|
688
|
+
): boolean {
|
|
689
|
+
const o = (p: readonly [number, number], q: readonly [number, number], r: readonly [number, number]): number => {
|
|
690
|
+
const v = (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
|
|
691
|
+
return Math.abs(v) < 1e-9 ? 0 : Math.sign(v);
|
|
692
|
+
};
|
|
693
|
+
const onSegment = (p: readonly [number, number], q: readonly [number, number], r: readonly [number, number]): boolean =>
|
|
694
|
+
o(p, q, r) === 0 &&
|
|
695
|
+
Math.min(p[0], q[0]) - 1e-9 <= r[0] &&
|
|
696
|
+
r[0] <= Math.max(p[0], q[0]) + 1e-9 &&
|
|
697
|
+
Math.min(p[1], q[1]) - 1e-9 <= r[1] &&
|
|
698
|
+
r[1] <= Math.max(p[1], q[1]) + 1e-9;
|
|
699
|
+
const o1 = o(a, b, c);
|
|
700
|
+
const o2 = o(a, b, d);
|
|
701
|
+
const o3 = o(c, d, a);
|
|
702
|
+
const o4 = o(c, d, b);
|
|
703
|
+
if (o1 !== o2 && o3 !== o4) return true;
|
|
704
|
+
return onSegment(a, b, c) || onSegment(a, b, d) || onSegment(c, d, a) || onSegment(c, d, b);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The first pair of non-adjacent edges that meet, or null when the polygon is
|
|
709
|
+
* strictly simple.
|
|
710
|
+
*
|
|
711
|
+
* "Meet" includes touching at a point, and that strictness is load-bearing: the
|
|
712
|
+
* two-ears theorem holds for a strictly simple polygon, so an ear-clipper that
|
|
713
|
+
* runs on one cannot stall. Accepting a polygon that touches itself would trade a
|
|
714
|
+
* named refusal for a build that either stalls or emits a fan of slivers.
|
|
715
|
+
*/
|
|
716
|
+
export function findSelfIntersection(poly: Array<[number, number]>): [number, number] | null {
|
|
717
|
+
const n = poly.length;
|
|
718
|
+
for (let i = 0; i < n; i++) {
|
|
719
|
+
const a = poly[i];
|
|
720
|
+
const b = poly[(i + 1) % n];
|
|
721
|
+
for (let j = i + 1; j < n; j++) {
|
|
722
|
+
if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue;
|
|
723
|
+
if (segmentsMeet(a, b, poly[j], poly[(j + 1) % n])) return [i, j];
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Is `p` inside (or on) the triangle `a,b,c` wound in `orient`? */
|
|
730
|
+
function pointInTriangle(
|
|
731
|
+
p: readonly [number, number],
|
|
732
|
+
a: readonly [number, number],
|
|
733
|
+
b: readonly [number, number],
|
|
734
|
+
c: readonly [number, number],
|
|
735
|
+
orient: number,
|
|
736
|
+
): boolean {
|
|
737
|
+
const side = (u: readonly [number, number], v: readonly [number, number]): number =>
|
|
738
|
+
((v[0] - u[0]) * (p[1] - u[1]) - (v[1] - u[1]) * (p[0] - u[0])) * orient;
|
|
739
|
+
return side(a, b) >= -1e-9 && side(b, c) >= -1e-9 && side(c, a) >= -1e-9;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Ear clipping over one strictly simple polygon, concave welcome.
|
|
744
|
+
*
|
|
745
|
+
* ## What it does
|
|
746
|
+
*
|
|
747
|
+
* Repeatedly find a vertex whose two edges make a convex turn in the polygon's
|
|
748
|
+
* own winding and whose triangle holds no other vertex, emit it as a triangle,
|
|
749
|
+
* and remove it. The emitted triples carry the polygon's own winding, so a
|
|
750
|
+
* clockwise-on-screen outline yields triangles that are counter-clockwise in
|
|
751
|
+
* Spine world after the y flip — the convention the rest of this file keeps.
|
|
752
|
+
*
|
|
753
|
+
* ## What it refuses, and why by name
|
|
754
|
+
*
|
|
755
|
+
* - **Holes.** There is no bridging step here, so a polygon is an outline and
|
|
756
|
+
* nothing else. `traceAlphaOutline` therefore hands over the OUTER boundary and
|
|
757
|
+
* reports the enclosed transparent area rather than cutting it out; those
|
|
758
|
+
* pixels are inside the mesh and draw nothing, because their alpha is still 0.
|
|
759
|
+
* - **Self-intersection**, including a polygon that merely touches itself:
|
|
760
|
+
* refused upstream by `findSelfIntersection`, because the two-ears theorem — the
|
|
761
|
+
* guarantee that this loop terminates — is a statement about a strictly simple
|
|
762
|
+
* polygon.
|
|
763
|
+
* - **No ear found** while three or more vertices remain. On a strictly simple
|
|
764
|
+
* polygon that cannot happen, so reaching it means an input this function was
|
|
765
|
+
* promised it would not get, and it says so instead of emitting a fan of
|
|
766
|
+
* slivers that would load and render as folded art.
|
|
767
|
+
*
|
|
768
|
+
* O(n²) per ear, O(n³) overall, and deliberately so: `maxVertices` bounds n at
|
|
769
|
+
* the tens, an outline that needs thousands of vertices is not a mesh anybody
|
|
770
|
+
* wants in a runtime's inner loop, and a sweep-line would be a second geometry
|
|
771
|
+
* kernel to be wrong in.
|
|
772
|
+
*/
|
|
773
|
+
export function earClip(poly: Array<[number, number]>): number[] {
|
|
774
|
+
const n = poly.length;
|
|
775
|
+
if (n < 3) throw new MeshError(`a polygon needs at least 3 vertices to triangulate, got ${n}`);
|
|
776
|
+
const area = signedArea(poly);
|
|
777
|
+
if (Math.abs(area) < 1e-9) throw new MeshError('the polygon encloses no area, so it has no triangles');
|
|
778
|
+
const orient = area > 0 ? 1 : -1;
|
|
779
|
+
const live: number[] = [];
|
|
780
|
+
for (let i = 0; i < n; i++) live.push(i);
|
|
781
|
+
const triangles: number[] = [];
|
|
782
|
+
while (live.length > 3) {
|
|
783
|
+
let clipped = false;
|
|
784
|
+
for (let k = 0; k < live.length; k++) {
|
|
785
|
+
const ia = live[(k + live.length - 1) % live.length];
|
|
786
|
+
const ib = live[k];
|
|
787
|
+
const ic = live[(k + 1) % live.length];
|
|
788
|
+
const a = poly[ia];
|
|
789
|
+
const b = poly[ib];
|
|
790
|
+
const c = poly[ic];
|
|
791
|
+
const turn = ((b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])) * orient;
|
|
792
|
+
if (turn <= 1e-12) continue; // reflex or straight: not an ear
|
|
793
|
+
let blocked = false;
|
|
794
|
+
for (const j of live) {
|
|
795
|
+
if (j === ia || j === ib || j === ic) continue;
|
|
796
|
+
if (pointInTriangle(poly[j], a, b, c, orient)) {
|
|
797
|
+
blocked = true;
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (blocked) continue;
|
|
802
|
+
triangles.push(ia, ib, ic);
|
|
803
|
+
live.splice(k, 1);
|
|
804
|
+
clipped = true;
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
if (!clipped) {
|
|
808
|
+
throw new MeshError(
|
|
809
|
+
`ear clipping stalled with ${live.length} of ${n} vertices left: every remaining corner is reflex or ` +
|
|
810
|
+
'covers another vertex, which a strictly simple polygon cannot be — the outline is degenerate',
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
triangles.push(live[0], live[1], live[2]);
|
|
815
|
+
return triangles;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Which pixels of a mask are art, as 1/0 bytes. */
|
|
819
|
+
function artOf(mask: AlphaMask, threshold: number): Uint8Array {
|
|
820
|
+
const out = new Uint8Array(mask.width * mask.height);
|
|
821
|
+
for (let i = 0; i < out.length; i++) out[i] = mask.alpha[i] >= threshold ? 1 : 0;
|
|
822
|
+
return out;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** 4-connected island labels, 1-based; 0 is background. */
|
|
826
|
+
function labelIslands(art: Uint8Array, w: number, h: number): { label: Int32Array; sizes: number[] } {
|
|
827
|
+
const label = new Int32Array(w * h);
|
|
828
|
+
const sizes: number[] = [];
|
|
829
|
+
const stack: number[] = [];
|
|
830
|
+
for (let start = 0; start < art.length; start++) {
|
|
831
|
+
if (!art[start] || label[start]) continue;
|
|
832
|
+
const id = sizes.length + 1;
|
|
833
|
+
let size = 0;
|
|
834
|
+
label[start] = id;
|
|
835
|
+
stack.push(start);
|
|
836
|
+
while (stack.length) {
|
|
837
|
+
const at = stack.pop()!;
|
|
838
|
+
size++;
|
|
839
|
+
const x = at % w;
|
|
840
|
+
const y = (at - x) / w;
|
|
841
|
+
const push = (nx: number, ny: number): void => {
|
|
842
|
+
if (nx < 0 || ny < 0 || nx >= w || ny >= h) return;
|
|
843
|
+
const to = ny * w + nx;
|
|
844
|
+
if (!art[to] || label[to]) return;
|
|
845
|
+
label[to] = id;
|
|
846
|
+
stack.push(to);
|
|
847
|
+
};
|
|
848
|
+
push(x - 1, y);
|
|
849
|
+
push(x + 1, y);
|
|
850
|
+
push(x, y - 1);
|
|
851
|
+
push(x, y + 1);
|
|
852
|
+
}
|
|
853
|
+
sizes.push(size);
|
|
854
|
+
}
|
|
855
|
+
return { label, sizes };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Trace the outer boundary of the largest island of art, on the pixel-CORNER
|
|
860
|
+
* lattice.
|
|
861
|
+
*
|
|
862
|
+
* ## Why the corner lattice and not the pixel centres
|
|
863
|
+
*
|
|
864
|
+
* A contour through pixel centres runs half a pixel inside the art all the way
|
|
865
|
+
* round, so the outermost row of every edge falls outside the mesh — a mesh that
|
|
866
|
+
* clips the art by half a pixel everywhere, which is exactly the defect this
|
|
867
|
+
* whole builder has to not have. On the corner lattice the traced polygon
|
|
868
|
+
* encloses every art pixel's full square, so the raw trace clips nothing at all
|
|
869
|
+
* and everything after it is a controlled retreat from that.
|
|
870
|
+
*
|
|
871
|
+
* ## The walk
|
|
872
|
+
*
|
|
873
|
+
* Each art pixel whose neighbour is background contributes one directed "crack"
|
|
874
|
+
* along the shared edge, wound so that the art stays on the walker's right: top
|
|
875
|
+
* edge to +x, right edge to +y, bottom to -x, left to -y. Those cracks form
|
|
876
|
+
* closed loops. Starting at the first art pixel in row order — whose top
|
|
877
|
+
* neighbour is background by construction — and always taking the sharpest
|
|
878
|
+
* clockwise turn available walks the island's outer loop.
|
|
879
|
+
*
|
|
880
|
+
* ⚠️ The clockwise rule is not a preference. At a corner where two art pixels
|
|
881
|
+
* meet diagonally, two cracks leave the same point and the choice decides whether
|
|
882
|
+
* the walk treats the diagonal as connected. Clockwise keeps it disconnected,
|
|
883
|
+
* which is the same 4-connectivity `labelIslands` used — the alternative would
|
|
884
|
+
* trace a boundary for an island the labelling says is two.
|
|
885
|
+
*/
|
|
886
|
+
export function traceAlphaOutline(
|
|
887
|
+
mask: AlphaMask,
|
|
888
|
+
threshold: number,
|
|
889
|
+
): {
|
|
890
|
+
outline: Array<[number, number]>;
|
|
891
|
+
artPixels: number;
|
|
892
|
+
islandPixels: number;
|
|
893
|
+
islands: number;
|
|
894
|
+
holePixels: number;
|
|
895
|
+
/** The filled silhouette: the island plus every transparent pixel it encloses. */
|
|
896
|
+
filled: Uint8Array;
|
|
897
|
+
} {
|
|
898
|
+
const { width: w, height: h } = mask;
|
|
899
|
+
if (!(w > 0 && h > 0)) throw new MeshError(`bad part size ${w}x${h}`);
|
|
900
|
+
if (mask.alpha.length !== w * h) {
|
|
901
|
+
throw new MeshError(`the alpha mask holds ${mask.alpha.length} bytes for a ${w}x${h} part`);
|
|
902
|
+
}
|
|
903
|
+
const art = artOf(mask, threshold);
|
|
904
|
+
let artPixels = 0;
|
|
905
|
+
for (const bit of art) artPixels += bit;
|
|
906
|
+
if (artPixels === 0) {
|
|
907
|
+
throw new MeshError(`no pixel of the ${w}x${h} part reaches alpha ${threshold}; there is no silhouette to trace`);
|
|
908
|
+
}
|
|
909
|
+
const { label, sizes } = labelIslands(art, w, h);
|
|
910
|
+
let biggest = 1;
|
|
911
|
+
for (let i = 0; i < sizes.length; i++) if (sizes[i] > sizes[biggest - 1]) biggest = i + 1;
|
|
912
|
+
const inside = new Uint8Array(w * h);
|
|
913
|
+
for (let i = 0; i < inside.length; i++) inside[i] = label[i] === biggest ? 1 : 0;
|
|
914
|
+
|
|
915
|
+
const at = (x: number, y: number): number => (x < 0 || y < 0 || x >= w || y >= h ? 0 : inside[y * w + x]);
|
|
916
|
+
/** Outgoing cracks at one lattice corner, as direction indices. */
|
|
917
|
+
const outgoing = (cx: number, cy: number): number[] => {
|
|
918
|
+
const dirs: number[] = [];
|
|
919
|
+
if (at(cx, cy) && !at(cx, cy - 1)) dirs.push(0); // this pixel's top edge
|
|
920
|
+
if (at(cx - 1, cy) && !at(cx, cy)) dirs.push(1); // left neighbour's right edge
|
|
921
|
+
if (at(cx - 1, cy - 1) && !at(cx - 1, cy)) dirs.push(2); // upper-left's bottom edge
|
|
922
|
+
if (at(cx, cy - 1) && !at(cx - 1, cy - 1)) dirs.push(3); // upper's left edge
|
|
923
|
+
return dirs;
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// A DIAGONAL PINCH is refused before the walk, not during it. At a corner where
|
|
927
|
+
// two art pixels meet diagonally with background on the other diagonal, two
|
|
928
|
+
// cracks leave the same point — so the boundary is not a set of simple loops
|
|
929
|
+
// any more, and whichever pairing the walk chooses it either passes through one
|
|
930
|
+
// point twice or leaves part of the outline untraced. Both are silent: the
|
|
931
|
+
// second one produces a perfectly valid mesh of the wrong region. Scanning for
|
|
932
|
+
// it costs one pass and names the pixel corner.
|
|
933
|
+
for (let cy = 0; cy <= h; cy++) {
|
|
934
|
+
for (let cx = 0; cx <= w; cx++) {
|
|
935
|
+
const tl = at(cx - 1, cy - 1);
|
|
936
|
+
const tr = at(cx, cy - 1);
|
|
937
|
+
const bl = at(cx - 1, cy);
|
|
938
|
+
const br = at(cx, cy);
|
|
939
|
+
if ((tl && br && !tr && !bl) || (tr && bl && !tl && !br)) {
|
|
940
|
+
throw new MeshError(
|
|
941
|
+
`the alpha silhouette pinches to a single point at pixel corner (${cx},${cy}), where two parts of the art ` +
|
|
942
|
+
'meet diagonally — one outline cannot pass through one point twice. Raise the alpha threshold so the ' +
|
|
943
|
+
'pinch closes or opens, or author the geometry as weights',
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
let startAt = -1;
|
|
950
|
+
for (let i = 0; i < inside.length; i++) {
|
|
951
|
+
if (inside[i]) {
|
|
952
|
+
startAt = i;
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const sx = startAt % w;
|
|
957
|
+
const sy = (startAt - sx) / w;
|
|
958
|
+
// With no pinch anywhere, every corner has at most one outgoing crack, so the
|
|
959
|
+
// boundary is a disjoint union of simple loops and this walk traces exactly
|
|
960
|
+
// one of them. Starting on the top edge of the first art pixel in row order
|
|
961
|
+
// puts it on the OUTER loop; a hole's loop is never entered, which is what
|
|
962
|
+
// makes "holes are filled" a property of the trace rather than a later repair.
|
|
963
|
+
const outline: Array<[number, number]> = [];
|
|
964
|
+
let cx = sx;
|
|
965
|
+
let cy = sy;
|
|
966
|
+
let dir = 0;
|
|
967
|
+
const limit = 2 * (w + 1) * (h + 1) + 8;
|
|
968
|
+
for (let step = 0; ; step++) {
|
|
969
|
+
if (step > limit) throw new MeshError('the alpha trace did not close; the mask is not a closed region');
|
|
970
|
+
outline.push([cx, cy]);
|
|
971
|
+
cx += CRACK_DIRS[dir][0];
|
|
972
|
+
cy += CRACK_DIRS[dir][1];
|
|
973
|
+
if (cx === sx && cy === sy) break;
|
|
974
|
+
const dirs = outgoing(cx, cy);
|
|
975
|
+
// Sharpest clockwise turn first: right, straight, left. A reversal is not
|
|
976
|
+
// reachable on a crack set, so its absence is not a case to handle.
|
|
977
|
+
const next = [(dir + 1) % 4, dir, (dir + 3) % 4].find((d) => dirs.includes(d));
|
|
978
|
+
if (next === undefined) {
|
|
979
|
+
throw new MeshError(`the alpha trace reached a dead end at corner (${cx},${cy}); the mask is not a closed region`);
|
|
980
|
+
}
|
|
981
|
+
dir = next;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Holes: background pixels the island encloses. Flooded from outside the part,
|
|
985
|
+
// so "enclosed" is decided by reachability rather than by a winding rule.
|
|
986
|
+
const outsideReach = new Uint8Array(w * h);
|
|
987
|
+
const queue: number[] = [];
|
|
988
|
+
const seed = (x: number, y: number): void => {
|
|
989
|
+
if (x < 0 || y < 0 || x >= w || y >= h) return;
|
|
990
|
+
const i = y * w + x;
|
|
991
|
+
if (inside[i] || outsideReach[i]) return;
|
|
992
|
+
outsideReach[i] = 1;
|
|
993
|
+
queue.push(i);
|
|
994
|
+
};
|
|
995
|
+
for (let x = 0; x < w; x++) {
|
|
996
|
+
seed(x, 0);
|
|
997
|
+
seed(x, h - 1);
|
|
998
|
+
}
|
|
999
|
+
for (let y = 0; y < h; y++) {
|
|
1000
|
+
seed(0, y);
|
|
1001
|
+
seed(w - 1, y);
|
|
1002
|
+
}
|
|
1003
|
+
while (queue.length) {
|
|
1004
|
+
const i = queue.pop()!;
|
|
1005
|
+
const x = i % w;
|
|
1006
|
+
const y = (i - x) / w;
|
|
1007
|
+
seed(x - 1, y);
|
|
1008
|
+
seed(x + 1, y);
|
|
1009
|
+
seed(x, y - 1);
|
|
1010
|
+
seed(x, y + 1);
|
|
1011
|
+
}
|
|
1012
|
+
const filled = new Uint8Array(w * h);
|
|
1013
|
+
let holePixels = 0;
|
|
1014
|
+
for (let i = 0; i < filled.length; i++) {
|
|
1015
|
+
if (inside[i]) filled[i] = 1;
|
|
1016
|
+
else if (!outsideReach[i]) {
|
|
1017
|
+
filled[i] = 1;
|
|
1018
|
+
holePixels++;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return { outline, artPixels, islandPixels: sizes[biggest - 1], islands: sizes.length, holePixels, filled };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Rasterise a triangle set over the part's pixel grid and measure two things the
|
|
1027
|
+
* skeleton file cannot say: how much of the art the mesh covers, and how far past
|
|
1028
|
+
* the silhouette it reaches.
|
|
1029
|
+
*
|
|
1030
|
+
* Coverage is against the ART (every pixel at or above the threshold, on any
|
|
1031
|
+
* island), so a second island the outline could never reach shows up as missing
|
|
1032
|
+
* coverage rather than as a passing build. Overshoot is against the FILLED
|
|
1033
|
+
* silhouette — the island plus the transparent pixels it encloses — because a
|
|
1034
|
+
* hole the mesh spans is not the mesh reaching past the art, it is the mesh
|
|
1035
|
+
* spanning a gap in it, and those pixels draw nothing either way.
|
|
1036
|
+
*
|
|
1037
|
+
* A pixel counts as covered when its CENTRE is in or on a triangle, which is the
|
|
1038
|
+
* same convention `src/render.ts` rasterises by. `radius` bounds the distance
|
|
1039
|
+
* search: nothing beyond it needs a number, because a mesh that reaches that far
|
|
1040
|
+
* out is refused whatever the exact figure is.
|
|
1041
|
+
*/
|
|
1042
|
+
export function measureContourFit(
|
|
1043
|
+
mask: AlphaMask,
|
|
1044
|
+
threshold: number,
|
|
1045
|
+
filled: Uint8Array,
|
|
1046
|
+
points: Array<[number, number]>,
|
|
1047
|
+
triangles: number[],
|
|
1048
|
+
radius: number,
|
|
1049
|
+
): { coverage: number; overshoot: number; artPixels: number; coveredArt: number } {
|
|
1050
|
+
const { width: w, height: h } = mask;
|
|
1051
|
+
const art = artOf(mask, threshold);
|
|
1052
|
+
const covered = new Uint8Array(w * h);
|
|
1053
|
+
for (let t = 0; t + 2 < triangles.length; t += 3) {
|
|
1054
|
+
const a = points[triangles[t]];
|
|
1055
|
+
const b = points[triangles[t + 1]];
|
|
1056
|
+
const c = points[triangles[t + 2]];
|
|
1057
|
+
const twice = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
1058
|
+
if (Math.abs(twice) < 1e-12) continue;
|
|
1059
|
+
const orient = twice > 0 ? 1 : -1;
|
|
1060
|
+
const minX = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0]) - 1));
|
|
1061
|
+
const maxX = Math.min(w - 1, Math.ceil(Math.max(a[0], b[0], c[0]) + 1));
|
|
1062
|
+
const minY = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1]) - 1));
|
|
1063
|
+
const maxY = Math.min(h - 1, Math.ceil(Math.max(a[1], b[1], c[1]) + 1));
|
|
1064
|
+
for (let y = minY; y <= maxY; y++) {
|
|
1065
|
+
for (let x = minX; x <= maxX; x++) {
|
|
1066
|
+
if (covered[y * w + x]) continue;
|
|
1067
|
+
if (pointInTriangle([x + 0.5, y + 0.5], a, b, c, orient)) covered[y * w + x] = 1;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
let artPixels = 0;
|
|
1072
|
+
let coveredArt = 0;
|
|
1073
|
+
for (let i = 0; i < art.length; i++) {
|
|
1074
|
+
if (!art[i]) continue;
|
|
1075
|
+
artPixels++;
|
|
1076
|
+
if (covered[i]) coveredArt++;
|
|
1077
|
+
}
|
|
1078
|
+
const reach = Math.max(1, Math.ceil(radius));
|
|
1079
|
+
let overshoot = 0;
|
|
1080
|
+
for (let y = 0; y < h; y++) {
|
|
1081
|
+
for (let x = 0; x < w; x++) {
|
|
1082
|
+
const i = y * w + x;
|
|
1083
|
+
if (!covered[i] || filled[i]) continue;
|
|
1084
|
+
let best = reach + 1;
|
|
1085
|
+
for (let dy = -reach; dy <= reach; dy++) {
|
|
1086
|
+
const ny = y + dy;
|
|
1087
|
+
if (ny < 0 || ny >= h) continue;
|
|
1088
|
+
for (let dx = -reach; dx <= reach; dx++) {
|
|
1089
|
+
const nx = x + dx;
|
|
1090
|
+
if (nx < 0 || nx >= w) continue;
|
|
1091
|
+
if (!filled[ny * w + nx]) continue;
|
|
1092
|
+
const d = Math.hypot(dx, dy);
|
|
1093
|
+
if (d < best) best = d;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (best > overshoot) overshoot = best;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return {
|
|
1100
|
+
coverage: artPixels === 0 ? 0 : coveredArt / artPixels,
|
|
1101
|
+
overshoot: r6(overshoot),
|
|
1102
|
+
artPixels,
|
|
1103
|
+
coveredArt,
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Build a mesh cut to the part's own alpha silhouette.
|
|
1109
|
+
*
|
|
1110
|
+
* Trace, simplify, push out, clamp to the window, triangulate, and then MEASURE
|
|
1111
|
+
* the result against the mask it came from. Every vertex is pinned to the slot
|
|
1112
|
+
* bone at weight 1 — see the section header for why that is the whole weighting
|
|
1113
|
+
* model and what to reach for instead when a bone has to bend the art.
|
|
1114
|
+
*/
|
|
1115
|
+
export function buildContourMesh(input: ContourSpecInput): MeshGeometry {
|
|
1116
|
+
const { mask, threshold, tolerance, margin, maxVertices } = input;
|
|
1117
|
+
const [w, h] = [mask.width, mask.height];
|
|
1118
|
+
if (!Number.isInteger(threshold) || threshold < 1 || threshold > 255) {
|
|
1119
|
+
throw new MeshError(`the alpha threshold must be a whole number in 1..255, got ${threshold}`);
|
|
1120
|
+
}
|
|
1121
|
+
if (!(tolerance > 0) || !Number.isFinite(tolerance)) {
|
|
1122
|
+
throw new MeshError(`the simplification tolerance must be a positive number of pixels, got ${tolerance}`);
|
|
1123
|
+
}
|
|
1124
|
+
if (!(margin >= 0) || !Number.isFinite(margin)) throw new MeshError(`the margin must be 0 or more pixels, got ${margin}`);
|
|
1125
|
+
if (!Number.isInteger(maxVertices) || maxVertices < 3) {
|
|
1126
|
+
throw new MeshError(`maxVertices must be a whole number of at least 3, got ${maxVertices}`);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const traced = traceAlphaOutline(mask, threshold);
|
|
1130
|
+
// ⭐ A part with no transparent pixel at all, MEASURED rather than read off the
|
|
1131
|
+
// PNG's colour type. A truecolour+alpha file whose alpha is 255 everywhere has
|
|
1132
|
+
// an alpha channel and no transparency, so the header answers the wrong
|
|
1133
|
+
// question — and the silhouette of such a part is the part window, which makes
|
|
1134
|
+
// a contour mesh of it a region attachment with extra vertices to pose.
|
|
1135
|
+
if (traced.artPixels === w * h) {
|
|
1136
|
+
throw new MeshError(
|
|
1137
|
+
`every pixel of the ${w}x${h} part reaches alpha ${threshold}, so its silhouette IS the part window and a ` +
|
|
1138
|
+
'contour mesh of it is a region attachment with extra vertices — give the art a transparent margin, ' +
|
|
1139
|
+
'raise the alpha threshold, or use a region',
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
// Islands, named before anything is triangulated. One outline encloses one
|
|
1143
|
+
// region, so art scattered over several would come out as missing coverage
|
|
1144
|
+
// further down — a true refusal with a message about margins, which is not
|
|
1145
|
+
// the thing to change.
|
|
1146
|
+
if (traced.islandPixels / traced.artPixels < CONTOUR_MIN_COVERAGE) {
|
|
1147
|
+
throw new MeshError(
|
|
1148
|
+
`the art is ${traced.islands} separate islands and one outline can only enclose the largest ` +
|
|
1149
|
+
`(${traced.islandPixels} of ${traced.artPixels} px, ` +
|
|
1150
|
+
`${((traced.islandPixels / traced.artPixels) * 100).toFixed(2)}%) — raise the alpha threshold if the ` +
|
|
1151
|
+
'strays are feathering, or give each island its own slot',
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
const simplified = simplifyClosedPolygon(traced.outline, tolerance);
|
|
1155
|
+
const pushed = offsetPolygon(simplified, margin);
|
|
1156
|
+
// Clamped to the part window, because a uv outside 0..1 is a different failure
|
|
1157
|
+
// (A22) and because there is no art out there to reach for anyway.
|
|
1158
|
+
const clamped = pushed.map(
|
|
1159
|
+
([x, y]) => [Math.min(w, Math.max(0, x)), Math.min(h, Math.max(0, y))] as [number, number],
|
|
1160
|
+
);
|
|
1161
|
+
const points = prunePolygon(clamped).map(([x, y]) => [r6(x), r6(y)] as [number, number]);
|
|
1162
|
+
if (points.length < 3) {
|
|
1163
|
+
throw new MeshError(
|
|
1164
|
+
`the ${w}x${h} silhouette simplified to ${points.length} distinct vertices at tolerance ${tolerance}; ` +
|
|
1165
|
+
'lower the tolerance',
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
if (points.length > maxVertices) {
|
|
1169
|
+
throw new MeshError(
|
|
1170
|
+
`the silhouette simplified to ${points.length} vertices at tolerance ${tolerance}, past the ${maxVertices} ` +
|
|
1171
|
+
'this mesh allows — raise the tolerance to spend fewer vertices, or raise maxVertices if the shape needs them',
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
const crossing = findSelfIntersection(points);
|
|
1175
|
+
if (crossing) {
|
|
1176
|
+
throw new MeshError(
|
|
1177
|
+
`the outline crosses itself: edge ${crossing[0]} meets edge ${crossing[1]} after a margin of ${margin}px ` +
|
|
1178
|
+
'was pushed out of a silhouette narrower than that — lower the margin, or the art has a neck too thin to mesh',
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
const triangles = earClip(points);
|
|
1182
|
+
|
|
1183
|
+
const allowed = contourOvershootBound(margin, tolerance);
|
|
1184
|
+
const fit = measureContourFit(mask, threshold, traced.filled, points, triangles, allowed + 1);
|
|
1185
|
+
if (fit.coverage < CONTOUR_MIN_COVERAGE) {
|
|
1186
|
+
throw new MeshError(
|
|
1187
|
+
`the mesh covers ${(fit.coverage * 100).toFixed(2)}% of the art (${fit.coveredArt} of ${fit.artPixels} px), ` +
|
|
1188
|
+
`under the ${(CONTOUR_MIN_COVERAGE * 100).toFixed(1)}% a contour mesh guarantees — raise the margin ` +
|
|
1189
|
+
`(now ${margin}px) above the tolerance (${tolerance}px), which is how far simplification is allowed to ` +
|
|
1190
|
+
'cut inward, or lower the tolerance',
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
if (fit.overshoot > allowed) {
|
|
1194
|
+
throw new MeshError(
|
|
1195
|
+
`the mesh reaches ${fit.overshoot.toFixed(2)}px past the silhouette, past the ${allowed.toFixed(2)}px that a ` +
|
|
1196
|
+
`margin of ${margin} and a tolerance of ${tolerance} can produce — the outline is not the one this builder ` +
|
|
1197
|
+
'promises, which is a defect in the builder rather than in the art',
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const uvs: number[] = [];
|
|
1202
|
+
for (const [x, y] of points) uvs.push(r6(x / w), r6(y / h));
|
|
1203
|
+
const weights: MeshVertexWeight[][] = points.map(() => [{ bone: 'anchor', weight: 1 }]);
|
|
1204
|
+
|
|
1205
|
+
return {
|
|
1206
|
+
kind: 'contour',
|
|
1207
|
+
points,
|
|
1208
|
+
uvs,
|
|
1209
|
+
triangles,
|
|
1210
|
+
weights,
|
|
1211
|
+
hullVertices: points.length,
|
|
1212
|
+
contour: {
|
|
1213
|
+
artPixels: fit.artPixels,
|
|
1214
|
+
islandPixels: traced.islandPixels,
|
|
1215
|
+
islands: traced.islands,
|
|
1216
|
+
holePixels: traced.holePixels,
|
|
1217
|
+
tracedVertices: traced.outline.length,
|
|
1218
|
+
coverage: r6(fit.coverage),
|
|
1219
|
+
overshoot: fit.overshoot,
|
|
1220
|
+
},
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
395
1224
|
/** One bone a weighted vertex can bind to: its index, and its world inverse. */
|
|
396
1225
|
export interface MeshBoneRef {
|
|
397
1226
|
index: number;
|