rig-c 0.0.0-stage → 2.21.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1191 -0
- package/src/meshquality.ts +2051 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1444 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
package/src/mesh.ts
ADDED
|
@@ -0,0 +1,2382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Procedural mesh geometry — three builders, two different jobs.
|
|
3
|
+
*
|
|
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
|
|
13
|
+
* nailed down and only the inside moves.** For a face cut that ring is the mouth
|
|
14
|
+
* aperture, and the rim already exists as data — the mask polygon in the cut
|
|
15
|
+
* manifest is exactly the contour where the generated part fades into untouched
|
|
16
|
+
* base pixels. Pin those vertices to the slot bone at weight 1 and the seam
|
|
17
|
+
* cannot move, which is the whole reason a mesh is safe here at all.
|
|
18
|
+
*
|
|
19
|
+
* Three rings and a hub:
|
|
20
|
+
*
|
|
21
|
+
* rim ring = the part WINDOW edge weight 1.0 -> anchor bone
|
|
22
|
+
* seam ring = the manifest polygon weight 1.0 -> anchor bone
|
|
23
|
+
* inner ring = the polygon scaled toward the weight w -> control bone
|
|
24
|
+
* aperture centre by `inner`
|
|
25
|
+
* hub = the aperture centre weight 1.0 -> control bone
|
|
26
|
+
*
|
|
27
|
+
* The rim ring is not decoration and the first cut of this code did not have it.
|
|
28
|
+
* A generated part carries alpha well OUTSIDE its mask polygon — thousands of
|
|
29
|
+
* pixels of it, on the one this was measured against — and that band is the
|
|
30
|
+
* feather, the soft ramp that makes generated pixels blend into the base at all.
|
|
31
|
+
* A mesh whose outer ring is the polygon simply does not draw them, and a render
|
|
32
|
+
* probe caught exactly that: a halo of difference reaching several pixels past
|
|
33
|
+
* the polygon, versus the rigid build of the same part. So the
|
|
34
|
+
* mesh covers the whole region (rim ring on the window edge, uv 0 and 1) and the
|
|
35
|
+
* polygon becomes an interior ring — pinned, because it is still the seam.
|
|
36
|
+
*
|
|
37
|
+
* The control bone carries every key, so key count is independent of vertex
|
|
38
|
+
* count and a physics constraint could later be hung on the same bone for free.
|
|
39
|
+
* There are no deform timelines: deforming the vertices directly is the fallback
|
|
40
|
+
* for when procedural weighting fails, and it has not.
|
|
41
|
+
*
|
|
42
|
+
* Everything here is pure and integer-stable: same manifest in, same floats out
|
|
43
|
+
* (assertion A18 recompiles and compares bytes).
|
|
44
|
+
*/
|
|
45
|
+
import type { ModelBinding, ModelVertices } from './model.ts';
|
|
46
|
+
|
|
47
|
+
// The mesh-quality measurement and its report (issue #1224) live in their own
|
|
48
|
+
// module and reach a dependant through this file, which is what
|
|
49
|
+
// `spine-rigc/mesh` names. That module imports helpers from this one, so the
|
|
50
|
+
// two form an import cycle: safe only because neither reads the other's
|
|
51
|
+
// bindings while it is being evaluated — every use is inside a function.
|
|
52
|
+
// The reduction (`reduceMesh`, stage B2 of #1224) sits in the same cycle, by
|
|
53
|
+
// the same rule.
|
|
54
|
+
export * from './meshquality.ts';
|
|
55
|
+
export * from './meshreduce.ts';
|
|
56
|
+
|
|
57
|
+
export interface MeshSpecInput {
|
|
58
|
+
/** Polygon in part-local pixels, y down, in manifest order. */
|
|
59
|
+
hull: Array<[number, number]>;
|
|
60
|
+
/** Aperture centre in part-local pixels, y down. */
|
|
61
|
+
center: [number, number];
|
|
62
|
+
/** Inner ring position: 0 = at the centre, 1 = on the hull. */
|
|
63
|
+
inner: number;
|
|
64
|
+
/** Part window size in pixels, for UVs. */
|
|
65
|
+
size: [number, number];
|
|
66
|
+
/** Optional directional weighting across the mouth line — see `sideWeight`. */
|
|
67
|
+
bias?: { axis_deg: number; ramp: [number, number] };
|
|
68
|
+
/**
|
|
69
|
+
* Screen-space angle of each control bone as seen from the aperture centre, in
|
|
70
|
+
* control-bone order. One entry keeps the single-bone behaviour exactly; more
|
|
71
|
+
* than one splits the ring's authority by angular position, which is how four
|
|
72
|
+
* grips make a ring expand unevenly without a key per vertex.
|
|
73
|
+
*/
|
|
74
|
+
controlAngles?: number[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A lattice over the part window — the topology `docs/FACE.md` §4 turns a plate
|
|
79
|
+
* into so that a `yaw` has somewhere to put its columns.
|
|
80
|
+
*
|
|
81
|
+
* ⭐ It exists because that lattice was being written BY HAND. `gallery/portrait`
|
|
82
|
+
* shipped a 5x5 grid as 25 authored vertex pairs, 32 triangles and a perimeter
|
|
83
|
+
* numbered in the one order Spine accepts, and every one of those numbers was
|
|
84
|
+
* a person's arithmetic. A grid is the least interesting geometry in the format
|
|
85
|
+
* and the easiest to get subtly wrong: the hull has to come first, in walk
|
|
86
|
+
* order, or the loader silently treats interior vertices as the outline.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ `us` and `vs` are POSITIONS, not a count, and that is the point of the
|
|
89
|
+
* shape. FACE §4.1 places columns where the drawing needs them — the worked
|
|
90
|
+
* example's are 0.0235, 0.1471, 0.5, 0.8529, 0.9765, which is dense at the
|
|
91
|
+
* silhouette and sparse across the middle — and a generator that could only
|
|
92
|
+
* divide evenly would be a step backwards from the hand-written table it
|
|
93
|
+
* replaces. Nor do they have to reach the window edge: that example's do not.
|
|
94
|
+
*/
|
|
95
|
+
export interface GridSpecInput {
|
|
96
|
+
/** Part window size in pixels, for UVs and positions. */
|
|
97
|
+
size: [number, number];
|
|
98
|
+
/** Column positions across the window, 0..1, ascending. At least 2. */
|
|
99
|
+
us: number[];
|
|
100
|
+
/** Row positions down the window, 0..1, ascending. At least 2. */
|
|
101
|
+
vs: number[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface MeshVertexWeight {
|
|
105
|
+
/** 'anchor' pins to the slot bone, 'control' to a control/chain bone. */
|
|
106
|
+
bone: 'anchor' | 'control';
|
|
107
|
+
/** Index into the control-bone list. Absent means 0. */
|
|
108
|
+
control?: number;
|
|
109
|
+
weight: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Which builder in this file made a mesh's geometry. */
|
|
113
|
+
export type MeshKind = 'ring' | 'ribbon' | 'contour' | 'grid' | 'segments';
|
|
114
|
+
|
|
115
|
+
export interface MeshGeometry {
|
|
116
|
+
kind: MeshKind;
|
|
117
|
+
/** Vertex positions in part-local pixels, y down. */
|
|
118
|
+
points: Array<[number, number]>;
|
|
119
|
+
/** Normalised region UVs, v measured from the top edge. */
|
|
120
|
+
uvs: number[];
|
|
121
|
+
/**
|
|
122
|
+
* Triangle indices, counter-clockwise in Spine world (y up) — every builder
|
|
123
|
+
* here, measured on spine-core's posed world by `CT15` in `selftest.ts`
|
|
124
|
+
* (issue #1236: `contour` and `ring` wound the other way until then).
|
|
125
|
+
*/
|
|
126
|
+
triangles: number[];
|
|
127
|
+
/** Per-vertex weights, parallel to `points`. */
|
|
128
|
+
weights: MeshVertexWeight[][];
|
|
129
|
+
/** Hull vertex count — emitted as `hull`, which the loader doubles. */
|
|
130
|
+
hullVertices: number;
|
|
131
|
+
/** What the contour builder measured about its own fit. Only `contour` has one. */
|
|
132
|
+
contour?: ContourReport;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A two-wide strip along a bone chain — a trickle, a strap, a tail. It changes
|
|
137
|
+
* length without changing width and its path curves; region scale cannot express
|
|
138
|
+
* either, because scaling a region longer also makes it fatter.
|
|
139
|
+
*
|
|
140
|
+
* The width guarantee is structural, not a hope: the two vertices of a row carry
|
|
141
|
+
* IDENTICAL weights, so whatever the chain does to one it does to the other, and
|
|
142
|
+
* their separation can only rotate. Assertion A28 checks exactly that, which
|
|
143
|
+
* turns "length without width" from a claim into a property of the file.
|
|
144
|
+
*/
|
|
145
|
+
export interface RibbonSpecInput {
|
|
146
|
+
/** Part window size in pixels. The strip spans it, so uv 0 and 1 are covered. */
|
|
147
|
+
size: [number, number];
|
|
148
|
+
/** Cross rows, entry first. Triangles = 2 * (rows - 1). */
|
|
149
|
+
rows: number;
|
|
150
|
+
/** Number of chain bones after the anchor. */
|
|
151
|
+
chainCount: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class MeshError extends Error {}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A refusal of the mesh-quality operations (`src/meshquality.ts`, issue #1224),
|
|
158
|
+
* by code: a `MeshError`, so a dependant that already catches `MeshError` keeps
|
|
159
|
+
* catching it, and `code` for one that wants to know which refusal it was. The
|
|
160
|
+
* codes are listed in docs/MESH_REDUCTION.md beside the section that defines
|
|
161
|
+
* each.
|
|
162
|
+
*
|
|
163
|
+
* ⚠️ Defined here and not in `src/meshquality.ts`, because that module imports
|
|
164
|
+
* this one and this one re-exports it: a class that extends `MeshError` at the
|
|
165
|
+
* top of the importing module would read `MeshError` before this module had
|
|
166
|
+
* run, and every import of `spine-rigc/mesh` would throw.
|
|
167
|
+
*/
|
|
168
|
+
export class MeshReductionError extends MeshError {
|
|
169
|
+
readonly code: string;
|
|
170
|
+
constructor(code: string, message: string) {
|
|
171
|
+
super(`${code}: ${message}`);
|
|
172
|
+
this.code = code;
|
|
173
|
+
this.name = 'MeshReductionError';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The generator's own grid: 6 decimals, never "-0". Its rows, weights and
|
|
179
|
+
* shares are built on it, and what it produces reaches the file through the
|
|
180
|
+
* compiler's `f32` at emission (issue #716), so the emitted text is still each
|
|
181
|
+
* number's float32 name — this is the generator's resolution, not the file's.
|
|
182
|
+
*/
|
|
183
|
+
export function r6(n: number): number {
|
|
184
|
+
const v = Math.round(n * 1e6) / 1e6;
|
|
185
|
+
return v === 0 ? 0 : v;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Smoothstep falloff from the hub (r=0, full control) to the hull (r=1, none).
|
|
190
|
+
*
|
|
191
|
+
* A linear ramp puts a visible crease on the inner ring because the second
|
|
192
|
+
* derivative jumps there; smoothstep is flat at both ends, so the deformation
|
|
193
|
+
* dies into the pinned rim instead of hitting it.
|
|
194
|
+
*/
|
|
195
|
+
export function falloff(r: number): number {
|
|
196
|
+
const t = Math.max(0, Math.min(1, r));
|
|
197
|
+
return r6(1 - (3 * t * t - 2 * t * t * t));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Directional authority across an axis: 0 on the negative side, 1 on the
|
|
202
|
+
* positive side, smoothstep over `ramp`.
|
|
203
|
+
*
|
|
204
|
+
* This is what makes a jaw read as a jaw. A radial falloff alone deforms the
|
|
205
|
+
* ring symmetrically, so opening the mouth drags the upper lip down with the
|
|
206
|
+
* lower one and takes the upper teeth with it — measured on this art, the teeth
|
|
207
|
+
* sit 15px on the negative side of the mouth line, squarely inside the moving
|
|
208
|
+
* zone. Ramping authority across the line leaves everything above it pinned.
|
|
209
|
+
*/
|
|
210
|
+
export function sideWeight(signedDistance: number, ramp: [number, number]): number {
|
|
211
|
+
const [d0, d1] = ramp;
|
|
212
|
+
if (!(d1 > d0)) throw new MeshError(`bias ramp must increase, got [${d0}, ${d1}]`);
|
|
213
|
+
const t = Math.max(0, Math.min(1, (signedDistance - d0) / (d1 - d0)));
|
|
214
|
+
return r6(3 * t * t - 2 * t * t * t);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Signed area of a polygon in the given (y-down) coordinates. */
|
|
218
|
+
export function signedArea(poly: Array<[number, number]>): number {
|
|
219
|
+
let a = 0;
|
|
220
|
+
for (let i = 0; i < poly.length; i++) {
|
|
221
|
+
const [x0, y0] = poly[i];
|
|
222
|
+
const [x1, y1] = poly[(i + 1) % poly.length];
|
|
223
|
+
a += x0 * y1 - x1 * y0;
|
|
224
|
+
}
|
|
225
|
+
return a / 2;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Turn a triangle list built ALONG `poly` — every triple carrying the
|
|
230
|
+
* polygon's own winding, as an ear-clip of it or a ring strip around it does —
|
|
231
|
+
* counter-clockwise in Spine world, the winding `MeshGeometry.triangles`
|
|
232
|
+
* promises. `poly` is in part-local pixels, y down.
|
|
233
|
+
*
|
|
234
|
+
* Counter-clockwise in Spine world (y up) is a NEGATIVE shoelace area in y-down
|
|
235
|
+
* pixels: the flip changes the sign of the area and not the direction the loop
|
|
236
|
+
* turns as drawn. So a polygon whose y-down area is positive — clockwise on
|
|
237
|
+
* screen, and therefore clockwise in Spine world — has each triangle's last two
|
|
238
|
+
* corners swapped, the way `buildSegmentsLattice` does per triangle; one with a
|
|
239
|
+
* negative area is already right and is returned as it came. The triangle SET
|
|
240
|
+
* is unchanged either way: only the order of two indices inside a triple moves.
|
|
241
|
+
*
|
|
242
|
+
* The decision is the polygon's, not each triangle's, so a near-collinear ear
|
|
243
|
+
* whose own sign is rounding noise is turned with its neighbours rather than
|
|
244
|
+
* read on its own (issue #1236 — `contour` and `ring` emitted every triangle
|
|
245
|
+
* clockwise in Spine world before this existed, under comments that derived the
|
|
246
|
+
* opposite from the y flip).
|
|
247
|
+
*/
|
|
248
|
+
export function windCounterClockwiseInSpineWorld(poly: Array<[number, number]>, triangles: readonly number[]): number[] {
|
|
249
|
+
const out = triangles.slice();
|
|
250
|
+
if (signedArea(poly) <= 0) return out;
|
|
251
|
+
for (let t = 0; t + 2 < out.length; t += 3) [out[t + 1], out[t + 2]] = [out[t + 2], out[t + 1]];
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Is every hull edge visible from `center`? If not, scaling the polygon toward
|
|
257
|
+
* the centre can fold the inner ring through the rim and the triangles cross —
|
|
258
|
+
* a mesh that loads with no error and renders as folded meat. Better to refuse.
|
|
259
|
+
*/
|
|
260
|
+
export function isStarShaped(poly: Array<[number, number]>, center: [number, number]): boolean {
|
|
261
|
+
const area = signedArea(poly);
|
|
262
|
+
const [cx, cy] = center;
|
|
263
|
+
for (let i = 0; i < poly.length; i++) {
|
|
264
|
+
const [x0, y0] = poly[i];
|
|
265
|
+
const [x1, y1] = poly[(i + 1) % poly.length];
|
|
266
|
+
const cross = (x1 - x0) * (cy - y0) - (y1 - y0) * (cx - x0);
|
|
267
|
+
if (cross * area <= 0) return false;
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Cast a ray from `center` through `through` and return the point where it
|
|
274
|
+
* leaves the [0,w]x[0,h] window. The polygon lives inside the window, so the
|
|
275
|
+
* ray always exits after it — this is what keeps the rim ring in 1:1
|
|
276
|
+
* correspondence with the seam ring, and a clean quad strip between them.
|
|
277
|
+
*/
|
|
278
|
+
export function rayToWindowEdge(
|
|
279
|
+
center: [number, number],
|
|
280
|
+
through: [number, number],
|
|
281
|
+
size: [number, number],
|
|
282
|
+
): [number, number] {
|
|
283
|
+
const [cx, cy] = center;
|
|
284
|
+
const [px, py] = through;
|
|
285
|
+
const [w, h] = size;
|
|
286
|
+
const dx = px - cx;
|
|
287
|
+
const dy = py - cy;
|
|
288
|
+
if (dx === 0 && dy === 0) throw new MeshError('a polygon vertex sits exactly on the aperture centre');
|
|
289
|
+
let t = Infinity;
|
|
290
|
+
if (dx > 0) t = Math.min(t, (w - cx) / dx);
|
|
291
|
+
if (dx < 0) t = Math.min(t, (0 - cx) / dx);
|
|
292
|
+
if (dy > 0) t = Math.min(t, (h - cy) / dy);
|
|
293
|
+
if (dy < 0) t = Math.min(t, (0 - cy) / dy);
|
|
294
|
+
if (!Number.isFinite(t) || t <= 0) throw new MeshError('ray to the window edge did not converge');
|
|
295
|
+
return [r6(cx + dx * t), r6(cy + dy * t)];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function buildRingMesh(input: MeshSpecInput): MeshGeometry {
|
|
299
|
+
const { hull, center, inner, size } = input;
|
|
300
|
+
const n = hull.length;
|
|
301
|
+
if (n < 6) throw new MeshError(`hull needs at least 6 points, got ${n}`);
|
|
302
|
+
if (!(inner > 0 && inner < 1)) throw new MeshError(`inner must be in (0,1), got ${inner}`);
|
|
303
|
+
const [w, h] = size;
|
|
304
|
+
if (!(w > 0 && h > 0)) throw new MeshError(`bad part size ${w}x${h}`);
|
|
305
|
+
|
|
306
|
+
for (const [x, y] of hull) {
|
|
307
|
+
if (x < 0 || y < 0 || x > w || y > h) {
|
|
308
|
+
throw new MeshError(`hull point (${x},${y}) is outside the ${w}x${h} part window`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!isStarShaped(hull, center)) {
|
|
312
|
+
throw new MeshError('hull is not star-shaped about the aperture centre; the inner ring would fold');
|
|
313
|
+
}
|
|
314
|
+
const [cx, cy] = center;
|
|
315
|
+
if (cx < 0 || cy < 0 || cx > w || cy > h) {
|
|
316
|
+
throw new MeshError(`aperture centre (${cx},${cy}) is outside the ${w}x${h} part window`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const points: Array<[number, number]> = [];
|
|
320
|
+
const weights: MeshVertexWeight[][] = [];
|
|
321
|
+
const pin = (x: number, y: number) => {
|
|
322
|
+
points.push([r6(x), r6(y)]);
|
|
323
|
+
weights.push([{ bone: 'anchor', weight: 1 }]);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// ring 0 — the window edge. Covers the feather, so nothing the part draws is
|
|
327
|
+
// outside the mesh; pinned, so uv 0/1 stay put.
|
|
328
|
+
for (const [x, y] of hull) {
|
|
329
|
+
const [ex, ey] = rayToWindowEdge(center, [x, y], size);
|
|
330
|
+
pin(ex, ey);
|
|
331
|
+
}
|
|
332
|
+
// ring 1 — the mask contour. This IS the seam: pinned at weight 1.
|
|
333
|
+
for (const [x, y] of hull) pin(x, y);
|
|
334
|
+
// ring 2 — the aperture ring, shared between the two bones by the falloff and,
|
|
335
|
+
// when a bias axis is declared, by which side of the mouth line it lands on.
|
|
336
|
+
const wInner = falloff(inner);
|
|
337
|
+
const axis = input.bias
|
|
338
|
+
? ([Math.cos((input.bias.axis_deg * Math.PI) / 180), Math.sin((input.bias.axis_deg * Math.PI) / 180)] as const)
|
|
339
|
+
: null;
|
|
340
|
+
// Normal of the mouth line, pointing to the jaw side (screen y down).
|
|
341
|
+
const normal = axis ? ([-axis[1], axis[0]] as const) : null;
|
|
342
|
+
const sideOf = (x: number, y: number): number => {
|
|
343
|
+
if (!normal || !input.bias) return 1;
|
|
344
|
+
return sideWeight((x - cx) * normal[0] + (y - cy) * normal[1], input.bias.ramp);
|
|
345
|
+
};
|
|
346
|
+
// Angular split across several control bones. With one control this collapses
|
|
347
|
+
// to "all authority to control 0", which is the single-bone path byte for byte.
|
|
348
|
+
const controlAngles = input.controlAngles ?? [0];
|
|
349
|
+
if (controlAngles.length < 1) throw new MeshError('a ring mesh needs at least one control bone');
|
|
350
|
+
const sorted = controlAngles
|
|
351
|
+
.map((deg, index) => ({ index, deg: ((deg % 360) + 360) % 360 }))
|
|
352
|
+
.sort((p, q) => (p.deg === q.deg ? p.index - q.index : p.deg - q.deg));
|
|
353
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
354
|
+
if (sorted[i].deg === sorted[i - 1].deg) {
|
|
355
|
+
throw new MeshError(`two control bones share the angle ${sorted[i].deg} degrees about the aperture centre`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Which controls own the authority at this angle, and in what proportion. */
|
|
359
|
+
const splitByAngle = (x: number, y: number): Array<{ index: number; share: number }> => {
|
|
360
|
+
if (sorted.length === 1) return [{ index: sorted[0].index, share: 1 }];
|
|
361
|
+
const deg = ((Math.atan2(y - cy, x - cx) * 180) / Math.PI + 360) % 360;
|
|
362
|
+
let k = sorted.length - 1; // the wrap-around arc, unless we find a better one
|
|
363
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
364
|
+
const next = (i + 1) % sorted.length;
|
|
365
|
+
const from = sorted[i].deg;
|
|
366
|
+
const to = sorted[next].deg + (next === 0 ? 360 : 0);
|
|
367
|
+
const d = deg < from ? deg + 360 : deg;
|
|
368
|
+
if (d >= from && d < to) {
|
|
369
|
+
k = i;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const next = (k + 1) % sorted.length;
|
|
374
|
+
const from = sorted[k].deg;
|
|
375
|
+
const to = sorted[next].deg + (next === 0 ? 360 : 0);
|
|
376
|
+
const d = deg < from ? deg + 360 : deg;
|
|
377
|
+
const t = to === from ? 0 : (d - from) / (to - from);
|
|
378
|
+
// Smoothstep for the same reason the radial falloff uses it: a linear blend
|
|
379
|
+
// puts a crease exactly on the bone's angle.
|
|
380
|
+
const s = r6(3 * t * t - 2 * t * t * t);
|
|
381
|
+
const out: Array<{ index: number; share: number }> = [];
|
|
382
|
+
if (s < 1) out.push({ index: sorted[k].index, share: r6(1 - s) });
|
|
383
|
+
if (s > 0) out.push({ index: sorted[next].index, share: s });
|
|
384
|
+
return out;
|
|
385
|
+
};
|
|
386
|
+
const share = (x: number, y: number, base: number) => {
|
|
387
|
+
const w = r6(base * sideOf(x, y));
|
|
388
|
+
points.push([r6(x), r6(y)]);
|
|
389
|
+
// A zero weight is not a weight: the validator rejects it (A20), and the
|
|
390
|
+
// loader would happily read it as a bone that owns nothing.
|
|
391
|
+
if (w <= 0) {
|
|
392
|
+
weights.push([{ bone: 'anchor', weight: 1 }]);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const parts = splitByAngle(x, y);
|
|
396
|
+
const vertex: MeshVertexWeight[] = [];
|
|
397
|
+
if (w < 1) vertex.push({ bone: 'anchor', weight: r6(1 - w) });
|
|
398
|
+
for (const part of parts) {
|
|
399
|
+
const weight = r6(w * part.share);
|
|
400
|
+
if (weight > 0) vertex.push({ bone: 'control', control: part.index, weight });
|
|
401
|
+
}
|
|
402
|
+
weights.push(vertex);
|
|
403
|
+
};
|
|
404
|
+
for (const [x, y] of hull) {
|
|
405
|
+
share(cx + (x - cx) * inner, cy + (y - cy) * inner, wInner);
|
|
406
|
+
}
|
|
407
|
+
// hub — at the centre of the aperture, so the bias ramp decides its share too.
|
|
408
|
+
share(cx, cy, 1);
|
|
409
|
+
|
|
410
|
+
const uvs: number[] = [];
|
|
411
|
+
for (const [x, y] of points) uvs.push(r6(x / w), r6(y / h));
|
|
412
|
+
|
|
413
|
+
// Each triple below runs along the hull's own list order, so it carries the
|
|
414
|
+
// hull's winding; `windCounterClockwiseInSpineWorld` then turns the list to
|
|
415
|
+
// the winding every generator here emits (issue #1236).
|
|
416
|
+
const along: number[] = [];
|
|
417
|
+
const hub = 3 * n;
|
|
418
|
+
const strip = (outerBase: number, innerBase: number) => {
|
|
419
|
+
for (let i = 0; i < n; i++) {
|
|
420
|
+
const j = (i + 1) % n;
|
|
421
|
+
along.push(outerBase + i, outerBase + j, innerBase + j);
|
|
422
|
+
along.push(outerBase + i, innerBase + j, innerBase + i);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
strip(0, n); // window edge -> seam
|
|
426
|
+
strip(n, 2 * n); // seam -> aperture ring
|
|
427
|
+
for (let i = 0; i < n; i++) {
|
|
428
|
+
const j = (i + 1) % n;
|
|
429
|
+
along.push(2 * n + i, 2 * n + j, hub);
|
|
430
|
+
}
|
|
431
|
+
const triangles = windCounterClockwiseInSpineWorld(hull, along);
|
|
432
|
+
|
|
433
|
+
return { kind: 'ring', points, uvs, triangles, weights, hullVertices: n };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The angles `buildRingMesh` splits a ring's authority by, measured from where
|
|
438
|
+
* the rig actually put each control bone.
|
|
439
|
+
*
|
|
440
|
+
* ⚠️ It is a function because it was a copy, and only one of the two callers had
|
|
441
|
+
* it (issue #684). The manifest route measured these angles; the rig-spec route
|
|
442
|
+
* passed none, so `controlAngles ?? [0]` collapsed the split and a rig naming two
|
|
443
|
+
* grips got the single-bone geometry — the second bone on the `MESH` line, bound
|
|
444
|
+
* by no vertex, and the whole gate green. What the two routes still do for
|
|
445
|
+
* themselves is turn a bone's WORLD position into the part-local pixels this ring
|
|
446
|
+
* is built in, because that is the part that genuinely differs: a manifest has a
|
|
447
|
+
* crop to flip against, and a rig spec centres the window on its own slot bone.
|
|
448
|
+
* Everything from the subtraction on is here.
|
|
449
|
+
*
|
|
450
|
+
* `positionOf` is a callback rather than an array so that the single-control rule
|
|
451
|
+
* below is the only thing that decides whether a position is needed at all.
|
|
452
|
+
*
|
|
453
|
+
* One control needs no angle: it owns the whole ring, and a face rig deliberately
|
|
454
|
+
* puts it ON the aperture centre, where a radial direction does not exist. That
|
|
455
|
+
* is `undefined` rather than `[0]`, so the single-bone path stays byte for byte
|
|
456
|
+
* what it was — and it is why the refusal below cannot fire on a ring with one
|
|
457
|
+
* control, whose bone is *expected* to sit there.
|
|
458
|
+
*/
|
|
459
|
+
export function ringControlAngles(
|
|
460
|
+
controls: readonly string[],
|
|
461
|
+
center: readonly [number, number],
|
|
462
|
+
positionOf: (name: string) => readonly [number, number],
|
|
463
|
+
): number[] | undefined {
|
|
464
|
+
if (controls.length <= 1) return undefined;
|
|
465
|
+
return controls.map((name) => {
|
|
466
|
+
const [x, y] = positionOf(name);
|
|
467
|
+
const dx = x - center[0];
|
|
468
|
+
const dy = y - center[1];
|
|
469
|
+
if (Math.hypot(dx, dy) < 1e-6) {
|
|
470
|
+
throw new MeshError(`control bone "${name}" sits on the aperture centre, so it has no radial direction`);
|
|
471
|
+
}
|
|
472
|
+
return (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Build a ribbon strip.
|
|
478
|
+
*
|
|
479
|
+
* Vertices run in PERIMETER order — left side entry-to-tip, then right side
|
|
480
|
+
* tip-to-entry — so the emitted `hull` is the real outline rather than a
|
|
481
|
+
* convenient prefix. That matters because `hull` is data other tools read, and a
|
|
482
|
+
* strip's outline genuinely is all of its vertices.
|
|
483
|
+
*
|
|
484
|
+
* Weights: knot 0 is the anchor bone at the entry point, knots 1..C are the chain
|
|
485
|
+
* bones, spaced evenly along the strip. A row between two knots blends linearly
|
|
486
|
+
* between them, and BOTH vertices of the row get the same blend. The entry row is
|
|
487
|
+
* pinned to the anchor at weight 1, which is what keeps the drip's origin at the
|
|
488
|
+
* entry point while the rest of it falls — assertion A21's ribbon branch.
|
|
489
|
+
*/
|
|
490
|
+
export function buildRibbonMesh(input: RibbonSpecInput): MeshGeometry {
|
|
491
|
+
const { rows, chainCount } = input;
|
|
492
|
+
const [w, h] = input.size;
|
|
493
|
+
if (!(w > 0 && h > 0)) throw new MeshError(`bad part size ${w}x${h}`);
|
|
494
|
+
if (!Number.isInteger(rows) || rows < 3) throw new MeshError(`ribbon needs at least 3 rows, got ${rows}`);
|
|
495
|
+
if (!Number.isInteger(chainCount) || chainCount < 1) {
|
|
496
|
+
throw new MeshError(`ribbon needs at least one chain bone, got ${chainCount}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const points: Array<[number, number]> = [];
|
|
500
|
+
const weights: MeshVertexWeight[][] = [];
|
|
501
|
+
const rowWeights: MeshVertexWeight[][] = [];
|
|
502
|
+
for (let i = 0; i < rows; i++) {
|
|
503
|
+
// s runs 0 at the entry to 1 at the tip; knots sit at k / chainCount.
|
|
504
|
+
const s = (i / (rows - 1)) * chainCount;
|
|
505
|
+
const lo = Math.min(Math.floor(s), chainCount - 1);
|
|
506
|
+
const t = r6(s - lo);
|
|
507
|
+
const vertex: MeshVertexWeight[] = [];
|
|
508
|
+
// knot index 0 is the anchor bone; 1..chainCount are chain[0..chainCount-1].
|
|
509
|
+
const push = (knot: number, weight: number) => {
|
|
510
|
+
if (weight <= 0) return;
|
|
511
|
+
if (knot === 0) vertex.push({ bone: 'anchor', weight: r6(weight) });
|
|
512
|
+
else vertex.push({ bone: 'control', control: knot - 1, weight: r6(weight) });
|
|
513
|
+
};
|
|
514
|
+
push(lo, 1 - t);
|
|
515
|
+
push(lo + 1, t);
|
|
516
|
+
rowWeights.push(vertex);
|
|
517
|
+
}
|
|
518
|
+
const rowY = (i: number) => r6((i / (rows - 1)) * h);
|
|
519
|
+
// left side, entry -> tip
|
|
520
|
+
for (let i = 0; i < rows; i++) {
|
|
521
|
+
points.push([0, rowY(i)]);
|
|
522
|
+
weights.push(rowWeights[i]);
|
|
523
|
+
}
|
|
524
|
+
// right side, tip -> entry
|
|
525
|
+
for (let i = rows - 1; i >= 0; i--) {
|
|
526
|
+
points.push([r6(w), rowY(i)]);
|
|
527
|
+
weights.push(rowWeights[i]);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const uvs: number[] = [];
|
|
531
|
+
for (const [x, y] of points) uvs.push(r6(x / w), r6(y / h));
|
|
532
|
+
|
|
533
|
+
// Quad strip. Left row i is index i; right row i is index (2*rows - 1 - i).
|
|
534
|
+
const triangles: number[] = [];
|
|
535
|
+
const L = (i: number) => i;
|
|
536
|
+
const R = (i: number) => 2 * rows - 1 - i;
|
|
537
|
+
for (let i = 0; i < rows - 1; i++) {
|
|
538
|
+
triangles.push(L(i), L(i + 1), R(i + 1));
|
|
539
|
+
triangles.push(L(i), R(i + 1), R(i));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { kind: 'ribbon', points, uvs, triangles, weights, hullVertices: 2 * rows };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// contour — a mesh cut to the part's own alpha silhouette
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
//
|
|
549
|
+
// The ring and the ribbon build a shape the DEFORMATION wants. This one builds
|
|
550
|
+
// the shape the ART already is: trace the alpha mask, simplify the outline,
|
|
551
|
+
// push it out by a margin, triangulate. What it is for is the case those two
|
|
552
|
+
// cannot express — a part whose outline is the interesting thing, where a
|
|
553
|
+
// rectangle of region is either too much geometry (a whole quad of transparent
|
|
554
|
+
// pixels to blend) or too little (no vertices to move where the silhouette is).
|
|
555
|
+
//
|
|
556
|
+
// 🚨 **It is geometry, not a deformation model.** Every vertex is pinned to the
|
|
557
|
+
// slot bone at weight 1, so a contour mesh at rest draws what the region drew
|
|
558
|
+
// and a bone cannot bend it. What it gains over a region is a real outline and
|
|
559
|
+
// real triangles: a `deform` timeline has somewhere to push, `hull` states the
|
|
560
|
+
// silhouette other tools can read, and the shape is measured rather than
|
|
561
|
+
// declared. Bone-driven interior motion is what `ring` is for, and authored
|
|
562
|
+
// `weights` is what an editor's own auto-weighting arrives as.
|
|
563
|
+
//
|
|
564
|
+
// Every claim this builder makes about its own output it MEASURES:
|
|
565
|
+
// `measureContourFit` rasterises the emitted triangles against the very mask
|
|
566
|
+
// they were traced from and the build is refused unless the triangles cover
|
|
567
|
+
// `CONTOUR_MIN_COVERAGE` of the art and stay within the margin the author asked
|
|
568
|
+
// for. A mesh that clips the art is the one failure mode that cannot be seen in
|
|
569
|
+
// the numbers of a skeleton file, so it is not left to a reader to notice.
|
|
570
|
+
|
|
571
|
+
/** One part's alpha channel, as the tracer wants it. */
|
|
572
|
+
/**
|
|
573
|
+
* Build the lattice: perimeter first in walk order, then the interior.
|
|
574
|
+
*
|
|
575
|
+
* The numbering is not a preference. Spine's `hull` is a COUNT — the first
|
|
576
|
+
* `hull` entries of the vertex array are the outline — so the perimeter has to
|
|
577
|
+
* be listed first and in the order it is walked, or the runtime reads an
|
|
578
|
+
* interior vertex as a boundary one. `checkHullOrder` says the same thing from
|
|
579
|
+
* the other side, and this builder is written to satisfy it by construction
|
|
580
|
+
* rather than to be checked against it afterwards.
|
|
581
|
+
*
|
|
582
|
+
* Winding is read off the worked example rather than derived here: its first
|
|
583
|
+
* two triangles are `[0, 15, 16]` and `[0, 16, 1]`, which is
|
|
584
|
+
* `[top-left, bottom-left, bottom-right]` and `[top-left, bottom-right,
|
|
585
|
+
* top-right]` per cell — counter-clockwise in Spine world once y is flipped up.
|
|
586
|
+
*/
|
|
587
|
+
export function buildGridMesh(input: GridSpecInput): MeshGeometry {
|
|
588
|
+
const { size, us, vs } = input;
|
|
589
|
+
const [w, h] = size;
|
|
590
|
+
const axis = (values: number[], name: string): void => {
|
|
591
|
+
if (!Array.isArray(values) || values.length < 2) {
|
|
592
|
+
throw new MeshError(`"${name}" has ${Array.isArray(values) ? values.length : 0} positions; a grid needs at least 2 on each axis`);
|
|
593
|
+
}
|
|
594
|
+
values.forEach((t, i) => {
|
|
595
|
+
if (typeof t !== 'number' || !Number.isFinite(t)) throw new MeshError(`"${name}"[${i}] is ${JSON.stringify(t)}; positions are finite numbers`);
|
|
596
|
+
if (t < 0 || t > 1) throw new MeshError(`"${name}"[${i}] is ${t}; positions are fractions of the part window, 0..1`);
|
|
597
|
+
// Equal neighbours would put two vertices in one place and collapse a
|
|
598
|
+
// whole row or column of triangles to zero area — a mesh that loads,
|
|
599
|
+
// draws and cannot be deformed, which is the silence this refuses.
|
|
600
|
+
if (i > 0 && !(t > values[i - 1])) {
|
|
601
|
+
throw new MeshError(`"${name}" is not ascending: [${i - 1}]=${values[i - 1]} and [${i}]=${t}`);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
};
|
|
605
|
+
axis(us, 'us');
|
|
606
|
+
axis(vs, 'vs');
|
|
607
|
+
if (!(w > 0) || !(h > 0)) throw new MeshError(`the part window is ${w}x${h}; a grid needs a positive size`);
|
|
608
|
+
|
|
609
|
+
const cols = us.length;
|
|
610
|
+
const rows = vs.length;
|
|
611
|
+
// Grid coordinate -> vertex index, filled as the two passes below number them.
|
|
612
|
+
const index: number[][] = Array.from({ length: rows }, () => new Array<number>(cols).fill(-1));
|
|
613
|
+
const points: Array<[number, number]> = [];
|
|
614
|
+
const place = (i: number, j: number): void => {
|
|
615
|
+
index[j][i] = points.length;
|
|
616
|
+
points.push([r6(us[i] * w), r6(vs[j] * h)]);
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
// 1. the perimeter, clockwise in part-local space (y down).
|
|
620
|
+
for (let i = 0; i < cols; i++) place(i, 0);
|
|
621
|
+
for (let j = 1; j < rows; j++) place(cols - 1, j);
|
|
622
|
+
for (let i = cols - 2; i >= 0; i--) place(i, rows - 1);
|
|
623
|
+
for (let j = rows - 2; j >= 1; j--) place(0, j);
|
|
624
|
+
const hullVertices = points.length;
|
|
625
|
+
// 2. the interior, row major.
|
|
626
|
+
for (let j = 1; j < rows - 1; j++) for (let i = 1; i < cols - 1; i++) place(i, j);
|
|
627
|
+
|
|
628
|
+
const triangles: number[] = [];
|
|
629
|
+
for (let j = 0; j < rows - 1; j++) {
|
|
630
|
+
for (let i = 0; i < cols - 1; i++) {
|
|
631
|
+
const tl = index[j][i];
|
|
632
|
+
const tr = index[j][i + 1];
|
|
633
|
+
const bl = index[j + 1][i];
|
|
634
|
+
const br = index[j + 1][i + 1];
|
|
635
|
+
triangles.push(tl, bl, br, tl, br, tr);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const uvs: number[] = [];
|
|
640
|
+
for (const [x, y] of points) uvs.push(r6(x / w), r6(y / h));
|
|
641
|
+
// Every vertex on the slot bone at weight 1, the same weighting model a
|
|
642
|
+
// `contour` has: the lattice is geometry to deform, not an authority split.
|
|
643
|
+
const weights: MeshVertexWeight[][] = points.map(() => [{ bone: 'anchor', weight: 1 }]);
|
|
644
|
+
|
|
645
|
+
return { kind: 'grid', points, uvs, triangles, weights, hullVertices };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export interface AlphaMask {
|
|
649
|
+
width: number;
|
|
650
|
+
height: number;
|
|
651
|
+
/** One byte per pixel, row major, y down. `width * height` long. */
|
|
652
|
+
alpha: Uint8Array;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export interface ContourSpecInput {
|
|
656
|
+
mask: AlphaMask;
|
|
657
|
+
/** Alpha at or above this counts as art. 1 means "any pixel that is not fully transparent". */
|
|
658
|
+
threshold: number;
|
|
659
|
+
/** Douglas-Peucker tolerance, in the drawing's pixels. */
|
|
660
|
+
tolerance: number;
|
|
661
|
+
/** How far the outline is pushed out past the traced silhouette, in the drawing's pixels. */
|
|
662
|
+
margin: number;
|
|
663
|
+
/** Refuse rather than emit more outline vertices than this. */
|
|
664
|
+
maxVertices: number;
|
|
665
|
+
/**
|
|
666
|
+
* The `scale:` the page the mask was lifted off STATES, when that is not 1 —
|
|
667
|
+
* texels per pixel of the drawing. Absent, the mask is the drawing and
|
|
668
|
+
* `tolerance` and `margin` are applied as they are.
|
|
669
|
+
*
|
|
670
|
+
* 📐 Present, the mask is the page's texels, and the two distances are
|
|
671
|
+
* applied as `value × pageScale` texels (issue #779): they are the author's
|
|
672
|
+
* statement, written in the unit every other size in the spec is in, and
|
|
673
|
+
* applying them on a finer or coarser grid unconverted asked a different
|
|
674
|
+
* question of each page — one spec traced 15, 11 and 66 vertices on the
|
|
675
|
+
* declared-size, `scale: 0.5` and `scale: 2` restatements of one drawing.
|
|
676
|
+
* Never a measured ratio: the page's header is what says how big a texel is.
|
|
677
|
+
*/
|
|
678
|
+
pageScale?: number;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** What the contour builder measured while building — reported, not asserted in prose. */
|
|
682
|
+
export interface ContourReport {
|
|
683
|
+
/** Pixels at or above the threshold. */
|
|
684
|
+
artPixels: number;
|
|
685
|
+
/** Pixels in the largest 4-connected island of art. */
|
|
686
|
+
islandPixels: number;
|
|
687
|
+
/** How many 4-connected islands of art the mask holds. */
|
|
688
|
+
islands: number;
|
|
689
|
+
/** Transparent pixels the traced outline encloses — inside the mesh, drawing nothing. */
|
|
690
|
+
holePixels: number;
|
|
691
|
+
/** Corner-lattice vertices the trace produced, before simplification. */
|
|
692
|
+
tracedVertices: number;
|
|
693
|
+
/** Fraction of art pixels the emitted triangles cover, 0..1. */
|
|
694
|
+
coverage: number;
|
|
695
|
+
/** Furthest a covered pixel sits outside the filled silhouette, in pixels. */
|
|
696
|
+
overshoot: number;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* The share of the art the triangles must cover, or the build is refused.
|
|
701
|
+
*
|
|
702
|
+
* Not 1. The outline is simplified, and a simplification that could never cut a
|
|
703
|
+
* corner is not a simplification — so the guarantee is a stated fraction and the
|
|
704
|
+
* measured number goes in the report, rather than an exactness nobody can hold.
|
|
705
|
+
*/
|
|
706
|
+
export const CONTOUR_MIN_COVERAGE = 0.995;
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* How far a mitred corner may travel, as a multiple of `margin`.
|
|
710
|
+
*
|
|
711
|
+
* A sharp spike's angle bisector runs away to infinity — `margin / sin(θ/2)` —
|
|
712
|
+
* and an unlimited miter turns a 5-degree point into a vertex hundreds of pixels
|
|
713
|
+
* off the art. Clamping the travel gives that corner a slightly cut tip instead,
|
|
714
|
+
* which the coverage measurement then either accepts or refuses.
|
|
715
|
+
*/
|
|
716
|
+
export const CONTOUR_MITER_LIMIT = 4;
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The furthest a contour mesh CAN reach past the filled silhouette, in pixels,
|
|
720
|
+
* derived rather than chosen.
|
|
721
|
+
*
|
|
722
|
+
* Three terms, and each one is a step of the build: the offset moves a vertex at
|
|
723
|
+
* most `margin * CONTOUR_MITER_LIMIT` (the miter clamp is what makes that a
|
|
724
|
+
* bound at all), simplification can bow an edge `tolerance` outward, and a pixel
|
|
725
|
+
* whose CENTRE lands inside a triangle can sit up to one pixel from the shape's
|
|
726
|
+
* true edge. So a build that exceeds this is not an author's margin being
|
|
727
|
+
* generous — it is this file's arithmetic being wrong, and `buildContourMesh`
|
|
728
|
+
* refuses it in those words.
|
|
729
|
+
*
|
|
730
|
+
* ⭐ It is a ceiling, not a forecast. The number a given part actually measures
|
|
731
|
+
* is in its `ContourReport.overshoot`, which is where to read "how closely does
|
|
732
|
+
* this mesh hug this art": the selftest's blob measures 3.16px against a ceiling
|
|
733
|
+
* of 10.50px at the same settings.
|
|
734
|
+
*/
|
|
735
|
+
export function contourOvershootBound(margin: number, tolerance: number): number {
|
|
736
|
+
return margin * CONTOUR_MITER_LIMIT + tolerance + 1;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Cracks run clockwise on screen (y down): +x, +y, -x, -y. */
|
|
740
|
+
const CRACK_DIRS: ReadonlyArray<readonly [number, number]> = [
|
|
741
|
+
[1, 0],
|
|
742
|
+
[0, 1],
|
|
743
|
+
[-1, 0],
|
|
744
|
+
[0, -1],
|
|
745
|
+
];
|
|
746
|
+
|
|
747
|
+
/** Perpendicular distance from `p` to the segment `a`-`b`, clamped to its ends. */
|
|
748
|
+
export function distanceToSegment(
|
|
749
|
+
p: readonly [number, number],
|
|
750
|
+
a: readonly [number, number],
|
|
751
|
+
b: readonly [number, number],
|
|
752
|
+
): number {
|
|
753
|
+
const dx = b[0] - a[0];
|
|
754
|
+
const dy = b[1] - a[1];
|
|
755
|
+
const len = dx * dx + dy * dy;
|
|
756
|
+
if (len === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
|
|
757
|
+
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len;
|
|
758
|
+
t = Math.max(0, Math.min(1, t));
|
|
759
|
+
return Math.hypot(p[0] - (a[0] + dx * t), p[1] - (a[1] + dy * t));
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Douglas-Peucker over one open polyline, returning the indices it keeps.
|
|
764
|
+
*
|
|
765
|
+
* Iterative rather than recursive: a 4000-vertex staircase (a 1000x1000 part
|
|
766
|
+
* traced on the corner lattice) recurses deeper than is comfortable, and the
|
|
767
|
+
* stack version is the same algorithm with the frames written down.
|
|
768
|
+
*/
|
|
769
|
+
function simplifyRun(points: Array<[number, number]>, from: number, to: number, tolerance: number): number[] {
|
|
770
|
+
const keep = new Uint8Array(to - from + 1);
|
|
771
|
+
keep[0] = 1;
|
|
772
|
+
keep[to - from] = 1;
|
|
773
|
+
const stack: Array<[number, number]> = [[from, to]];
|
|
774
|
+
while (stack.length) {
|
|
775
|
+
const [lo, hi] = stack.pop()!;
|
|
776
|
+
if (hi <= lo + 1) continue;
|
|
777
|
+
let worst = -1;
|
|
778
|
+
let worstAt = -1;
|
|
779
|
+
for (let i = lo + 1; i < hi; i++) {
|
|
780
|
+
const d = distanceToSegment(points[i], points[lo], points[hi]);
|
|
781
|
+
if (d > worst) {
|
|
782
|
+
worst = d;
|
|
783
|
+
worstAt = i;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (worst <= tolerance || worstAt < 0) continue;
|
|
787
|
+
keep[worstAt - from] = 1;
|
|
788
|
+
stack.push([lo, worstAt], [worstAt, hi]);
|
|
789
|
+
}
|
|
790
|
+
const out: number[] = [];
|
|
791
|
+
for (let i = 0; i < keep.length; i++) if (keep[i]) out.push(from + i);
|
|
792
|
+
return out;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Douglas-Peucker over a CLOSED ring.
|
|
797
|
+
*
|
|
798
|
+
* The algorithm is defined for a polyline with two fixed ends, and a ring has
|
|
799
|
+
* none — so the ring is cut at its first vertex and at the vertex furthest from
|
|
800
|
+
* it, and the two arcs are simplified independently. Cutting at the diameter
|
|
801
|
+
* rather than at an arbitrary second point is what keeps the two arcs from being
|
|
802
|
+
* a long one and a stub, whose simplification would then be lopsided.
|
|
803
|
+
*/
|
|
804
|
+
export function simplifyClosedPolygon(poly: Array<[number, number]>, tolerance: number): Array<[number, number]> {
|
|
805
|
+
const n = poly.length;
|
|
806
|
+
if (n < 4 || !(tolerance > 0)) return poly.slice();
|
|
807
|
+
let far = 1;
|
|
808
|
+
let farD = -1;
|
|
809
|
+
for (let i = 1; i < n; i++) {
|
|
810
|
+
const d = Math.hypot(poly[i][0] - poly[0][0], poly[i][1] - poly[0][1]);
|
|
811
|
+
if (d > farD) {
|
|
812
|
+
farD = d;
|
|
813
|
+
far = i;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const rotated = [...poly.slice(0), poly[0]]; // one open run 0..far, one far..n
|
|
817
|
+
const first = simplifyRun(rotated, 0, far, tolerance);
|
|
818
|
+
const second = simplifyRun(rotated, far, n, tolerance);
|
|
819
|
+
const out: Array<[number, number]> = [];
|
|
820
|
+
for (const i of first) out.push(poly[i]);
|
|
821
|
+
for (const i of second.slice(1, second.length - 1)) out.push(poly[i % n]);
|
|
822
|
+
return out;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Push a simple polygon out along its own vertex bisectors.
|
|
827
|
+
*
|
|
828
|
+
* ⭐ The traced outline runs on the corner lattice, so it already encloses every
|
|
829
|
+
* art pixel WHOLE and a margin of 0 clips nothing. What the margin is actually
|
|
830
|
+
* for is the simplification: Douglas-Peucker moves a vertex up to `tolerance` in
|
|
831
|
+
* either direction, and the inward half of that is a bite out of the art. So the
|
|
832
|
+
* useful setting is `margin >= tolerance`, and the coverage measurement is what
|
|
833
|
+
* says whether a given pair got there.
|
|
834
|
+
*
|
|
835
|
+
* `poly` must be clockwise on screen (positive `signedArea` in y-down pixels),
|
|
836
|
+
* which is what the tracer produces; outward is then to the left of travel.
|
|
837
|
+
*/
|
|
838
|
+
export function offsetPolygon(poly: Array<[number, number]>, margin: number): Array<[number, number]> {
|
|
839
|
+
const n = poly.length;
|
|
840
|
+
if (margin === 0) return poly.map(([x, y]) => [x, y] as [number, number]);
|
|
841
|
+
const out: Array<[number, number]> = [];
|
|
842
|
+
for (let i = 0; i < n; i++) {
|
|
843
|
+
const prev = poly[(i + n - 1) % n];
|
|
844
|
+
const here = poly[i];
|
|
845
|
+
const next = poly[(i + 1) % n];
|
|
846
|
+
const e1 = unit(here[0] - prev[0], here[1] - prev[1]);
|
|
847
|
+
const e2 = unit(next[0] - here[0], next[1] - here[1]);
|
|
848
|
+
// Outward normal of an edge running (dx, dy) on a clockwise screen polygon.
|
|
849
|
+
const n1: [number, number] = [e1[1], -e1[0]];
|
|
850
|
+
const n2: [number, number] = [e2[1], -e2[0]];
|
|
851
|
+
let bx = n1[0] + n2[0];
|
|
852
|
+
let by = n1[1] + n2[1];
|
|
853
|
+
let len = Math.hypot(bx, by);
|
|
854
|
+
if (len < 1e-9) {
|
|
855
|
+
// A 180-degree turn: the two edges double back, so there is no bisector.
|
|
856
|
+
// The incoming edge's own normal is the only direction that is outward for
|
|
857
|
+
// both, and a spike this thin is a candidate for the coverage refusal.
|
|
858
|
+
bx = n1[0];
|
|
859
|
+
by = n1[1];
|
|
860
|
+
len = 1;
|
|
861
|
+
}
|
|
862
|
+
bx /= len;
|
|
863
|
+
by /= len;
|
|
864
|
+
const project = Math.max(bx * n1[0] + by * n1[1], 1 / CONTOUR_MITER_LIMIT);
|
|
865
|
+
out.push([here[0] + (bx * margin) / project, here[1] + (by * margin) / project]);
|
|
866
|
+
}
|
|
867
|
+
return out;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function unit(dx: number, dy: number): [number, number] {
|
|
871
|
+
const len = Math.hypot(dx, dy);
|
|
872
|
+
return len < 1e-12 ? [0, 0] : [dx / len, dy / len];
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Drop repeated and exactly collinear vertices, so no ear can have zero area. */
|
|
876
|
+
export function prunePolygon(poly: Array<[number, number]>): Array<[number, number]> {
|
|
877
|
+
const dedup: Array<[number, number]> = [];
|
|
878
|
+
for (const p of poly) {
|
|
879
|
+
const last = dedup[dedup.length - 1];
|
|
880
|
+
if (last && Math.abs(last[0] - p[0]) < 1e-9 && Math.abs(last[1] - p[1]) < 1e-9) continue;
|
|
881
|
+
dedup.push([p[0], p[1]]);
|
|
882
|
+
}
|
|
883
|
+
while (dedup.length > 1) {
|
|
884
|
+
const a = dedup[0];
|
|
885
|
+
const b = dedup[dedup.length - 1];
|
|
886
|
+
if (Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9) dedup.pop();
|
|
887
|
+
else break;
|
|
888
|
+
}
|
|
889
|
+
// Collinear runs, repeatedly: removing one vertex can make its neighbours
|
|
890
|
+
// collinear in turn, and a single pass would leave those behind.
|
|
891
|
+
let changed = true;
|
|
892
|
+
while (changed && dedup.length > 3) {
|
|
893
|
+
changed = false;
|
|
894
|
+
for (let i = 0; i < dedup.length; i++) {
|
|
895
|
+
const p = dedup[(i + dedup.length - 1) % dedup.length];
|
|
896
|
+
const h = dedup[i];
|
|
897
|
+
const q = dedup[(i + 1) % dedup.length];
|
|
898
|
+
const cross = (h[0] - p[0]) * (q[1] - h[1]) - (h[1] - p[1]) * (q[0] - h[0]);
|
|
899
|
+
if (Math.abs(cross) < 1e-9) {
|
|
900
|
+
dedup.splice(i, 1);
|
|
901
|
+
changed = true;
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return dedup;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Do two segments share a point? Touching counts — an ear needs strict simplicity. */
|
|
910
|
+
export function segmentsMeet(
|
|
911
|
+
a: readonly [number, number],
|
|
912
|
+
b: readonly [number, number],
|
|
913
|
+
c: readonly [number, number],
|
|
914
|
+
d: readonly [number, number],
|
|
915
|
+
): boolean {
|
|
916
|
+
const o = (p: readonly [number, number], q: readonly [number, number], r: readonly [number, number]): number => {
|
|
917
|
+
const v = (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
|
|
918
|
+
return Math.abs(v) < 1e-9 ? 0 : Math.sign(v);
|
|
919
|
+
};
|
|
920
|
+
const onSegment = (p: readonly [number, number], q: readonly [number, number], r: readonly [number, number]): boolean =>
|
|
921
|
+
o(p, q, r) === 0 &&
|
|
922
|
+
Math.min(p[0], q[0]) - 1e-9 <= r[0] &&
|
|
923
|
+
r[0] <= Math.max(p[0], q[0]) + 1e-9 &&
|
|
924
|
+
Math.min(p[1], q[1]) - 1e-9 <= r[1] &&
|
|
925
|
+
r[1] <= Math.max(p[1], q[1]) + 1e-9;
|
|
926
|
+
const o1 = o(a, b, c);
|
|
927
|
+
const o2 = o(a, b, d);
|
|
928
|
+
const o3 = o(c, d, a);
|
|
929
|
+
const o4 = o(c, d, b);
|
|
930
|
+
if (o1 !== o2 && o3 !== o4) return true;
|
|
931
|
+
return onSegment(a, b, c) || onSegment(a, b, d) || onSegment(c, d, a) || onSegment(c, d, b);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The first pair of non-adjacent edges that meet, or null when the polygon is
|
|
936
|
+
* strictly simple.
|
|
937
|
+
*
|
|
938
|
+
* "Meet" includes touching at a point, and that strictness is load-bearing: the
|
|
939
|
+
* two-ears theorem holds for a strictly simple polygon, so an ear-clipper that
|
|
940
|
+
* runs on one cannot stall. Accepting a polygon that touches itself would trade a
|
|
941
|
+
* named refusal for a build that either stalls or emits a fan of slivers.
|
|
942
|
+
*/
|
|
943
|
+
export function findSelfIntersection(poly: Array<[number, number]>): [number, number] | null {
|
|
944
|
+
const n = poly.length;
|
|
945
|
+
for (let i = 0; i < n; i++) {
|
|
946
|
+
const a = poly[i];
|
|
947
|
+
const b = poly[(i + 1) % n];
|
|
948
|
+
for (let j = i + 1; j < n; j++) {
|
|
949
|
+
if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue;
|
|
950
|
+
if (segmentsMeet(a, b, poly[j], poly[(j + 1) % n])) return [i, j];
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Is `p` inside (or on) the triangle `a,b,c` wound in `orient`? */
|
|
957
|
+
function pointInTriangle(
|
|
958
|
+
p: readonly [number, number],
|
|
959
|
+
a: readonly [number, number],
|
|
960
|
+
b: readonly [number, number],
|
|
961
|
+
c: readonly [number, number],
|
|
962
|
+
orient: number,
|
|
963
|
+
): boolean {
|
|
964
|
+
const side = (u: readonly [number, number], v: readonly [number, number]): number =>
|
|
965
|
+
((v[0] - u[0]) * (p[1] - u[1]) - (v[1] - u[1]) * (p[0] - u[0])) * orient;
|
|
966
|
+
return side(a, b) >= -1e-9 && side(b, c) >= -1e-9 && side(c, a) >= -1e-9;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Ear clipping over one strictly simple polygon, concave welcome.
|
|
971
|
+
*
|
|
972
|
+
* ## What it does
|
|
973
|
+
*
|
|
974
|
+
* Repeatedly find a vertex whose two edges make a convex turn in the polygon's
|
|
975
|
+
* own winding and whose triangle holds no other vertex, emit it as a triangle,
|
|
976
|
+
* and remove it. The emitted triples carry the polygon's own winding — the
|
|
977
|
+
* order its list runs in — and nothing else decides it: clipping the same
|
|
978
|
+
* outline mirrored picks the same ears in the same order and writes the same
|
|
979
|
+
* triples. ⚠️ A y flip does NOT turn that winding over. Flipping y changes the
|
|
980
|
+
* sign of a signed area, but the y-up frame is drawn with y up, so the loop
|
|
981
|
+
* turns the same way on screen as it does in Spine world: an outline that is
|
|
982
|
+
* clockwise on screen (positive `signedArea` in y-down pixels) yields
|
|
983
|
+
* triangles that are clockwise in Spine world. A caller that emits them turns
|
|
984
|
+
* them with `windCounterClockwiseInSpineWorld` (issue #1236: this paragraph
|
|
985
|
+
* said the opposite, and `contour` emitted clockwise for that reason).
|
|
986
|
+
*
|
|
987
|
+
* ## What it refuses, and why by name
|
|
988
|
+
*
|
|
989
|
+
* - **Holes.** There is no bridging step here, so a polygon is an outline and
|
|
990
|
+
* nothing else. `traceAlphaOutline` therefore hands over the OUTER boundary and
|
|
991
|
+
* reports the enclosed transparent area rather than cutting it out; those
|
|
992
|
+
* pixels are inside the mesh and draw nothing, because their alpha is still 0.
|
|
993
|
+
* - **Self-intersection**, including a polygon that merely touches itself:
|
|
994
|
+
* refused upstream by `findSelfIntersection`, because the two-ears theorem — the
|
|
995
|
+
* guarantee that this loop terminates — is a statement about a strictly simple
|
|
996
|
+
* polygon.
|
|
997
|
+
* - **No ear found** while three or more vertices remain. On a strictly simple
|
|
998
|
+
* polygon that cannot happen, so reaching it means an input this function was
|
|
999
|
+
* promised it would not get, and it says so instead of emitting a fan of
|
|
1000
|
+
* slivers that would load and render as folded art.
|
|
1001
|
+
*
|
|
1002
|
+
* O(n²) per ear, O(n³) overall, and deliberately so: `maxVertices` bounds n at
|
|
1003
|
+
* the tens, an outline that needs thousands of vertices is not a mesh anybody
|
|
1004
|
+
* wants in a runtime's inner loop, and a sweep-line would be a second geometry
|
|
1005
|
+
* kernel to be wrong in.
|
|
1006
|
+
*/
|
|
1007
|
+
export function earClip(poly: Array<[number, number]>): number[] {
|
|
1008
|
+
const n = poly.length;
|
|
1009
|
+
if (n < 3) throw new MeshError(`a polygon needs at least 3 vertices to triangulate, got ${n}`);
|
|
1010
|
+
const area = signedArea(poly);
|
|
1011
|
+
if (Math.abs(area) < 1e-9) throw new MeshError('the polygon encloses no area, so it has no triangles');
|
|
1012
|
+
const orient = area > 0 ? 1 : -1;
|
|
1013
|
+
const live: number[] = [];
|
|
1014
|
+
for (let i = 0; i < n; i++) live.push(i);
|
|
1015
|
+
const triangles: number[] = [];
|
|
1016
|
+
while (live.length > 3) {
|
|
1017
|
+
let clipped = false;
|
|
1018
|
+
for (let k = 0; k < live.length; k++) {
|
|
1019
|
+
const ia = live[(k + live.length - 1) % live.length];
|
|
1020
|
+
const ib = live[k];
|
|
1021
|
+
const ic = live[(k + 1) % live.length];
|
|
1022
|
+
const a = poly[ia];
|
|
1023
|
+
const b = poly[ib];
|
|
1024
|
+
const c = poly[ic];
|
|
1025
|
+
const turn = ((b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])) * orient;
|
|
1026
|
+
if (turn <= 1e-12) continue; // reflex or straight: not an ear
|
|
1027
|
+
let blocked = false;
|
|
1028
|
+
for (const j of live) {
|
|
1029
|
+
if (j === ia || j === ib || j === ic) continue;
|
|
1030
|
+
if (pointInTriangle(poly[j], a, b, c, orient)) {
|
|
1031
|
+
blocked = true;
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (blocked) continue;
|
|
1036
|
+
triangles.push(ia, ib, ic);
|
|
1037
|
+
live.splice(k, 1);
|
|
1038
|
+
clipped = true;
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
if (!clipped) {
|
|
1042
|
+
throw new MeshError(
|
|
1043
|
+
`ear clipping stalled with ${live.length} of ${n} vertices left: every remaining corner is reflex or ` +
|
|
1044
|
+
'covers another vertex, which a strictly simple polygon cannot be — the outline is degenerate',
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
triangles.push(live[0], live[1], live[2]);
|
|
1049
|
+
return triangles;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Which pixels of a mask are art, as 1/0 bytes. */
|
|
1053
|
+
export function artOf(mask: AlphaMask, threshold: number): Uint8Array {
|
|
1054
|
+
const out = new Uint8Array(mask.width * mask.height);
|
|
1055
|
+
for (let i = 0; i < out.length; i++) out[i] = mask.alpha[i] >= threshold ? 1 : 0;
|
|
1056
|
+
return out;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** 4-connected island labels, 1-based; 0 is background. */
|
|
1060
|
+
export function labelIslands(art: Uint8Array, w: number, h: number): { label: Int32Array; sizes: number[] } {
|
|
1061
|
+
const label = new Int32Array(w * h);
|
|
1062
|
+
const sizes: number[] = [];
|
|
1063
|
+
const stack: number[] = [];
|
|
1064
|
+
for (let start = 0; start < art.length; start++) {
|
|
1065
|
+
if (!art[start] || label[start]) continue;
|
|
1066
|
+
const id = sizes.length + 1;
|
|
1067
|
+
let size = 0;
|
|
1068
|
+
label[start] = id;
|
|
1069
|
+
stack.push(start);
|
|
1070
|
+
while (stack.length) {
|
|
1071
|
+
const at = stack.pop()!;
|
|
1072
|
+
size++;
|
|
1073
|
+
const x = at % w;
|
|
1074
|
+
const y = (at - x) / w;
|
|
1075
|
+
const push = (nx: number, ny: number): void => {
|
|
1076
|
+
if (nx < 0 || ny < 0 || nx >= w || ny >= h) return;
|
|
1077
|
+
const to = ny * w + nx;
|
|
1078
|
+
if (!art[to] || label[to]) return;
|
|
1079
|
+
label[to] = id;
|
|
1080
|
+
stack.push(to);
|
|
1081
|
+
};
|
|
1082
|
+
push(x - 1, y);
|
|
1083
|
+
push(x + 1, y);
|
|
1084
|
+
push(x, y - 1);
|
|
1085
|
+
push(x, y + 1);
|
|
1086
|
+
}
|
|
1087
|
+
sizes.push(size);
|
|
1088
|
+
}
|
|
1089
|
+
return { label, sizes };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Trace the outer boundary of the largest island of art, on the pixel-CORNER
|
|
1094
|
+
* lattice.
|
|
1095
|
+
*
|
|
1096
|
+
* ## Why the corner lattice and not the pixel centres
|
|
1097
|
+
*
|
|
1098
|
+
* A contour through pixel centres runs half a pixel inside the art all the way
|
|
1099
|
+
* round, so the outermost row of every edge falls outside the mesh — a mesh that
|
|
1100
|
+
* clips the art by half a pixel everywhere, which is exactly the defect this
|
|
1101
|
+
* whole builder has to not have. On the corner lattice the traced polygon
|
|
1102
|
+
* encloses every art pixel's full square, so the raw trace clips nothing at all
|
|
1103
|
+
* and everything after it is a controlled retreat from that.
|
|
1104
|
+
*
|
|
1105
|
+
* ## The walk
|
|
1106
|
+
*
|
|
1107
|
+
* Each art pixel whose neighbour is background contributes one directed "crack"
|
|
1108
|
+
* along the shared edge, wound so that the art stays on the walker's right: top
|
|
1109
|
+
* edge to +x, right edge to +y, bottom to -x, left to -y. Those cracks form
|
|
1110
|
+
* closed loops. Starting at the first art pixel in row order — whose top
|
|
1111
|
+
* neighbour is background by construction — and always taking the sharpest
|
|
1112
|
+
* clockwise turn available walks the island's outer loop.
|
|
1113
|
+
*
|
|
1114
|
+
* ⚠️ The clockwise rule is not a preference. At a corner where two art pixels
|
|
1115
|
+
* meet diagonally, two cracks leave the same point and the choice decides whether
|
|
1116
|
+
* the walk treats the diagonal as connected. Clockwise keeps it disconnected,
|
|
1117
|
+
* which is the same 4-connectivity `labelIslands` used — the alternative would
|
|
1118
|
+
* trace a boundary for an island the labelling says is two.
|
|
1119
|
+
*/
|
|
1120
|
+
export function traceAlphaOutline(
|
|
1121
|
+
mask: AlphaMask,
|
|
1122
|
+
threshold: number,
|
|
1123
|
+
): {
|
|
1124
|
+
outline: Array<[number, number]>;
|
|
1125
|
+
artPixels: number;
|
|
1126
|
+
islandPixels: number;
|
|
1127
|
+
islands: number;
|
|
1128
|
+
holePixels: number;
|
|
1129
|
+
/** The filled silhouette: the island plus every transparent pixel it encloses. */
|
|
1130
|
+
filled: Uint8Array;
|
|
1131
|
+
} {
|
|
1132
|
+
const { width: w, height: h } = mask;
|
|
1133
|
+
if (!(w > 0 && h > 0)) throw new MeshError(`bad part size ${w}x${h}`);
|
|
1134
|
+
if (mask.alpha.length !== w * h) {
|
|
1135
|
+
throw new MeshError(`the alpha mask holds ${mask.alpha.length} bytes for a ${w}x${h} part`);
|
|
1136
|
+
}
|
|
1137
|
+
const art = artOf(mask, threshold);
|
|
1138
|
+
let artPixels = 0;
|
|
1139
|
+
for (const bit of art) artPixels += bit;
|
|
1140
|
+
if (artPixels === 0) {
|
|
1141
|
+
throw new MeshError(`no pixel of the ${w}x${h} part reaches alpha ${threshold}; there is no silhouette to trace`);
|
|
1142
|
+
}
|
|
1143
|
+
const { label, sizes } = labelIslands(art, w, h);
|
|
1144
|
+
let biggest = 1;
|
|
1145
|
+
for (let i = 0; i < sizes.length; i++) if (sizes[i] > sizes[biggest - 1]) biggest = i + 1;
|
|
1146
|
+
const inside = new Uint8Array(w * h);
|
|
1147
|
+
for (let i = 0; i < inside.length; i++) inside[i] = label[i] === biggest ? 1 : 0;
|
|
1148
|
+
|
|
1149
|
+
// The silhouette the walk traces is the island WITH ITS HOLES FILLED, and it is
|
|
1150
|
+
// built before the pinch scan because the scan has to read the same pixels the
|
|
1151
|
+
// walk does. A corner where two holes meet diagonally is inside the filled
|
|
1152
|
+
// silhouette, and the outer outline never visits it; scanning the island
|
|
1153
|
+
// before the fill refused such a part for a pinch no outline passes through
|
|
1154
|
+
// (issue #1209).
|
|
1155
|
+
//
|
|
1156
|
+
// ⚠️ A hole is background that no 8-connected path of background reaches from
|
|
1157
|
+
// the border, not merely no 4-connected one. The island is 4-connected, so two
|
|
1158
|
+
// background pixels that touch only at a corner are connected through it — the
|
|
1159
|
+
// same duality the walk's clockwise rule keeps. With a 4-connected flood a
|
|
1160
|
+
// transparent pixel that touches the outside only diagonally would read as a
|
|
1161
|
+
// hole, the fill would close the pinch at that corner, and the part would trace
|
|
1162
|
+
// a region the art does not enclose: that is filling a pinch, not a hole. On
|
|
1163
|
+
// every part this function accepts the two floods fill the same pixels — they
|
|
1164
|
+
// can only differ across a corner the scan below refuses.
|
|
1165
|
+
const { filled, holePixels } = fillEnclosed(inside, w, h, 8);
|
|
1166
|
+
const at = (x: number, y: number): number => (x < 0 || y < 0 || x >= w || y >= h ? 0 : filled[y * w + x]);
|
|
1167
|
+
/** Outgoing cracks at one lattice corner, as direction indices. */
|
|
1168
|
+
const outgoing = (cx: number, cy: number): number[] => {
|
|
1169
|
+
const dirs: number[] = [];
|
|
1170
|
+
if (at(cx, cy) && !at(cx, cy - 1)) dirs.push(0); // this pixel's top edge
|
|
1171
|
+
if (at(cx - 1, cy) && !at(cx, cy)) dirs.push(1); // left neighbour's right edge
|
|
1172
|
+
if (at(cx - 1, cy - 1) && !at(cx - 1, cy)) dirs.push(2); // upper-left's bottom edge
|
|
1173
|
+
if (at(cx, cy - 1) && !at(cx - 1, cy - 1)) dirs.push(3); // upper's left edge
|
|
1174
|
+
return dirs;
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
// A DIAGONAL PINCH is refused before the walk, not during it. At a corner where
|
|
1178
|
+
// two pixels of the filled silhouette meet diagonally with background on the
|
|
1179
|
+
// other diagonal, two cracks leave the same point — so the boundary is not a
|
|
1180
|
+
// set of simple loops any more, and whichever pairing the walk chooses it
|
|
1181
|
+
// either passes through one point twice or leaves part of the outline
|
|
1182
|
+
// untraced. Both are silent: the second one produces a perfectly valid mesh of
|
|
1183
|
+
// the wrong region. Scanning for it costs one pass and names the pixel corner.
|
|
1184
|
+
// Background here is what the fill left, so both pixels on that diagonal are
|
|
1185
|
+
// reached from outside the part: the corner is on the outer outline itself.
|
|
1186
|
+
// The remedy names a direction for each outcome because a threshold moves art
|
|
1187
|
+
// one way only: raising it removes pixels, which can open a pinch and never
|
|
1188
|
+
// close one, and lowering it adds them.
|
|
1189
|
+
for (let cy = 0; cy <= h; cy++) {
|
|
1190
|
+
for (let cx = 0; cx <= w; cx++) {
|
|
1191
|
+
const tl = at(cx - 1, cy - 1);
|
|
1192
|
+
const tr = at(cx, cy - 1);
|
|
1193
|
+
const bl = at(cx - 1, cy);
|
|
1194
|
+
const br = at(cx, cy);
|
|
1195
|
+
if ((tl && br && !tr && !bl) || (tr && bl && !tl && !br)) {
|
|
1196
|
+
throw new MeshError(
|
|
1197
|
+
`the alpha silhouette pinches to a single point at pixel corner (${cx},${cy}), where two parts of the art ` +
|
|
1198
|
+
'meet diagonally — one outline cannot pass through one point twice. Move the alpha threshold so the ' +
|
|
1199
|
+
'pinch opens (raise it) or closes (lower it), or author the geometry as weights',
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// The first filled pixel in row order is an island pixel: a hole's upper
|
|
1206
|
+
// neighbour is never background the border reaches, so no hole sits on the
|
|
1207
|
+
// silhouette's top row.
|
|
1208
|
+
let startAt = -1;
|
|
1209
|
+
for (let i = 0; i < filled.length; i++) {
|
|
1210
|
+
if (filled[i]) {
|
|
1211
|
+
startAt = i;
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const sx = startAt % w;
|
|
1216
|
+
const sy = (startAt - sx) / w;
|
|
1217
|
+
// With no pinch anywhere, every corner has at most one outgoing crack, so the
|
|
1218
|
+
// boundary is a simple loop and this walk traces it. The holes have no loop
|
|
1219
|
+
// of their own to enter, because the walk reads the filled silhouette: their
|
|
1220
|
+
// pixels are inside the outline and draw nothing, and `holePixels` counts them.
|
|
1221
|
+
const outline: Array<[number, number]> = [];
|
|
1222
|
+
let cx = sx;
|
|
1223
|
+
let cy = sy;
|
|
1224
|
+
let dir = 0;
|
|
1225
|
+
const limit = 2 * (w + 1) * (h + 1) + 8;
|
|
1226
|
+
for (let step = 0; ; step++) {
|
|
1227
|
+
if (step > limit) throw new MeshError('the alpha trace did not close; the mask is not a closed region');
|
|
1228
|
+
outline.push([cx, cy]);
|
|
1229
|
+
cx += CRACK_DIRS[dir][0];
|
|
1230
|
+
cy += CRACK_DIRS[dir][1];
|
|
1231
|
+
if (cx === sx && cy === sy) break;
|
|
1232
|
+
const dirs = outgoing(cx, cy);
|
|
1233
|
+
// Sharpest clockwise turn first: right, straight, left. A reversal is not
|
|
1234
|
+
// reachable on a crack set, so its absence is not a case to handle.
|
|
1235
|
+
const next = [(dir + 1) % 4, dir, (dir + 3) % 4].find((d) => dirs.includes(d));
|
|
1236
|
+
if (next === undefined) {
|
|
1237
|
+
throw new MeshError(`the alpha trace reached a dead end at corner (${cx},${cy}); the mask is not a closed region`);
|
|
1238
|
+
}
|
|
1239
|
+
dir = next;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return { outline, artPixels, islandPixels: sizes[biggest - 1], islands: sizes.length, holePixels, filled };
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* A set of pixels plus every background pixel it encloses.
|
|
1247
|
+
*
|
|
1248
|
+
* Flooded from outside the part, so "enclosed" is decided by REACHABILITY rather
|
|
1249
|
+
* than by a winding rule — which is what makes it answer the same question for a
|
|
1250
|
+
* mask of one island and a mask of several, and is why the authored-mesh
|
|
1251
|
+
* measurement can pass it all the art where the trace passes it one island.
|
|
1252
|
+
*
|
|
1253
|
+
* `background` is how the flood steps: 4 through edges, 8 through corners too.
|
|
1254
|
+
* The trace floods 8 — the dual of its 4-connected island, so background that
|
|
1255
|
+
* meets the outside at a corner is outside — and says why where it calls this.
|
|
1256
|
+
* The authored-mesh measurement floods 4, as it always has; the two agree on
|
|
1257
|
+
* every mask the trace accepts.
|
|
1258
|
+
*/
|
|
1259
|
+
export function fillEnclosed(
|
|
1260
|
+
inside: Uint8Array,
|
|
1261
|
+
w: number,
|
|
1262
|
+
h: number,
|
|
1263
|
+
background: 4 | 8,
|
|
1264
|
+
): { filled: Uint8Array; holePixels: number } {
|
|
1265
|
+
const outsideReach = new Uint8Array(w * h);
|
|
1266
|
+
const queue: number[] = [];
|
|
1267
|
+
const seed = (x: number, y: number): void => {
|
|
1268
|
+
if (x < 0 || y < 0 || x >= w || y >= h) return;
|
|
1269
|
+
const i = y * w + x;
|
|
1270
|
+
if (inside[i] || outsideReach[i]) return;
|
|
1271
|
+
outsideReach[i] = 1;
|
|
1272
|
+
queue.push(i);
|
|
1273
|
+
};
|
|
1274
|
+
for (let x = 0; x < w; x++) {
|
|
1275
|
+
seed(x, 0);
|
|
1276
|
+
seed(x, h - 1);
|
|
1277
|
+
}
|
|
1278
|
+
for (let y = 0; y < h; y++) {
|
|
1279
|
+
seed(0, y);
|
|
1280
|
+
seed(w - 1, y);
|
|
1281
|
+
}
|
|
1282
|
+
while (queue.length) {
|
|
1283
|
+
const i = queue.pop()!;
|
|
1284
|
+
const x = i % w;
|
|
1285
|
+
const y = (i - x) / w;
|
|
1286
|
+
seed(x - 1, y);
|
|
1287
|
+
seed(x + 1, y);
|
|
1288
|
+
seed(x, y - 1);
|
|
1289
|
+
seed(x, y + 1);
|
|
1290
|
+
if (background === 8) {
|
|
1291
|
+
seed(x - 1, y - 1);
|
|
1292
|
+
seed(x + 1, y - 1);
|
|
1293
|
+
seed(x - 1, y + 1);
|
|
1294
|
+
seed(x + 1, y + 1);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
const filled = new Uint8Array(w * h);
|
|
1298
|
+
let holePixels = 0;
|
|
1299
|
+
for (let i = 0; i < filled.length; i++) {
|
|
1300
|
+
if (inside[i]) filled[i] = 1;
|
|
1301
|
+
else if (!outsideReach[i]) {
|
|
1302
|
+
filled[i] = 1;
|
|
1303
|
+
holePixels++;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return { filled, holePixels };
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Which pixels of a `w`x`h` grid a triangle set draws over.
|
|
1311
|
+
*
|
|
1312
|
+
* A pixel counts as covered when its CENTRE is in or on a triangle, which is the
|
|
1313
|
+
* same convention `src/render.ts` rasterises by. A degenerate triangle — zero
|
|
1314
|
+
* doubled area — is skipped rather than given an orientation it does not have.
|
|
1315
|
+
*/
|
|
1316
|
+
export function rasteriseTriangles(points: Array<[number, number]>, triangles: number[], w: number, h: number): Uint8Array {
|
|
1317
|
+
const covered = new Uint8Array(w * h);
|
|
1318
|
+
for (let t = 0; t + 2 < triangles.length; t += 3) {
|
|
1319
|
+
const a = points[triangles[t]];
|
|
1320
|
+
const b = points[triangles[t + 1]];
|
|
1321
|
+
const c = points[triangles[t + 2]];
|
|
1322
|
+
const box = triangleRasterBox(a, b, c, w, h);
|
|
1323
|
+
if (box === null) continue;
|
|
1324
|
+
for (let y = box.minY; y <= box.maxY; y++) {
|
|
1325
|
+
for (let x = box.minX; x <= box.maxX; x++) {
|
|
1326
|
+
if (covered[y * w + x]) continue;
|
|
1327
|
+
if (pixelCentreInTriangle(x, y, a, b, c, box.orient)) covered[y * w + x] = 1;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
return covered;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* The pixels of a `w`x`h` grid one triangle can cover under `rasteriseTriangles`'s
|
|
1336
|
+
* rule — the box of its corners widened by a pixel and clamped to the grid —
|
|
1337
|
+
* and the orientation its centre test reads; null for a degenerate triangle
|
|
1338
|
+
* (zero doubled area), which covers nothing. `rasteriseTriangles` and the
|
|
1339
|
+
* per-step coverage of `src/meshrasters.ts` both read the rule from here and
|
|
1340
|
+
* from `pixelCentreInTriangle`, so the two cannot drift apart.
|
|
1341
|
+
*/
|
|
1342
|
+
export function triangleRasterBox(
|
|
1343
|
+
a: readonly [number, number],
|
|
1344
|
+
b: readonly [number, number],
|
|
1345
|
+
c: readonly [number, number],
|
|
1346
|
+
w: number,
|
|
1347
|
+
h: number,
|
|
1348
|
+
): { orient: number; minX: number; maxX: number; minY: number; maxY: number } | null {
|
|
1349
|
+
const twice = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
1350
|
+
if (Math.abs(twice) < 1e-12) return null;
|
|
1351
|
+
return {
|
|
1352
|
+
orient: twice > 0 ? 1 : -1,
|
|
1353
|
+
minX: Math.max(0, Math.floor(Math.min(a[0], b[0], c[0]) - 1)),
|
|
1354
|
+
maxX: Math.min(w - 1, Math.ceil(Math.max(a[0], b[0], c[0]) + 1)),
|
|
1355
|
+
minY: Math.max(0, Math.floor(Math.min(a[1], b[1], c[1]) - 1)),
|
|
1356
|
+
maxY: Math.min(h - 1, Math.ceil(Math.max(a[1], b[1], c[1]) + 1)),
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Is the centre of pixel (`x`, `y`) in or on the triangle — `rasteriseTriangles`'s
|
|
1362
|
+
* test, with the orientation `triangleRasterBox` gave. `pointInTriangle` at
|
|
1363
|
+
* `[x + 0.5, y + 0.5]`, written out without the point array: the same three
|
|
1364
|
+
* products, in the same order, against the same tolerance.
|
|
1365
|
+
*/
|
|
1366
|
+
export function pixelCentreInTriangle(x: number, y: number, a: readonly [number, number], b: readonly [number, number], c: readonly [number, number], orient: number): boolean {
|
|
1367
|
+
const px = x + 0.5;
|
|
1368
|
+
const py = y + 0.5;
|
|
1369
|
+
return (
|
|
1370
|
+
((b[0] - a[0]) * (py - a[1]) - (b[1] - a[1]) * (px - a[0])) * orient >= -1e-9 &&
|
|
1371
|
+
((c[0] - b[0]) * (py - b[1]) - (c[1] - b[1]) * (px - b[0])) * orient >= -1e-9 &&
|
|
1372
|
+
((a[0] - c[0]) * (py - c[1]) - (a[1] - c[1]) * (px - c[0])) * orient >= -1e-9
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Rasterise a triangle set over the part's pixel grid and measure two things the
|
|
1378
|
+
* skeleton file cannot say: how much of the art the mesh covers, and how far past
|
|
1379
|
+
* the silhouette it reaches.
|
|
1380
|
+
*
|
|
1381
|
+
* Coverage is against the ART (every pixel at or above the threshold, on any
|
|
1382
|
+
* island), so a second island the outline could never reach shows up as missing
|
|
1383
|
+
* coverage rather than as a passing build. Overshoot is against the FILLED
|
|
1384
|
+
* silhouette — the island plus the transparent pixels it encloses — because a
|
|
1385
|
+
* hole the mesh spans is not the mesh reaching past the art, it is the mesh
|
|
1386
|
+
* spanning a gap in it, and those pixels draw nothing either way.
|
|
1387
|
+
*
|
|
1388
|
+
* A pixel counts as covered when its CENTRE is in or on a triangle, which is the
|
|
1389
|
+
* same convention `src/render.ts` rasterises by. `radius` bounds the distance
|
|
1390
|
+
* search: nothing beyond it needs a number, because a mesh that reaches that far
|
|
1391
|
+
* out is refused whatever the exact figure is.
|
|
1392
|
+
*/
|
|
1393
|
+
export function measureContourFit(
|
|
1394
|
+
mask: AlphaMask,
|
|
1395
|
+
threshold: number,
|
|
1396
|
+
filled: Uint8Array,
|
|
1397
|
+
points: Array<[number, number]>,
|
|
1398
|
+
triangles: number[],
|
|
1399
|
+
radius: number,
|
|
1400
|
+
): { coverage: number; overshoot: number; artPixels: number; coveredArt: number } {
|
|
1401
|
+
const { width: w, height: h } = mask;
|
|
1402
|
+
const art = artOf(mask, threshold);
|
|
1403
|
+
const covered = rasteriseTriangles(points, triangles, w, h);
|
|
1404
|
+
let artPixels = 0;
|
|
1405
|
+
let coveredArt = 0;
|
|
1406
|
+
for (let i = 0; i < art.length; i++) {
|
|
1407
|
+
if (!art[i]) continue;
|
|
1408
|
+
artPixels++;
|
|
1409
|
+
if (covered[i]) coveredArt++;
|
|
1410
|
+
}
|
|
1411
|
+
const reach = Math.max(1, Math.ceil(radius));
|
|
1412
|
+
let overshoot = 0;
|
|
1413
|
+
for (let y = 0; y < h; y++) {
|
|
1414
|
+
for (let x = 0; x < w; x++) {
|
|
1415
|
+
const i = y * w + x;
|
|
1416
|
+
if (!covered[i] || filled[i]) continue;
|
|
1417
|
+
let best = reach + 1;
|
|
1418
|
+
for (let dy = -reach; dy <= reach; dy++) {
|
|
1419
|
+
const ny = y + dy;
|
|
1420
|
+
if (ny < 0 || ny >= h) continue;
|
|
1421
|
+
for (let dx = -reach; dx <= reach; dx++) {
|
|
1422
|
+
const nx = x + dx;
|
|
1423
|
+
if (nx < 0 || nx >= w) continue;
|
|
1424
|
+
if (!filled[ny * w + nx]) continue;
|
|
1425
|
+
const d = Math.hypot(dx, dy);
|
|
1426
|
+
if (d < best) best = d;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
if (best > overshoot) overshoot = best;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
return {
|
|
1433
|
+
coverage: artPixels === 0 ? 0 : coveredArt / artPixels,
|
|
1434
|
+
overshoot: r6(overshoot),
|
|
1435
|
+
artPixels,
|
|
1436
|
+
coveredArt,
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** What a mesh's triangles measure against the art the attachment names. */
|
|
1441
|
+
export interface MeshFitReport {
|
|
1442
|
+
/** Pixels at or above the threshold. */
|
|
1443
|
+
artPixels: number;
|
|
1444
|
+
/** How many of them a triangle covers. */
|
|
1445
|
+
coveredArt: number;
|
|
1446
|
+
/** `coveredArt / artPixels`, 0..1. */
|
|
1447
|
+
coverage: number;
|
|
1448
|
+
/** Furthest a covered pixel sits outside the filled silhouette, in pixels. */
|
|
1449
|
+
overshoot: number;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* The same measurement for geometry rigc did NOT build — issue #277.
|
|
1454
|
+
*
|
|
1455
|
+
* ## Why an authored mesh can be measured at all
|
|
1456
|
+
*
|
|
1457
|
+
* Issue #44's lesson was that rigc must not apply a GENERATOR's topology rules to
|
|
1458
|
+
* authored geometry: where a rim is, how rows pair, which edge is pinned. It did
|
|
1459
|
+
* not build the mesh, so it cannot know any of that. Coverage is not topology.
|
|
1460
|
+
* It is a number between two things the compiler has in front of it — the emitted
|
|
1461
|
+
* triangles, and the PNG the attachment names with `image` — and it assumes
|
|
1462
|
+
* nothing whatever about how the vertices are arranged. The defect it catches is
|
|
1463
|
+
* the renderer's, not the author's: texture outside the triangles is not drawn,
|
|
1464
|
+
* so art outside the mesh disappears on every runtime. A 9-vertex fan whose 8 rim
|
|
1465
|
+
* vertices sit exactly on a round part's silhouette loses its whole ink outline
|
|
1466
|
+
* between the spokes — an octagon's sides pass `R·cos(π/8)` from its centre — and
|
|
1467
|
+
* measured 94.31%, five points under the bar the same art would have been refused
|
|
1468
|
+
* at as a `contour`, with nothing in the report saying so.
|
|
1469
|
+
*
|
|
1470
|
+
* ## Two differences from `measureContourFit`, both deliberate
|
|
1471
|
+
*
|
|
1472
|
+
* **The filled silhouette is ALL the art plus what it encloses**, not the largest
|
|
1473
|
+
* island plus what that encloses. A contour is one traced loop and can only ever
|
|
1474
|
+
* enclose one island; an authored mesh over a part drawn as several islands is
|
|
1475
|
+
* ordinary, correct data.
|
|
1476
|
+
*
|
|
1477
|
+
* **The overshoot search has no radius.** `measureContourFit` bounds it because a
|
|
1478
|
+
* contour past its bound is refused and needs no exact figure. An authored mesh
|
|
1479
|
+
* is never refused, so every figure needs a number — hence the exact distance
|
|
1480
|
+
* transform below rather than a neighbourhood search that would have to stop
|
|
1481
|
+
* somewhere.
|
|
1482
|
+
*/
|
|
1483
|
+
export function measureAuthoredMeshFit(
|
|
1484
|
+
mask: AlphaMask,
|
|
1485
|
+
threshold: number,
|
|
1486
|
+
points: Array<[number, number]>,
|
|
1487
|
+
triangles: number[],
|
|
1488
|
+
): MeshFitReport {
|
|
1489
|
+
const { width: w, height: h } = mask;
|
|
1490
|
+
const art = artOf(mask, threshold);
|
|
1491
|
+
const { filled } = fillEnclosed(art, w, h, 4);
|
|
1492
|
+
const covered = rasteriseTriangles(points, triangles, w, h);
|
|
1493
|
+
let artPixels = 0;
|
|
1494
|
+
let coveredArt = 0;
|
|
1495
|
+
for (let i = 0; i < art.length; i++) {
|
|
1496
|
+
if (!art[i]) continue;
|
|
1497
|
+
artPixels++;
|
|
1498
|
+
if (covered[i]) coveredArt++;
|
|
1499
|
+
}
|
|
1500
|
+
const squared = squaredDistanceToSet(filled, w, h);
|
|
1501
|
+
let worst = 0;
|
|
1502
|
+
for (let i = 0; i < covered.length; i++) {
|
|
1503
|
+
if (!covered[i] || filled[i]) continue;
|
|
1504
|
+
if (squared[i] > worst) worst = squared[i];
|
|
1505
|
+
}
|
|
1506
|
+
return {
|
|
1507
|
+
artPixels,
|
|
1508
|
+
coveredArt,
|
|
1509
|
+
coverage: artPixels === 0 ? 0 : coveredArt / artPixels,
|
|
1510
|
+
overshoot: r6(Math.sqrt(worst)),
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Exact squared Euclidean distance from every pixel to the nearest set pixel of
|
|
1516
|
+
* `inside`, by the two-pass lower-envelope transform (Felzenszwalb-Huttenlocher).
|
|
1517
|
+
*
|
|
1518
|
+
* Two one-dimensional passes — down each column, then along each row of the
|
|
1519
|
+
* result — because the squared Euclidean distance separates across axes:
|
|
1520
|
+
* `min_p (x-px)² + (y-py)²` is the lower envelope of one parabola per candidate,
|
|
1521
|
+
* and a pass builds that envelope in one sweep. Exact, and linear in the number
|
|
1522
|
+
* of pixels, which is the reason it is here at all: the bounded neighbourhood
|
|
1523
|
+
* search `measureContourFit` uses costs `radius²` per pixel and has to be told
|
|
1524
|
+
* where to stop, and the authored path has nowhere to stop.
|
|
1525
|
+
*
|
|
1526
|
+
* `INF` is one past the furthest two pixels of this grid can be, so a column with
|
|
1527
|
+
* no set pixel survives the first pass as "nothing in this column" rather than as
|
|
1528
|
+
* a distance. A grid with no set pixel at all comes back all `INF`; the one caller
|
|
1529
|
+
* never asks (a mask with no art has no covered-outside pixel to ask about).
|
|
1530
|
+
*/
|
|
1531
|
+
export function squaredDistanceToSet(inside: Uint8Array, w: number, h: number): Float64Array {
|
|
1532
|
+
const passes = distancePassesOf(w, h);
|
|
1533
|
+
const dist = new Float64Array(w * h);
|
|
1534
|
+
for (let x = 0; x < w; x++) {
|
|
1535
|
+
const out = passes.column(inside, x);
|
|
1536
|
+
for (let y = 0; y < h; y++) dist[y * w + x] = out[y];
|
|
1537
|
+
}
|
|
1538
|
+
for (let y = 0; y < h; y++) {
|
|
1539
|
+
const out = passes.row(dist, y);
|
|
1540
|
+
for (let x = 0; x < w; x++) dist[y * w + x] = out[x];
|
|
1541
|
+
}
|
|
1542
|
+
return dist;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* `squaredDistanceToSet`'s two passes over one `w`x`h` grid, one column or one
|
|
1547
|
+
* row per call, so a caller holding the previous grid's passes can redo only
|
|
1548
|
+
* the columns whose set changed and the rows whose column values changed.
|
|
1549
|
+
* `column(inside, x)` is column `x`'s pass over the set (`INF` where the
|
|
1550
|
+
* column has no set pixel); `row(columns, y)` is row `y`'s pass over the
|
|
1551
|
+
* column results. Each returns a scratch array whose first `h` (column) or
|
|
1552
|
+
* `w` (row) entries are the result, valid until the next call. A column's
|
|
1553
|
+
* result depends on that column of `inside` alone and a row's on that row of
|
|
1554
|
+
* `columns` alone, which is what makes redoing a subset exact: every output
|
|
1555
|
+
* is this one function of the same input.
|
|
1556
|
+
*/
|
|
1557
|
+
export function distancePassesOf(w: number, h: number): { column(inside: Uint8Array, x: number): Float64Array; row(columns: Float64Array, y: number): Float64Array } {
|
|
1558
|
+
const INF = w * w + h * h + 1;
|
|
1559
|
+
const span = Math.max(w, h);
|
|
1560
|
+
const f = new Float64Array(span);
|
|
1561
|
+
const out = new Float64Array(span);
|
|
1562
|
+
/** The parabolas still on the envelope, as the sample index each rises from. */
|
|
1563
|
+
const v = new Int32Array(span);
|
|
1564
|
+
/** Where consecutive envelope parabolas cross. One longer than `v` by nature. */
|
|
1565
|
+
const z = new Float64Array(span + 1);
|
|
1566
|
+
const envelope = (n: number): void => {
|
|
1567
|
+
let k = 0;
|
|
1568
|
+
v[0] = 0;
|
|
1569
|
+
z[0] = -Infinity;
|
|
1570
|
+
z[1] = Infinity;
|
|
1571
|
+
for (let q = 1; q < n; q++) {
|
|
1572
|
+
let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
|
|
1573
|
+
while (s <= z[k]) {
|
|
1574
|
+
k--;
|
|
1575
|
+
s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
|
|
1576
|
+
}
|
|
1577
|
+
k++;
|
|
1578
|
+
v[k] = q;
|
|
1579
|
+
z[k] = s;
|
|
1580
|
+
z[k + 1] = Infinity;
|
|
1581
|
+
}
|
|
1582
|
+
k = 0;
|
|
1583
|
+
for (let q = 0; q < n; q++) {
|
|
1584
|
+
while (z[k + 1] < q) k++;
|
|
1585
|
+
out[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
return {
|
|
1590
|
+
column: (inside, x) => {
|
|
1591
|
+
for (let y = 0; y < h; y++) f[y] = inside[y * w + x] ? 0 : INF;
|
|
1592
|
+
envelope(h);
|
|
1593
|
+
return out;
|
|
1594
|
+
},
|
|
1595
|
+
row: (columns, y) => {
|
|
1596
|
+
for (let x = 0; x < w; x++) f[x] = columns[y * w + x];
|
|
1597
|
+
envelope(w);
|
|
1598
|
+
return out;
|
|
1599
|
+
},
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* Build a mesh cut to the part's own alpha silhouette.
|
|
1605
|
+
*
|
|
1606
|
+
* Trace, simplify, push out, clamp to the window, triangulate, and then MEASURE
|
|
1607
|
+
* the result against the mask it came from. Every vertex is pinned to the slot
|
|
1608
|
+
* bone at weight 1 — see the section header for why that is the whole weighting
|
|
1609
|
+
* model and what to reach for instead when a bone has to bend the art.
|
|
1610
|
+
*/
|
|
1611
|
+
export function buildContourMesh(input: ContourSpecInput): MeshGeometry {
|
|
1612
|
+
const { mask, threshold, tolerance, margin, maxVertices, pageScale } = input;
|
|
1613
|
+
const [w, h] = [mask.width, mask.height];
|
|
1614
|
+
// The grid the trace runs on, and what the spec's two distances are on it.
|
|
1615
|
+
// Off a `scale:` page nothing is multiplied — the mask IS the drawing — so
|
|
1616
|
+
// the operands below are the spec's own numbers, to the bit.
|
|
1617
|
+
const onGrid = (value: number): number => (pageScale === undefined ? value : value * pageScale);
|
|
1618
|
+
const texelTolerance = onGrid(tolerance);
|
|
1619
|
+
const texelMargin = onGrid(margin);
|
|
1620
|
+
/**
|
|
1621
|
+
* The texel figures the trace actually ran at, for a refusal to say beside the spec's own. Handed the operands
|
|
1622
|
+
* themselves rather than recomputing them, so the clause cannot state a conversion the trace did not make.
|
|
1623
|
+
*/
|
|
1624
|
+
const applied = (texels: readonly number[]): string =>
|
|
1625
|
+
pageScale === undefined
|
|
1626
|
+
? ''
|
|
1627
|
+
: ` (the drawing's pixels — applied as ${texels.map((v) => String(r6(v))).join(' and ')} texel(s) of ` +
|
|
1628
|
+
`this page, whose scale: ${pageScale} makes a texel ${(1 / pageScale).toFixed(2)}px of the drawing)`;
|
|
1629
|
+
/** A count of mask cells: pixels of the drawing, or texels of a `scale:` page. */
|
|
1630
|
+
const cells = pageScale === undefined ? 'px' : 'texels';
|
|
1631
|
+
if (!Number.isInteger(threshold) || threshold < 1 || threshold > 255) {
|
|
1632
|
+
throw new MeshError(`the alpha threshold must be a whole number in 1..255, got ${threshold}`);
|
|
1633
|
+
}
|
|
1634
|
+
if (!(tolerance > 0) || !Number.isFinite(tolerance)) {
|
|
1635
|
+
throw new MeshError(`the simplification tolerance must be a positive number of pixels, got ${tolerance}`);
|
|
1636
|
+
}
|
|
1637
|
+
if (!(margin >= 0) || !Number.isFinite(margin)) throw new MeshError(`the margin must be 0 or more pixels, got ${margin}`);
|
|
1638
|
+
if (!Number.isInteger(maxVertices) || maxVertices < 3) {
|
|
1639
|
+
throw new MeshError(`maxVertices must be a whole number of at least 3, got ${maxVertices}`);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const traced = traceAlphaOutline(mask, threshold);
|
|
1643
|
+
// ⭐ A part with no transparent pixel at all, MEASURED rather than read off the
|
|
1644
|
+
// PNG's colour type. A truecolour+alpha file whose alpha is 255 everywhere has
|
|
1645
|
+
// an alpha channel and no transparency, so the header answers the wrong
|
|
1646
|
+
// question — and the silhouette of such a part is the part window, which makes
|
|
1647
|
+
// a contour mesh of it a region attachment with extra vertices to pose.
|
|
1648
|
+
if (traced.artPixels === w * h) {
|
|
1649
|
+
throw new MeshError(
|
|
1650
|
+
`every pixel of the ${w}x${h} part reaches alpha ${threshold}, so its silhouette IS the part window and a ` +
|
|
1651
|
+
'contour mesh of it is a region attachment with extra vertices — give the art a transparent margin, ' +
|
|
1652
|
+
'raise the alpha threshold, or use a region',
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
// Islands, named before anything is triangulated. One outline encloses one
|
|
1656
|
+
// region, so art scattered over several would come out as missing coverage
|
|
1657
|
+
// further down — a true refusal with a message about margins, which is not
|
|
1658
|
+
// the thing to change.
|
|
1659
|
+
if (traced.islandPixels / traced.artPixels < CONTOUR_MIN_COVERAGE) {
|
|
1660
|
+
throw new MeshError(
|
|
1661
|
+
`the art is ${traced.islands} separate islands and one outline can only enclose the largest ` +
|
|
1662
|
+
`(${traced.islandPixels} of ${traced.artPixels} px, ` +
|
|
1663
|
+
`${((traced.islandPixels / traced.artPixels) * 100).toFixed(2)}%) — raise the alpha threshold if the ` +
|
|
1664
|
+
'strays are feathering, or give each island its own slot',
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
const simplified = simplifyClosedPolygon(traced.outline, texelTolerance);
|
|
1668
|
+
const pushed = offsetPolygon(simplified, texelMargin);
|
|
1669
|
+
// Clamped to the part window, because a uv outside 0..1 is a different failure
|
|
1670
|
+
// (A22) and because there is no art out there to reach for anyway.
|
|
1671
|
+
const clamped = pushed.map(
|
|
1672
|
+
([x, y]) => [Math.min(w, Math.max(0, x)), Math.min(h, Math.max(0, y))] as [number, number],
|
|
1673
|
+
);
|
|
1674
|
+
const points = prunePolygon(clamped).map(([x, y]) => [r6(x), r6(y)] as [number, number]);
|
|
1675
|
+
if (points.length < 3) {
|
|
1676
|
+
throw new MeshError(
|
|
1677
|
+
`the ${w}x${h} silhouette simplified to ${points.length} distinct vertices at tolerance ${tolerance}` +
|
|
1678
|
+
`${applied([texelTolerance])}; lower the tolerance`,
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
if (points.length > maxVertices) {
|
|
1682
|
+
throw new MeshError(
|
|
1683
|
+
`the silhouette simplified to ${points.length} vertices at tolerance ${tolerance}${applied([texelTolerance])}, ` +
|
|
1684
|
+
`past the ${maxVertices} this mesh allows — raise the tolerance to spend fewer vertices, or raise ` +
|
|
1685
|
+
'maxVertices if the shape needs them',
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
const crossing = findSelfIntersection(points);
|
|
1689
|
+
if (crossing) {
|
|
1690
|
+
throw new MeshError(
|
|
1691
|
+
`the outline crosses itself: edge ${crossing[0]} meets edge ${crossing[1]} after a margin of ${margin}px` +
|
|
1692
|
+
`${applied([texelMargin])} was pushed out of a silhouette narrower than that — lower the margin, or the art ` +
|
|
1693
|
+
'has a neck too thin to mesh',
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1696
|
+
const triangles = windCounterClockwiseInSpineWorld(points, earClip(points));
|
|
1697
|
+
|
|
1698
|
+
// The bound on the grid the fit is measured on: its last term is one cell of
|
|
1699
|
+
// that grid, which is why it is derived from the texel figures rather than
|
|
1700
|
+
// converted from the drawing's bound afterwards.
|
|
1701
|
+
const allowed = contourOvershootBound(texelMargin, texelTolerance);
|
|
1702
|
+
const fit = measureContourFit(mask, threshold, traced.filled, points, triangles, allowed + 1);
|
|
1703
|
+
if (fit.coverage < CONTOUR_MIN_COVERAGE) {
|
|
1704
|
+
throw new MeshError(
|
|
1705
|
+
`the mesh covers ${(fit.coverage * 100).toFixed(2)}% of the art (${fit.coveredArt} of ${fit.artPixels} ${cells}), ` +
|
|
1706
|
+
`under the ${(CONTOUR_MIN_COVERAGE * 100).toFixed(1)}% a contour mesh guarantees — raise the margin ` +
|
|
1707
|
+
`(now ${margin}px) above the tolerance (${tolerance}px), which is how far simplification is allowed to ` +
|
|
1708
|
+
`cut inward, or lower the tolerance${applied([texelMargin, texelTolerance])}`,
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
if (fit.overshoot > allowed) {
|
|
1712
|
+
throw new MeshError(
|
|
1713
|
+
`the mesh reaches ${(pageScale === undefined ? fit.overshoot : fit.overshoot / pageScale).toFixed(2)}px past ` +
|
|
1714
|
+
`the silhouette, past the ${(pageScale === undefined ? allowed : allowed / pageScale).toFixed(2)}px that a ` +
|
|
1715
|
+
`margin of ${margin} and a tolerance of ${tolerance}${applied([texelMargin, texelTolerance])} can produce — the ` +
|
|
1716
|
+
'outline is not the one this builder promises, which is a defect in the builder rather than in the art',
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const uvs: number[] = [];
|
|
1721
|
+
for (const [x, y] of points) uvs.push(r6(x / w), r6(y / h));
|
|
1722
|
+
const weights: MeshVertexWeight[][] = points.map(() => [{ bone: 'anchor', weight: 1 }]);
|
|
1723
|
+
|
|
1724
|
+
return {
|
|
1725
|
+
kind: 'contour',
|
|
1726
|
+
points,
|
|
1727
|
+
uvs,
|
|
1728
|
+
triangles,
|
|
1729
|
+
weights,
|
|
1730
|
+
hullVertices: points.length,
|
|
1731
|
+
contour: {
|
|
1732
|
+
artPixels: fit.artPixels,
|
|
1733
|
+
islandPixels: traced.islandPixels,
|
|
1734
|
+
islands: traced.islands,
|
|
1735
|
+
holePixels: traced.holePixels,
|
|
1736
|
+
tracedVertices: traced.outline.length,
|
|
1737
|
+
coverage: r6(fit.coverage),
|
|
1738
|
+
overshoot: fit.overshoot,
|
|
1739
|
+
},
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* One bone a weighted vertex can bind to: its NAME, and its world inverse.
|
|
1745
|
+
*
|
|
1746
|
+
* It carried the bone's index into the emitted bone array until issue #917; the
|
|
1747
|
+
* index is the Spine emitter's now (`emitVertices` in `src/emit_spine.ts`), so a
|
|
1748
|
+
* binding made here names its bone and survives a bone inserted ahead of it.
|
|
1749
|
+
*/
|
|
1750
|
+
export interface MeshBoneRef {
|
|
1751
|
+
name: string;
|
|
1752
|
+
/** Spine world point -> this bone's local space, at the setup pose. */
|
|
1753
|
+
toBind: (worldX: number, worldY: number) => [number, number];
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* Bind a generated mesh's vertices to their bones by name: per vertex, one
|
|
1758
|
+
* `{ bone, x, y, weight }` per influence, `x, y` the vertex in that bone's LOCAL
|
|
1759
|
+
* setup space and `weight` on the generator's grid (`r6`).
|
|
1760
|
+
*
|
|
1761
|
+
* Bind coordinates are in each bone's LOCAL space, so a rotated bone needs a real
|
|
1762
|
+
* inverse transform — see `src/transform.ts` for why the old "world minus origin"
|
|
1763
|
+
* shortcut had to go and what it would have failed like.
|
|
1764
|
+
*
|
|
1765
|
+
* Returned as the model's `ModelVertices`, weighted, and not as Spine's run: the
|
|
1766
|
+
* run's bone INDEX is written by the Spine emitter at emission (issue #917), and
|
|
1767
|
+
* the encoding it chooses by a length comparison alone is said here outright.
|
|
1768
|
+
* The caller puts the numbers on the float32 grid, as it did the run's.
|
|
1769
|
+
*/
|
|
1770
|
+
export function bindWeightedVertices(
|
|
1771
|
+
geometry: MeshGeometry,
|
|
1772
|
+
/** Part-local pixel -> Spine world. */
|
|
1773
|
+
toWorld: (x: number, y: number) => [number, number],
|
|
1774
|
+
bones: { anchor: MeshBoneRef; controls: MeshBoneRef[] },
|
|
1775
|
+
): Extract<ModelVertices, { weighted: true }> {
|
|
1776
|
+
const bindings: ModelBinding[][] = [];
|
|
1777
|
+
geometry.points.forEach(([px, py], i) => {
|
|
1778
|
+
const [wx, wy] = toWorld(px, py);
|
|
1779
|
+
const vertex: ModelBinding[] = [];
|
|
1780
|
+
for (const { bone, control, weight } of geometry.weights[i]) {
|
|
1781
|
+
const ref = bone === 'anchor' ? bones.anchor : bones.controls[control ?? 0];
|
|
1782
|
+
if (!ref) throw new MeshError(`vertex ${i} binds to control bone ${control ?? 0}, which the rig does not have`);
|
|
1783
|
+
const [bx, by] = ref.toBind(wx, wy);
|
|
1784
|
+
vertex.push({ bone: ref.name, x: bx, y: by, weight: r6(weight) });
|
|
1785
|
+
}
|
|
1786
|
+
bindings.push(vertex);
|
|
1787
|
+
});
|
|
1788
|
+
return { weighted: true, bindings };
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// ---------------------------------------------------------------------------
|
|
1792
|
+
// the outline a triangulation already states — `hull` and `edges` (issue #368)
|
|
1793
|
+
// ---------------------------------------------------------------------------
|
|
1794
|
+
//
|
|
1795
|
+
// Spine's `hull` is not a free field: it is the number of vertices, FIRST in the
|
|
1796
|
+
// vertex list and IN ORDER, that make up the mesh's outline polygon. Everything
|
|
1797
|
+
// that reads it assumes exactly that — the editor draws the outline by joining
|
|
1798
|
+
// hull vertex i to i+1 and constrains its triangulation to those segments, the
|
|
1799
|
+
// runtime's debug renderer does the same, and `SkeletonBinary` does not even
|
|
1800
|
+
// store a triangle count: it reads `(vertices.length - hullLength - 2) * 3`
|
|
1801
|
+
// shorts, Euler's count for a hole-free triangulation whose boundary has `hull`
|
|
1802
|
+
// vertices. So a hull that disagrees with the triangles is not cosmetic, it is a
|
|
1803
|
+
// mesh that cannot be read back from a `.skel`, and a hull of 0 hands the editor
|
|
1804
|
+
// a mesh it has to guess an outline for — which it does by declaring EVERY
|
|
1805
|
+
// vertex a hull vertex, in list order, and saying so in a WARNING on import.
|
|
1806
|
+
//
|
|
1807
|
+
// The triangles already fix the outline: an edge used by exactly one triangle
|
|
1808
|
+
// is on it, an edge shared by two is interior. `traceOutline` reads that off,
|
|
1809
|
+
// `checkHullOrder` asks whether the vertex list is arranged the way `hull` needs
|
|
1810
|
+
// it to be, and `meshEdges` writes the edge list the editor otherwise reports
|
|
1811
|
+
// lost. None of this invents a value — every number comes out of `triangles`,
|
|
1812
|
+
// and a list the rule cannot be applied to is refused with the fix spelled out.
|
|
1813
|
+
|
|
1814
|
+
/** What the triangles say the outline is. */
|
|
1815
|
+
export interface MeshOutline {
|
|
1816
|
+
/** Boundary vertex count — the `hull` a consistent list declares. */
|
|
1817
|
+
hull: number;
|
|
1818
|
+
/**
|
|
1819
|
+
* The outline as one closed walk, starting at its lowest-numbered vertex and
|
|
1820
|
+
* heading for the smaller of that vertex's two neighbours. Deterministic, so a
|
|
1821
|
+
* refusal can print it as the order to renumber along.
|
|
1822
|
+
*/
|
|
1823
|
+
walk: number[];
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
/** `0 → 5 → 10 → …`, the shape every outline message prints. */
|
|
1827
|
+
export function formatWalk(walk: readonly number[]): string {
|
|
1828
|
+
return walk.join(' → ');
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* Read the outline off a triangulation, or refuse a triangulation that has none.
|
|
1833
|
+
*
|
|
1834
|
+
* Refused here, each by name: an index outside the vertex list, a triangle that
|
|
1835
|
+
* repeats a vertex, a boundary that is not one closed loop (a pinched vertex, a
|
|
1836
|
+
* hole, two islands), and a triangle count that is not Euler's for that outline
|
|
1837
|
+
* — which is what an unused vertex or a doubled triangle looks like, and what
|
|
1838
|
+
* the binary reader would choke on.
|
|
1839
|
+
*/
|
|
1840
|
+
export function traceOutline(vertexCount: number, triangles: readonly number[]): MeshOutline {
|
|
1841
|
+
if (triangles.length % 3 !== 0) throw new MeshError(`triangle count ${triangles.length} is not a multiple of 3`);
|
|
1842
|
+
const key = (a: number, b: number): number => (a < b ? a * vertexCount + b : b * vertexCount + a);
|
|
1843
|
+
const uses = new Map<number, number>();
|
|
1844
|
+
for (let i = 0; i < triangles.length; i += 3) {
|
|
1845
|
+
const tri = [triangles[i], triangles[i + 1], triangles[i + 2]];
|
|
1846
|
+
for (const idx of tri) {
|
|
1847
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= vertexCount) {
|
|
1848
|
+
throw new MeshError(`triangle ${i / 3} names vertex ${idx}, and the mesh has vertices 0..${vertexCount - 1}`);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
if (tri[0] === tri[1] || tri[1] === tri[2] || tri[2] === tri[0]) {
|
|
1852
|
+
throw new MeshError(`triangle ${i / 3} (${tri.join(', ')}) repeats a vertex, so it has no area`);
|
|
1853
|
+
}
|
|
1854
|
+
for (const [a, b] of [[tri[0], tri[1]], [tri[1], tri[2]], [tri[2], tri[0]]]) {
|
|
1855
|
+
const k = key(a, b);
|
|
1856
|
+
uses.set(k, (uses.get(k) ?? 0) + 1);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
// Boundary edges, as adjacency. A vertex on a closed outline has exactly two.
|
|
1860
|
+
const next = new Map<number, number[]>();
|
|
1861
|
+
for (const [k, count] of uses) {
|
|
1862
|
+
if (count !== 1) continue;
|
|
1863
|
+
const a = Math.floor(k / vertexCount);
|
|
1864
|
+
const b = k % vertexCount;
|
|
1865
|
+
next.set(a, [...(next.get(a) ?? []), b]);
|
|
1866
|
+
next.set(b, [...(next.get(b) ?? []), a]);
|
|
1867
|
+
}
|
|
1868
|
+
if (next.size === 0) throw new MeshError('the triangles have no outline: every edge is shared by two triangles');
|
|
1869
|
+
for (const [v, ns] of [...next.entries()].sort((p, q) => p[0] - q[0])) {
|
|
1870
|
+
if (ns.length !== 2) {
|
|
1871
|
+
throw new MeshError(`the triangles' outline is not one closed loop: vertex ${v} has ${ns.length} boundary edges`);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
const start = Math.min(...next.keys());
|
|
1875
|
+
const walk = [start];
|
|
1876
|
+
let prev = -1;
|
|
1877
|
+
let at = start;
|
|
1878
|
+
for (;;) {
|
|
1879
|
+
const [n0, n1] = next.get(at)!;
|
|
1880
|
+
const to = prev === -1 ? Math.min(n0, n1) : n0 === prev ? n1 : n0;
|
|
1881
|
+
if (to === start) break;
|
|
1882
|
+
walk.push(to);
|
|
1883
|
+
prev = at;
|
|
1884
|
+
at = to;
|
|
1885
|
+
}
|
|
1886
|
+
if (walk.length !== next.size) {
|
|
1887
|
+
throw new MeshError(
|
|
1888
|
+
`the triangles' outline is not one closed loop: the walk from vertex ${start} closes after ` +
|
|
1889
|
+
`${walk.length} of ${next.size} boundary vertices`,
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
const hull = walk.length;
|
|
1893
|
+
const euler = 2 * vertexCount - hull - 2;
|
|
1894
|
+
if (triangles.length / 3 !== euler) {
|
|
1895
|
+
throw new MeshError(
|
|
1896
|
+
`the triangles do not tile the outline: ${vertexCount} vertices with a ${hull}-vertex outline tile as ` +
|
|
1897
|
+
`${euler} triangles and there are ${triangles.length / 3} — Spine's binary reader derives the triangle ` +
|
|
1898
|
+
'count from exactly that, so a mesh that breaks it cannot be read back from a .skel',
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
return { hull, walk };
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Is the vertex list arranged the way `hull` needs it — outline first, in order?
|
|
1906
|
+
*
|
|
1907
|
+
* Two refusals, both with the walk printed, because the walk IS the fix: the
|
|
1908
|
+
* outline vertices are not the first `hull` of the list (a row-major grid: its
|
|
1909
|
+
* perimeter is 16 of 25 and interleaved with the interior), or they are but out
|
|
1910
|
+
* of order (a two-column strip: every vertex is on the outline, and the outline
|
|
1911
|
+
* runs down one side and up the other while the list zigzags across). Either
|
|
1912
|
+
* direction around the loop is accepted — it is the same polygon.
|
|
1913
|
+
*/
|
|
1914
|
+
export function checkHullOrder(outline: MeshOutline, vertexCount: number): void {
|
|
1915
|
+
const { hull, walk } = outline;
|
|
1916
|
+
const onOutline = new Set(walk);
|
|
1917
|
+
const outside = walk.filter((v) => v >= hull).sort((a, b) => a - b)[0];
|
|
1918
|
+
if (outside !== undefined) {
|
|
1919
|
+
const inside = [...Array(hull).keys()].find((v) => !onOutline.has(v))!;
|
|
1920
|
+
throw new MeshError(
|
|
1921
|
+
`hull vertices must come first; vertex ${outside} is on the boundary and vertex ${inside} is not. ` +
|
|
1922
|
+
`The triangles' outline runs ${formatWalk(walk)}: list those ${hull} vertices first, in that order, ` +
|
|
1923
|
+
`then the ${vertexCount - hull} interior vertices`,
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1926
|
+
const forward = walk.every((v, i) => v === i);
|
|
1927
|
+
const backward = walk.every((v, i) => v === (i === 0 ? 0 : hull - i));
|
|
1928
|
+
if (forward || backward) return;
|
|
1929
|
+
// Report along whichever direction keeps vertex 1 next to vertex 0 when it
|
|
1930
|
+
// can, so the printed order changes as little of the author's list as possible.
|
|
1931
|
+
const oriented = walk[1] <= walk[hull - 1] ? walk : [walk[0], ...walk.slice(1).reverse()];
|
|
1932
|
+
const at = oriented.findIndex((v, i) => v !== i);
|
|
1933
|
+
throw new MeshError(
|
|
1934
|
+
`hull vertices must trace the outline in order; the triangles' outline runs ${formatWalk(oriented)}, ` +
|
|
1935
|
+
`so vertex ${oriented[at]} has to follow vertex ${oriented[at - 1]} in the list, and vertex ${at} does. ` +
|
|
1936
|
+
'Renumber the vertices along that walk',
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/**
|
|
1941
|
+
* The mesh's edge list, in the encoding the editor writes.
|
|
1942
|
+
*
|
|
1943
|
+
* ⚠️ Each entry is a vertex index TIMES TWO — an offset into the flat x,y array,
|
|
1944
|
+
* the same convention the loader applies to `hull` when it stores it doubled.
|
|
1945
|
+
* Read off the editor's own exports rather than assumed: a 22-vertex mesh's list
|
|
1946
|
+
* tops out at 42, every pair is an edge of some triangle, the outline loop is
|
|
1947
|
+
* always present, and the spineboy example's meshes carry their interior edges
|
|
1948
|
+
* the same way. The format page says "vertex index pairs" and leaves the factor
|
|
1949
|
+
* to the reader.
|
|
1950
|
+
*
|
|
1951
|
+
* Every triangle edge is written — the outline loop first, `(0,1) … (hull-1,0)`,
|
|
1952
|
+
* then the interior edges sorted — so the editor's constrained triangulation has
|
|
1953
|
+
* every segment it needs to reproduce these exact triangles instead of reporting
|
|
1954
|
+
* the interior ones lost. The order is fixed so A18's byte comparison holds.
|
|
1955
|
+
*/
|
|
1956
|
+
export function meshEdges(vertexCount: number, triangles: readonly number[], hull: number): number[] {
|
|
1957
|
+
const key = (a: number, b: number): number => (a < b ? a * vertexCount + b : b * vertexCount + a);
|
|
1958
|
+
const seen = new Set<number>();
|
|
1959
|
+
const out: number[] = [];
|
|
1960
|
+
for (let i = 0; i < hull; i++) {
|
|
1961
|
+
const j = (i + 1) % hull;
|
|
1962
|
+
seen.add(key(i, j));
|
|
1963
|
+
out.push(2 * i, 2 * j);
|
|
1964
|
+
}
|
|
1965
|
+
const interior: Array<[number, number]> = [];
|
|
1966
|
+
for (let i = 0; i < triangles.length; i += 3) {
|
|
1967
|
+
const tri = [triangles[i], triangles[i + 1], triangles[i + 2]];
|
|
1968
|
+
for (const [a, b] of [[tri[0], tri[1]], [tri[1], tri[2]], [tri[2], tri[0]]]) {
|
|
1969
|
+
const k = key(a, b);
|
|
1970
|
+
if (seen.has(k)) continue;
|
|
1971
|
+
seen.add(k);
|
|
1972
|
+
interior.push(a < b ? [a, b] : [b, a]);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
interior.sort((p, q) => p[0] - q[0] || p[1] - q[1]);
|
|
1976
|
+
for (const [a, b] of interior) out.push(2 * a, 2 * b);
|
|
1977
|
+
return out;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// ---------------------------------------------------------------------------
|
|
1981
|
+
// segments — a lattice over the part's alpha, weighted by distance to bones
|
|
1982
|
+
// ---------------------------------------------------------------------------
|
|
1983
|
+
//
|
|
1984
|
+
// The generator for "an arbitrary layer pulled by a chosen set of bones". Its
|
|
1985
|
+
// one authored input is WHICH segments may pull the part; the geometry is read
|
|
1986
|
+
// off the art and the weights off the distance between the two, so a pipeline
|
|
1987
|
+
// that used to re-implement distance weighting outside the compiler states the
|
|
1988
|
+
// segment list and nothing else.
|
|
1989
|
+
//
|
|
1990
|
+
// The algorithm is a port of `spine-parts`' lattice mesh and segment weights
|
|
1991
|
+
// (MIT, same owner). What it does, in order:
|
|
1992
|
+
//
|
|
1993
|
+
// 1. **Cells.** A square lattice of `cell`-pixel squares over the part; the
|
|
1994
|
+
// last column and row are clipped to the image. A cell is kept when any
|
|
1995
|
+
// pixel inside it reaches the alpha threshold, so the triangles cover
|
|
1996
|
+
// every art pixel by construction.
|
|
1997
|
+
// 2. **One loop.** Spine's `hull` is one closed outline, and a layer is often
|
|
1998
|
+
// two islands or has a hole. So the kept cells are made one simply
|
|
1999
|
+
// connected region: holes filled, every other island bridged to the
|
|
2000
|
+
// largest by a straight run of cells, every diagonal pinch filled, and the
|
|
2001
|
+
// three repeated until a pass changes nothing. Every pass that does not
|
|
2002
|
+
// settle adds at least one cell, so the loop is bounded by the lattice and
|
|
2003
|
+
// needs no pass limit of its own. The added cells hold no art.
|
|
2004
|
+
// 3. **Triangles.** Vertices numbered in the order the kept cells first touch
|
|
2005
|
+
// them (cells row-major; corners top-left, top-right, bottom-right,
|
|
2006
|
+
// bottom-left), each cell two triangles with the diagonal alternating by
|
|
2007
|
+
// `(i + j) % 2`, so the lattice has no preferred shear. Emitted
|
|
2008
|
+
// counter-clockwise in Spine world, the winding every generator here
|
|
2009
|
+
// writes (GR02 holds the grid to it, CT15 every generator).
|
|
2010
|
+
// 4. **Outline first.** The boundary is walked from the edges used by exactly
|
|
2011
|
+
// one triangle, in first-seen order, and every interior vertex follows in
|
|
2012
|
+
// index order — the arrangement `checkHullOrder` requires.
|
|
2013
|
+
// 5. **Weights.** Per vertex and per candidate segment, `w = 1 / (d + r)^p`
|
|
2014
|
+
// where `d` is the distance to the segment's nearest point; a bone two
|
|
2015
|
+
// segments name keeps the larger; the strongest `maxBones` are kept and
|
|
2016
|
+
// normalised, a share under `minWeight` is dropped, and the rest are
|
|
2017
|
+
// normalised again. Ties keep the order the bones first appear in.
|
|
2018
|
+
|
|
2019
|
+
/** What the lattice step did, for the report and for the refusals. */
|
|
2020
|
+
export interface SegmentsLatticeReport {
|
|
2021
|
+
/** Cells across and down. */
|
|
2022
|
+
cols: number;
|
|
2023
|
+
rows: number;
|
|
2024
|
+
/** Cells holding an art pixel, and cells kept after the one-loop passes. */
|
|
2025
|
+
artCells: number;
|
|
2026
|
+
keptCells: number;
|
|
2027
|
+
/** Passes of hole-fill, island-join and pinch-fill until one changed nothing. */
|
|
2028
|
+
passes: number;
|
|
2029
|
+
/** 4-connected islands of art cells before the joins. */
|
|
2030
|
+
islands: number;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
export interface SegmentsLattice {
|
|
2034
|
+
/** Vertex positions in part-local pixels, y down, outline first. */
|
|
2035
|
+
points: Array<[number, number]>;
|
|
2036
|
+
uvs: number[];
|
|
2037
|
+
/** Counter-clockwise in Spine world. */
|
|
2038
|
+
triangles: number[];
|
|
2039
|
+
hullVertices: number;
|
|
2040
|
+
report: SegmentsLatticeReport;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/** `np.rint`: to the nearest integer, exact halves to even — the bridge's rounding. */
|
|
2044
|
+
function rintEven(x: number): number {
|
|
2045
|
+
const f = Math.floor(x);
|
|
2046
|
+
const frac = x - f;
|
|
2047
|
+
if (frac < 0.5) return f;
|
|
2048
|
+
if (frac > 0.5) return f + 1;
|
|
2049
|
+
return f % 2 === 0 ? f : f + 1;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/** Fill every background cell the lattice border cannot reach through 4-connected background. */
|
|
2053
|
+
function fillCellHoles(cells: Uint8Array, nx: number, ny: number): Uint8Array {
|
|
2054
|
+
const outside = new Uint8Array(nx * ny);
|
|
2055
|
+
const stack: number[] = [];
|
|
2056
|
+
const seed = (i: number): void => {
|
|
2057
|
+
if (cells[i] === 0 && outside[i] === 0) {
|
|
2058
|
+
outside[i] = 1;
|
|
2059
|
+
stack.push(i);
|
|
2060
|
+
}
|
|
2061
|
+
};
|
|
2062
|
+
for (let x = 0; x < nx; x++) {
|
|
2063
|
+
seed(x);
|
|
2064
|
+
seed((ny - 1) * nx + x);
|
|
2065
|
+
}
|
|
2066
|
+
for (let y = 0; y < ny; y++) {
|
|
2067
|
+
seed(y * nx);
|
|
2068
|
+
seed(y * nx + nx - 1);
|
|
2069
|
+
}
|
|
2070
|
+
while (stack.length > 0) {
|
|
2071
|
+
const i = stack.pop()!;
|
|
2072
|
+
const x = i % nx;
|
|
2073
|
+
const y = (i - x) / nx;
|
|
2074
|
+
if (x > 0) seed(i - 1);
|
|
2075
|
+
if (x < nx - 1) seed(i + 1);
|
|
2076
|
+
if (y > 0) seed(i - nx);
|
|
2077
|
+
if (y < ny - 1) seed(i + nx);
|
|
2078
|
+
}
|
|
2079
|
+
const out = new Uint8Array(nx * ny);
|
|
2080
|
+
for (let i = 0; i < out.length; i++) out[i] = outside[i] === 1 ? 0 : 1;
|
|
2081
|
+
return out;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
/**
|
|
2085
|
+
* 4-connected components of the kept cells, numbered in raster order of each
|
|
2086
|
+
* component's first cell, each with its cells in raster order.
|
|
2087
|
+
*/
|
|
2088
|
+
function cellIslands(cells: Uint8Array, nx: number, ny: number): Array<Array<[number, number]>> {
|
|
2089
|
+
const label = new Int32Array(nx * ny).fill(-1);
|
|
2090
|
+
const islands: Array<Array<[number, number]>> = [];
|
|
2091
|
+
for (let start = 0; start < cells.length; start++) {
|
|
2092
|
+
if (cells[start] === 0 || label[start] >= 0) continue;
|
|
2093
|
+
const id = islands.length;
|
|
2094
|
+
const stack = [start];
|
|
2095
|
+
label[start] = id;
|
|
2096
|
+
while (stack.length > 0) {
|
|
2097
|
+
const i = stack.pop()!;
|
|
2098
|
+
const x = i % nx;
|
|
2099
|
+
const y = (i - x) / nx;
|
|
2100
|
+
const visit = (j: number): void => {
|
|
2101
|
+
if (cells[j] !== 0 && label[j] < 0) {
|
|
2102
|
+
label[j] = id;
|
|
2103
|
+
stack.push(j);
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
if (x > 0) visit(i - 1);
|
|
2107
|
+
if (x < nx - 1) visit(i + 1);
|
|
2108
|
+
if (y > 0) visit(i - nx);
|
|
2109
|
+
if (y < ny - 1) visit(i + nx);
|
|
2110
|
+
}
|
|
2111
|
+
islands.push([]);
|
|
2112
|
+
}
|
|
2113
|
+
for (let i = 0; i < cells.length; i++) if (label[i] >= 0) islands[label[i]].push([(i - (i % nx)) / nx, i % nx]);
|
|
2114
|
+
return islands;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
/**
|
|
2118
|
+
* The lattice over one part's alpha: cells, one loop, triangles, outline first.
|
|
2119
|
+
*
|
|
2120
|
+
* Refuses a part that keeps no cell and a cell that is not a whole number of
|
|
2121
|
+
* pixels of at least 1; everything else about the geometry follows from the art.
|
|
2122
|
+
*/
|
|
2123
|
+
export function buildSegmentsLattice(input: { mask: AlphaMask; threshold: number; cell: number }): SegmentsLattice {
|
|
2124
|
+
const { mask, threshold, cell } = input;
|
|
2125
|
+
const { width: w, height: h } = mask;
|
|
2126
|
+
if (!Number.isInteger(cell) || cell < 1) {
|
|
2127
|
+
throw new MeshError(`"cell" is ${JSON.stringify(cell)}; it is the lattice's square, a whole number of pixels of at least 1`);
|
|
2128
|
+
}
|
|
2129
|
+
if (!Number.isInteger(threshold) || threshold < 1 || threshold > 255) {
|
|
2130
|
+
throw new MeshError(`the alpha threshold must be a whole number in 1..255, got ${threshold}`);
|
|
2131
|
+
}
|
|
2132
|
+
const nx = Math.ceil(w / cell);
|
|
2133
|
+
const ny = Math.ceil(h / cell);
|
|
2134
|
+
const xs = Array.from({ length: nx + 1 }, (_, i) => Math.min(i * cell, w));
|
|
2135
|
+
const ys = Array.from({ length: ny + 1 }, (_, j) => Math.min(j * cell, h));
|
|
2136
|
+
let cells: Uint8Array = new Uint8Array(nx * ny);
|
|
2137
|
+
let artCells = 0;
|
|
2138
|
+
for (let j = 0; j < ny; j++) {
|
|
2139
|
+
for (let i = 0; i < nx; i++) {
|
|
2140
|
+
let any = 0;
|
|
2141
|
+
for (let y = ys[j]; y < ys[j + 1] && any === 0; y++) {
|
|
2142
|
+
for (let x = xs[i]; x < xs[i + 1]; x++) {
|
|
2143
|
+
if (mask.alpha[y * w + x] >= threshold) {
|
|
2144
|
+
any = 1;
|
|
2145
|
+
break;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
cells[j * nx + i] = any;
|
|
2150
|
+
artCells += any;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
if (artCells === 0) {
|
|
2154
|
+
throw new MeshError(
|
|
2155
|
+
`no pixel of the ${w}x${h} part reaches alpha ${threshold}, so a lattice at cell ${cell} keeps no cell — ` +
|
|
2156
|
+
'there is no art to cover. Lower "alpha", or point at the image this mesh is meant to draw',
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
const islands = cellIslands(cells, nx, ny).length;
|
|
2160
|
+
|
|
2161
|
+
// 2. one loop, until a pass changes nothing.
|
|
2162
|
+
let passes = 0;
|
|
2163
|
+
for (;;) {
|
|
2164
|
+
passes++;
|
|
2165
|
+
cells = fillCellHoles(cells, nx, ny);
|
|
2166
|
+
const found = cellIslands(cells, nx, ny);
|
|
2167
|
+
if (found.length > 1) {
|
|
2168
|
+
let main = 0;
|
|
2169
|
+
for (let k = 1; k < found.length; k++) if (found[k].length > found[main].length) main = k;
|
|
2170
|
+
const mainCells = found[main];
|
|
2171
|
+
for (let k = 0; k < found.length; k++) {
|
|
2172
|
+
if (k === main) continue;
|
|
2173
|
+
const other = found[k];
|
|
2174
|
+
let best = Infinity;
|
|
2175
|
+
let a = 0;
|
|
2176
|
+
let b = 0;
|
|
2177
|
+
for (let p = 0; p < other.length; p++) {
|
|
2178
|
+
for (let q = 0; q < mainCells.length; q++) {
|
|
2179
|
+
const dj = other[p][0] - mainCells[q][0];
|
|
2180
|
+
const di = other[p][1] - mainCells[q][1];
|
|
2181
|
+
const d = dj * dj + di * di;
|
|
2182
|
+
if (d < best) {
|
|
2183
|
+
best = d;
|
|
2184
|
+
a = p;
|
|
2185
|
+
b = q;
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
const [j0, i0] = other[a];
|
|
2190
|
+
const [j1, i1] = mainCells[b];
|
|
2191
|
+
const steps = 2 * (Math.abs(j1 - j0) + Math.abs(i1 - i0)) + 2;
|
|
2192
|
+
const stride = 1 / (steps - 1);
|
|
2193
|
+
for (let s = 0; s < steps; s++) {
|
|
2194
|
+
const t = s === steps - 1 ? 1 : s * stride;
|
|
2195
|
+
cells[rintEven(j0 + (j1 - j0) * t) * nx + i0] = 1;
|
|
2196
|
+
cells[j1 * nx + rintEven(i0 + (i1 - i0) * t)] = 1;
|
|
2197
|
+
cells[rintEven(j0 + (j1 - j0) * t) * nx + rintEven(i0 + (i1 - i0) * t)] = 1;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
continue;
|
|
2201
|
+
}
|
|
2202
|
+
const snap = new Uint8Array(cells);
|
|
2203
|
+
const at = (j: number, i: number): number => snap[j * nx + i];
|
|
2204
|
+
let pinch = false;
|
|
2205
|
+
for (let j = 0; j < ny - 1; j++) {
|
|
2206
|
+
for (let i = 0; i < nx - 1; i++) {
|
|
2207
|
+
if (at(j, i) && at(j + 1, i + 1) && !at(j, i + 1) && !at(j + 1, i)) {
|
|
2208
|
+
cells[j * nx + i + 1] = 1;
|
|
2209
|
+
pinch = true;
|
|
2210
|
+
} else if (at(j, i + 1) && at(j + 1, i) && !at(j, i) && !at(j + 1, i + 1)) {
|
|
2211
|
+
cells[j * nx + i] = 1;
|
|
2212
|
+
pinch = true;
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
if (!pinch) break;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// 3. triangles, in the lattice's own winding (top-left, top-right, bottom-right).
|
|
2220
|
+
const vid = new Int32Array((nx + 1) * (ny + 1)).fill(-1);
|
|
2221
|
+
const pts: Array<[number, number]> = [];
|
|
2222
|
+
const tri: number[] = [];
|
|
2223
|
+
let keptCells = 0;
|
|
2224
|
+
for (let j = 0; j < ny; j++) {
|
|
2225
|
+
for (let i = 0; i < nx; i++) {
|
|
2226
|
+
if (cells[j * nx + i] === 0) continue;
|
|
2227
|
+
keptCells++;
|
|
2228
|
+
const c: number[] = [];
|
|
2229
|
+
for (const [jj, ii] of [
|
|
2230
|
+
[j, i],
|
|
2231
|
+
[j, i + 1],
|
|
2232
|
+
[j + 1, i + 1],
|
|
2233
|
+
[j + 1, i],
|
|
2234
|
+
]) {
|
|
2235
|
+
const k = jj * (nx + 1) + ii;
|
|
2236
|
+
if (vid[k] < 0) {
|
|
2237
|
+
vid[k] = pts.length;
|
|
2238
|
+
pts.push([xs[ii], ys[jj]]);
|
|
2239
|
+
}
|
|
2240
|
+
c.push(vid[k]);
|
|
2241
|
+
}
|
|
2242
|
+
if ((i + j) % 2 === 0) tri.push(c[0], c[1], c[2], c[0], c[2], c[3]);
|
|
2243
|
+
else tri.push(c[0], c[1], c[3], c[1], c[2], c[3]);
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// 4. the outline, walked from the boundary edges in first-seen order.
|
|
2248
|
+
const edgeUses = new Map<number, { a: number; b: number; n: number }>();
|
|
2249
|
+
const V = pts.length;
|
|
2250
|
+
for (let t = 0; t < tri.length; t += 3) {
|
|
2251
|
+
for (const [p, q] of [
|
|
2252
|
+
[tri[t], tri[t + 1]],
|
|
2253
|
+
[tri[t + 1], tri[t + 2]],
|
|
2254
|
+
[tri[t + 2], tri[t]],
|
|
2255
|
+
]) {
|
|
2256
|
+
const a = Math.min(p, q);
|
|
2257
|
+
const b = Math.max(p, q);
|
|
2258
|
+
const key = a * V + b;
|
|
2259
|
+
const e = edgeUses.get(key);
|
|
2260
|
+
if (e === undefined) edgeUses.set(key, { a, b, n: 1 });
|
|
2261
|
+
else e.n++;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
const adj = new Map<number, number[]>();
|
|
2265
|
+
const link = (p: number, q: number): void => {
|
|
2266
|
+
const list = adj.get(p);
|
|
2267
|
+
if (list === undefined) adj.set(p, [q]);
|
|
2268
|
+
else list.push(q);
|
|
2269
|
+
};
|
|
2270
|
+
for (const { a, b, n } of edgeUses.values()) {
|
|
2271
|
+
if (n !== 1) continue;
|
|
2272
|
+
link(a, b);
|
|
2273
|
+
link(b, a);
|
|
2274
|
+
}
|
|
2275
|
+
const order: number[] = [];
|
|
2276
|
+
const seen = new Set<number>();
|
|
2277
|
+
for (const s of adj.keys()) {
|
|
2278
|
+
if (seen.has(s)) continue;
|
|
2279
|
+
let cur = s;
|
|
2280
|
+
let prev = -1;
|
|
2281
|
+
while (!seen.has(cur)) {
|
|
2282
|
+
seen.add(cur);
|
|
2283
|
+
order.push(cur);
|
|
2284
|
+
const next = (adj.get(cur) ?? []).filter((v) => v !== prev && !seen.has(v));
|
|
2285
|
+
if (next.length === 0) break;
|
|
2286
|
+
prev = cur;
|
|
2287
|
+
cur = next[0];
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
const hullVertices = order.length;
|
|
2291
|
+
for (let v = 0; v < V; v++) if (!seen.has(v)) order.push(v);
|
|
2292
|
+
const remap = new Int32Array(V);
|
|
2293
|
+
order.forEach((v, i) => (remap[v] = i));
|
|
2294
|
+
const points = order.map((v) => pts[v]);
|
|
2295
|
+
// Emitted counter-clockwise in Spine world: the lattice's corners were taken
|
|
2296
|
+
// clockwise on the y-down page, so each triangle's last two are swapped.
|
|
2297
|
+
const triangles: number[] = [];
|
|
2298
|
+
for (let t = 0; t < tri.length; t += 3) triangles.push(remap[tri[t]], remap[tri[t + 2]], remap[tri[t + 1]]);
|
|
2299
|
+
const uvs: number[] = [];
|
|
2300
|
+
for (const [x, y] of points) uvs.push(r6(x / w), r6(y / h));
|
|
2301
|
+
return {
|
|
2302
|
+
points,
|
|
2303
|
+
uvs,
|
|
2304
|
+
triangles,
|
|
2305
|
+
hullVertices,
|
|
2306
|
+
report: { cols: nx, rows: ny, artCells, keptCells, passes, islands },
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/** One candidate segment, in Spine world, and which of the mesh's bones it belongs to. */
|
|
2311
|
+
export interface WorldSegment {
|
|
2312
|
+
/** Index into the mesh's distinct bone list. */
|
|
2313
|
+
bone: number;
|
|
2314
|
+
a: readonly [number, number];
|
|
2315
|
+
b: readonly [number, number];
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
/** The four numbers the falloff reads, every one of them from the spec. */
|
|
2319
|
+
export interface SegmentsFalloff {
|
|
2320
|
+
power: number;
|
|
2321
|
+
radius: number;
|
|
2322
|
+
maxBones: number;
|
|
2323
|
+
minWeight: number;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
/**
|
|
2327
|
+
* Distance from `p` to the segment `a -> b`, a zero-length segment being its point.
|
|
2328
|
+
*
|
|
2329
|
+
* `sqrt(dx*dx + dy*dy)` rather than `Math.hypot`, whose last bit can differ:
|
|
2330
|
+
* a weight is written on a 6-decimal grid, and that is where one ulp becomes a
|
|
2331
|
+
* different number in the file.
|
|
2332
|
+
*/
|
|
2333
|
+
export function segmentDistance(p: readonly [number, number], a: readonly [number, number], b: readonly [number, number]): number {
|
|
2334
|
+
const abx = b[0] - a[0];
|
|
2335
|
+
const aby = b[1] - a[1];
|
|
2336
|
+
const len2 = abx * abx + aby * aby;
|
|
2337
|
+
let t = 0;
|
|
2338
|
+
if (len2 > 0) {
|
|
2339
|
+
t = ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / len2;
|
|
2340
|
+
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
2341
|
+
}
|
|
2342
|
+
const dx = p[0] - (a[0] + t * abx);
|
|
2343
|
+
const dy = p[1] - (a[1] + t * aby);
|
|
2344
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/**
|
|
2348
|
+
* The shares on one vertex, strongest first and closing at exactly 1 on the
|
|
2349
|
+
* generator's 6-decimal grid — or, when every share the kept bones carry is
|
|
2350
|
+
* under `minWeight`, the unrounded shares that were dropped, for the refusal to
|
|
2351
|
+
* print. A vertex is never given a bone the falloff did not choose.
|
|
2352
|
+
*/
|
|
2353
|
+
export function segmentShares(
|
|
2354
|
+
p: readonly [number, number],
|
|
2355
|
+
segments: readonly WorldSegment[],
|
|
2356
|
+
falloff: SegmentsFalloff,
|
|
2357
|
+
): { weights: MeshVertexWeight[] } | { dropped: Array<{ bone: number; share: number }> } {
|
|
2358
|
+
const byBone = new Map<number, number>();
|
|
2359
|
+
for (const s of segments) {
|
|
2360
|
+
const reach = segmentDistance(p, s.a, s.b) + falloff.radius;
|
|
2361
|
+
const w = 1 / Math.pow(reach, falloff.power);
|
|
2362
|
+
const was = byBone.get(s.bone);
|
|
2363
|
+
byBone.set(s.bone, was === undefined ? w : Math.max(was, w));
|
|
2364
|
+
}
|
|
2365
|
+
// `sort` is stable, so equal pulls keep the order the bones first appeared in.
|
|
2366
|
+
const top = [...byBone.entries()].sort((x, y) => y[1] - x[1]).slice(0, falloff.maxBones);
|
|
2367
|
+
let sum = 0;
|
|
2368
|
+
for (const [, v] of top) sum += v;
|
|
2369
|
+
const shares = top.map(([bone, v]) => ({ bone, share: v / sum }));
|
|
2370
|
+
const kept = shares.filter((e) => e.share >= falloff.minWeight);
|
|
2371
|
+
if (kept.length === 0) return { dropped: shares };
|
|
2372
|
+
let sum2 = 0;
|
|
2373
|
+
for (const e of kept) sum2 += e.share;
|
|
2374
|
+
const weights: MeshVertexWeight[] = [];
|
|
2375
|
+
let others = 0;
|
|
2376
|
+
kept.forEach((e, k) => {
|
|
2377
|
+
const weight = k === kept.length - 1 ? r6(1 - others) : r6(e.share / sum2);
|
|
2378
|
+
others += weight;
|
|
2379
|
+
weights.push({ bone: 'control', control: e.bone, weight });
|
|
2380
|
+
});
|
|
2381
|
+
return { weights };
|
|
2382
|
+
}
|