rig-c 0.0.0-stage → 2.20.4

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.
Files changed (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. 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
+ }