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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
package/src/atlas.ts
ADDED
|
@@ -0,0 +1,2870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The atlas as a data structure: read one, pack one, write one.
|
|
3
|
+
*
|
|
4
|
+
* Until issue #4 rigc had exactly one atlas shape — one part, one page, region
|
|
5
|
+
* covers the page — and one function that wrote it (`buildAtlasText` in
|
|
6
|
+
* [`src/compile.ts`](compile.ts)). That is why the npm keyword `atlas` set an
|
|
7
|
+
* expectation the tool did not meet: nine loose PNGs compiled to `pages=9
|
|
8
|
+
* regions=9`, which is correct, valid, and not what anybody means by an atlas.
|
|
9
|
+
*
|
|
10
|
+
* This module holds the three pieces that were missing, and nothing else:
|
|
11
|
+
*
|
|
12
|
+
* * `parseAtlasText` — the format read back in, so an atlas somebody else
|
|
13
|
+
* packed can be an INPUT (`build --atlas-in`);
|
|
14
|
+
* * `packAtlas` — loose part PNGs arranged onto shared pages (`build --pack`);
|
|
15
|
+
* * `writeAtlasText` — the one emitter for both shapes, so the unpacked
|
|
16
|
+
* default and a packed page are written by the same code.
|
|
17
|
+
*
|
|
18
|
+
* ## 🚨 Two invariants this file exists to keep
|
|
19
|
+
*
|
|
20
|
+
* **Packing is an OUTPUT arrangement, never an input contract.** Sizes are still
|
|
21
|
+
* measured from the loose PNGs by `readPngInfo` before anything here runs, the
|
|
22
|
+
* skeleton is compiled from those measurements, and `--pack` changes only where
|
|
23
|
+
* the bytes sit on a page. A packed build's `skeleton.json` is byte-identical to
|
|
24
|
+
* the unpacked one's — that is a property of this split, and the selftest
|
|
25
|
+
* asserts it rather than trusting it.
|
|
26
|
+
*
|
|
27
|
+
* **A packed region is a lossless copy.** Nothing here resamples, scales, trims
|
|
28
|
+
* or rotates: a region's pixels are written to the page unchanged, and the
|
|
29
|
+
* selftest lifts every region back off its page and compares it byte for byte
|
|
30
|
+
* against the loose PNG (`PK02`). See `extrudeCell` for the one thing that has to
|
|
31
|
+
* be ADDED to the page for the render to agree as well, and `PACK_NO_ROTATE` for
|
|
32
|
+
* the field this deliberately does not use.
|
|
33
|
+
*
|
|
34
|
+
* Under `shape: 'polygon'` (issue #1099) the copy is lossless where a region
|
|
35
|
+
* can be SAMPLED: two rectangles may overlap where neither region draws, so a
|
|
36
|
+
* mesh region's rectangle outside its hull may carry a neighbour's texels, and
|
|
37
|
+
* the claim the selftest holds there is that every texel within a tap of what
|
|
38
|
+
* a region draws is its own (`PK79`). See `packAtlas`, *`shape: 'polygon'`*.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ **The rendered pictures are equal to within one least significant bit, not
|
|
41
|
+
* bit-for-bit, and the difference is arithmetic rather than texels.** Measured: 0
|
|
42
|
+
* to 480 channel samples of 7 to 21 million on the three public fixtures, worst
|
|
43
|
+
* difference **1**, against 22,000 to 60,000 samples and a worst difference of
|
|
44
|
+
* **77** when the gutter is removed — and byte-identical on eleven of the
|
|
45
|
+
* repository's thirteen rigs across 1,101 frames. The two that are not are the
|
|
46
|
+
* two with meshes.
|
|
47
|
+
*
|
|
48
|
+
* 🔬 **Where that last bit is lost, measured (issue #266, follow-up 1).** This
|
|
49
|
+
* paragraph used to say the cause was rigc's own sampling coordinate — `fl(regionX
|
|
50
|
+
* + fl(s · width))` failing to be exact once `regionX > 0` — and that the repair
|
|
51
|
+
* was to sample in region-local coordinates and add the integer page origin to the
|
|
52
|
+
* tap indices. **Both halves are wrong, and the second is unreachable.** Poses of
|
|
53
|
+
* the loose and the packed build of one skeleton, compared coordinate by
|
|
54
|
+
* coordinate as `u · pageWidth − regionX` against the loose `u · pageWidth`:
|
|
55
|
+
*
|
|
56
|
+
* * on every **region** attachment the difference is **exactly 0**. `regionX`,
|
|
57
|
+
* `regionWidth` and `pageWidth` are integers and the page is a power of two,
|
|
58
|
+
* so `u · pageWidth` recovers `regionX + localTexel` with nothing lost —
|
|
59
|
+
* which is why the eleven region-only rigs are already byte-identical, and
|
|
60
|
+
* why `PK18` can assert exactness rather than a bound. That holds for the
|
|
61
|
+
* default power-of-two page only: `pageEdges: 'free'` gives it up, and the
|
|
62
|
+
* region attachments of a free page sit at the mesh's bound of 1 (`PK72`,
|
|
63
|
+
* and `packAtlas`, *free*);
|
|
64
|
+
* * on a **mesh** it is **not** 0 — 88 of 88 coordinates on `gallery/squash`'s
|
|
65
|
+
* ball, worst 3.15e-5 texels — because `spine-core` stores mesh page UVs in a
|
|
66
|
+
* **`Float32Array`**. `MeshAttachment.updateRegion` computes `u +
|
|
67
|
+
* regionUVs[i] · width` and rounds the whole thing to float32, so the low bits
|
|
68
|
+
* of the scaled coordinate are gone **before rigc reads the array**. The
|
|
69
|
+
* counterfactual settles which term does it: the same region at page origin
|
|
70
|
+
* `x = 0` still lands 9.5e-7 texels off the loose value, so it is the `f32(u ·
|
|
71
|
+
* regionWidth / pageWidth)` scaling and not the origin addition.
|
|
72
|
+
*
|
|
73
|
+
* ⇒ **No change to [`src/render.ts`](render.ts) can recover it**, because it reads
|
|
74
|
+
* `piece.uvs` and the information is not in there. The only routes are for rigc to
|
|
75
|
+
* re-derive mesh page UVs from `MeshAttachment.regionUVs` in double precision —
|
|
76
|
+
* a second opinion about the runtime's own trim and rotation mapping, which
|
|
77
|
+
* `src/render.ts` refuses by name (see `artUvsOf`) — or one part per page, which
|
|
78
|
+
* is the unpacked convention. And the first would be worse than the residual: the
|
|
79
|
+
* renderer is the yardstick `check` measures a candidate against *because* it
|
|
80
|
+
* draws what a runtime draws, and a runtime playing this atlas gets the float32
|
|
81
|
+
* numbers. Making two rigc renders agree by disagreeing with the runtime is the
|
|
82
|
+
* wrong trade. So the bound stays a bound, `PK18` attributes it, and the follow-up
|
|
83
|
+
* is closed as measured rather than done.
|
|
84
|
+
*
|
|
85
|
+
* ## Why a second parser for a format `spine-core` already parses
|
|
86
|
+
*
|
|
87
|
+
* `src/compile.ts` must stay independent of the runtime — that is what keeps the
|
|
88
|
+
* compiler and the gate from checking each other's assumptions — so the importer
|
|
89
|
+
* cannot reach for `TextureAtlas`. The reader below therefore states, for every
|
|
90
|
+
* field the format has, what the runtime reads it as — the page and region
|
|
91
|
+
* fields of `dist/TextureAtlas.js` — including the readings that look like
|
|
92
|
+
* mistakes and are not: the page name is trimmed and a region name is the RAW
|
|
93
|
+
* line, a blank line closes a page block, an entry holds at most four values,
|
|
94
|
+
* and `originalWidth/Height` fall back to `width/height` only when BOTH are
|
|
95
|
+
* zero.
|
|
96
|
+
*
|
|
97
|
+
* A second opinion about a format is a liability, so it is measured rather than
|
|
98
|
+
* asserted: `PKR01` parses every `.atlas` in the example corpus with both this
|
|
99
|
+
* reader and `spine-core`'s `TextureAtlas` and compares every page's name, size
|
|
100
|
+
* and `pma` and every region's eleven fields — 10 atlas files, 132 regions, 3
|
|
101
|
+
* of them rotated, 0 fields apart when this was written (issue #1015). If they
|
|
102
|
+
* ever disagree, that control goes red and this file is wrong.
|
|
103
|
+
*/
|
|
104
|
+
import { CompileError } from './errors.ts';
|
|
105
|
+
import { Plate, readPlate } from '../tools/plate.ts';
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// reading
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** One region of a parsed atlas, in `TextureAtlasRegion`'s own field names. */
|
|
112
|
+
export interface AtlasRegion {
|
|
113
|
+
/**
|
|
114
|
+
* The region's name, exactly as `TextureAtlas` takes it: the raw line,
|
|
115
|
+
* UNTRIMMED. Every consumer that joins on a name has to trim it itself, and
|
|
116
|
+
* `regionKey` in [`src/render.ts`](render.ts) is the precedent — a file
|
|
117
|
+
* written with CRLF would otherwise name different regions from the same text.
|
|
118
|
+
*/
|
|
119
|
+
name: string;
|
|
120
|
+
/** Page-space left edge of the packed rectangle, x right from the page's left. */
|
|
121
|
+
x: number;
|
|
122
|
+
/** Page-space top edge, y DOWN from the page's top. */
|
|
123
|
+
y: number;
|
|
124
|
+
/**
|
|
125
|
+
* The kept rectangle's width, in the DRAWING's orientation.
|
|
126
|
+
*
|
|
127
|
+
* ⚠️ Not the page footprint. At `rotate: 90` / `270` the rectangle on the page
|
|
128
|
+
* is `height x width`; `TextureAtlas` transposes for `u2/v2` at 90 and not at
|
|
129
|
+
* 270, which is a bug in the runtime and the reason every reader here derives
|
|
130
|
+
* the rectangle rather than reading those two numbers. `pageFootprint` below
|
|
131
|
+
* is that derivation, spelled once. This field is the atlas's own meaning of
|
|
132
|
+
* `bounds`, untouched.
|
|
133
|
+
*/
|
|
134
|
+
width: number;
|
|
135
|
+
height: number;
|
|
136
|
+
/** Trim offset from the drawing's LEFT edge. */
|
|
137
|
+
offsetX: number;
|
|
138
|
+
/** Trim offset from the drawing's BOTTOM edge — art space runs the other way. */
|
|
139
|
+
offsetY: number;
|
|
140
|
+
/** The untrimmed drawing's size: what an attachment's width/height means. */
|
|
141
|
+
originalWidth: number;
|
|
142
|
+
originalHeight: number;
|
|
143
|
+
/** 0, 90, 180 or 270. A `rotate: true` line reads as 90, which is the format's older spelling. */
|
|
144
|
+
degrees: number;
|
|
145
|
+
/** Sequence index, or 0 for a region that is not part of one. */
|
|
146
|
+
index: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One page of a parsed atlas, with the regions that sit on it. */
|
|
150
|
+
export interface AtlasPage {
|
|
151
|
+
/** The page name, trimmed — the image path as seen from the atlas file. */
|
|
152
|
+
name: string;
|
|
153
|
+
/** Index into the text's line array of the line the name was read from. */
|
|
154
|
+
nameLine: number;
|
|
155
|
+
width: number;
|
|
156
|
+
height: number;
|
|
157
|
+
pma: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* The page's `scale:` line — how much SMALLER these texels are than the
|
|
160
|
+
* drawings they were packed from — or `1` when the page declares none.
|
|
161
|
+
*
|
|
162
|
+
* ⚠️ The second field on this interface that `TextureAtlas` does not have, and
|
|
163
|
+
* for the same kind of reason as `nameLine`: the runtime drops `scale:` because
|
|
164
|
+
* an attachment's size comes out of the skeleton JSON (`region.width =
|
|
165
|
+
* map.width * scale` in `SkeletonJson`, no atlas involved), so a player never
|
|
166
|
+
* needs it. An IMPORTER does. `--atlas-in` derives an attachment's size from
|
|
167
|
+
* the region when the rig spec declares none, and the region's
|
|
168
|
+
* `originalWidth/Height` are in the page's own texels — at `scale: 0.5` they
|
|
169
|
+
* are half the drawing. Reading the line here is what lets the importer state
|
|
170
|
+
* the drawing's size instead of the pack's (issue #267); dropping it is what
|
|
171
|
+
* made an imported `scale: 0.5` pack halve every attachment in silence.
|
|
172
|
+
*
|
|
173
|
+
* `atlasScales` in [`src/render.ts`](render.ts) reads the same field off the
|
|
174
|
+
* raw text for the MAE report, and the selftest holds the two readers to the
|
|
175
|
+
* same answer on every corpus atlas.
|
|
176
|
+
*/
|
|
177
|
+
scale: number;
|
|
178
|
+
regions: AtlasRegion[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ParsedAtlas {
|
|
182
|
+
pages: AtlasPage[];
|
|
183
|
+
/** Every region on every page, in file order — `TextureAtlas.regions`'s order. */
|
|
184
|
+
regions: AtlasRegion[];
|
|
185
|
+
/** The text split the way `TextureAtlas` splits it, for `rewritePageNames`. */
|
|
186
|
+
lines: string[];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The rectangle a region occupies **on its page** — which is not the rectangle
|
|
191
|
+
* its `bounds:` line states.
|
|
192
|
+
*
|
|
193
|
+
* `bounds:` is the kept rectangle in the DRAWING's orientation, so a packer that
|
|
194
|
+
* turned the drawing a quarter turn to fit it wrote `width x height` for a
|
|
195
|
+
* region that covers `height x width` of the page. 180 turns nothing: the
|
|
196
|
+
* footprint is the drawing's own way round at 0 and at 180, and transposed at 90
|
|
197
|
+
* and at 270.
|
|
198
|
+
*
|
|
199
|
+
* ## Why this is one function and was four (issue #579)
|
|
200
|
+
*
|
|
201
|
+
* Four readers in this tree want exactly this rectangle — `windowOf` in
|
|
202
|
+
* [`src/render.ts`](render.ts) fences a substituted piece with it,
|
|
203
|
+
* `resolveFromAtlas` in [`src/compile.ts`](compile.ts) refuses a region that
|
|
204
|
+
* runs off its page by it, and `A06` and `A19` in [`src/validate.ts`](validate.ts)
|
|
205
|
+
* measure a shared page's tiling and open one region's own texels with it — and
|
|
206
|
+
* two of the four transposed at 90 **only**, under a comment that read *"spine-core
|
|
207
|
+
* transposes a region's extent at 90 and not at 270 when it derives the UVs, so
|
|
208
|
+
* the rectangle ON THE PAGE follows the same rule"*.
|
|
209
|
+
*
|
|
210
|
+
* The premise is true and the conclusion does not follow, because the two are
|
|
211
|
+
* different quantities:
|
|
212
|
+
*
|
|
213
|
+
* * what `TextureAtlas` transposes at 90 and not at 270 is `u2`/`v2`
|
|
214
|
+
* (spine-core 4.3.13, `dist/TextureAtlas.js:162-171`) — so at 270 those two
|
|
215
|
+
* numbers do describe a rectangle the page does not have;
|
|
216
|
+
* * but `MeshAttachment.computeUVs` (`dist/attachments/MeshAttachment.js:118-162`)
|
|
217
|
+
* never reads `u2`/`v2` for an atlas region. It branches on `degrees` and
|
|
218
|
+
* derives the span from `originalWidth`/`originalHeight`, transposed at 90
|
|
219
|
+
* **and** at 270 alike. That is the routine that says where a region's texels
|
|
220
|
+
* are, and `extractRegion` below is its inverse.
|
|
221
|
+
*
|
|
222
|
+
* ⚠️ The consequence was not confined to a printed number, which is what the
|
|
223
|
+
* card assumed. A19 opens a shared page and scans one region's own rectangle for
|
|
224
|
+
* a transparent texel: at 270 it scanned `width x height` where the drawing
|
|
225
|
+
* occupies `height x width`, ran off the part into the transparent gutter, found
|
|
226
|
+
* its texel there and **named nothing**. Two fully opaque parts on one turned
|
|
227
|
+
* page — the exact defect A19 exists for — were measured green at `rotate: 270`
|
|
228
|
+
* and red at 0, 90 and 180.
|
|
229
|
+
*
|
|
230
|
+
* Structurally typed rather than taking `AtlasRegion`, because two of the four
|
|
231
|
+
* callers hold spine-core's `TextureAtlasRegion` instead and this file
|
|
232
|
+
* deliberately does not link the runtime.
|
|
233
|
+
*/
|
|
234
|
+
export function pageFootprint(region: { width: number; height: number; degrees: number }): {
|
|
235
|
+
width: number;
|
|
236
|
+
height: number;
|
|
237
|
+
} {
|
|
238
|
+
const turned = region.degrees === 90 || region.degrees === 270;
|
|
239
|
+
return turned
|
|
240
|
+
? { width: region.height, height: region.width }
|
|
241
|
+
: { width: region.width, height: region.height };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `TextureAtlasReader.readEntry`, to the value.
|
|
246
|
+
*
|
|
247
|
+
* Returns the number of values, 0 when the line is blank or carries no colon —
|
|
248
|
+
* which is the signal the caller uses to decide "this is a name line, not a
|
|
249
|
+
* field". Four values maximum, because that is where the runtime stops (`if (i
|
|
250
|
+
* === 4) return 4`) and a fifth would silently mean something here that it does
|
|
251
|
+
* not mean there.
|
|
252
|
+
*
|
|
253
|
+
* Exported for `src/repack.ts` (issue #1169), which reads the keys a page and a
|
|
254
|
+
* region state — the ones this parser drops (`filter`, `format`, `repeat`,
|
|
255
|
+
* `split`, `pad`) included — with this function rather than a second spelling
|
|
256
|
+
* of the format, and holds what it walked to `parseAtlasText`'s regions.
|
|
257
|
+
*/
|
|
258
|
+
export function readEntry(line: string | null): { key: string; values: string[] } | null {
|
|
259
|
+
if (line === null) return null;
|
|
260
|
+
const trimmed = line.trim();
|
|
261
|
+
if (trimmed.length === 0) return null;
|
|
262
|
+
const colon = trimmed.indexOf(':');
|
|
263
|
+
if (colon === -1) return null;
|
|
264
|
+
const key = trimmed.slice(0, colon).trim();
|
|
265
|
+
const values: string[] = [];
|
|
266
|
+
let lastMatch = colon + 1;
|
|
267
|
+
for (;;) {
|
|
268
|
+
const comma = trimmed.indexOf(',', lastMatch);
|
|
269
|
+
if (comma === -1) {
|
|
270
|
+
values.push(trimmed.slice(lastMatch).trim());
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
values.push(trimmed.slice(lastMatch, comma).trim());
|
|
274
|
+
lastMatch = comma + 1;
|
|
275
|
+
if (values.length === 4) break;
|
|
276
|
+
}
|
|
277
|
+
return { key, values };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `parseInt` with the runtime's own tolerance: a bad number reads as NaN there too. */
|
|
281
|
+
function int(text: string | undefined): number {
|
|
282
|
+
return parseInt(text ?? '', 10);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Read an atlas file's text into pages and regions.
|
|
287
|
+
*
|
|
288
|
+
* The format as the runtime reads it, field for field, and deliberately a dull
|
|
289
|
+
* reader — every branch below states a reading `PKR01` holds against
|
|
290
|
+
* `TextureAtlas`'s parse of the corpus (see the header). Two additions, both of
|
|
291
|
+
* them fields a PLAYER has no use for and an IMPORTER does: `nameLine`, which
|
|
292
|
+
* `rewritePageNames` needs, and the page's `scale:`, which is what turns a
|
|
293
|
+
* region's texels back into the drawing's own size (`AtlasPage.scale`).
|
|
294
|
+
*/
|
|
295
|
+
export function parseAtlasText(text: string): ParsedAtlas {
|
|
296
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
297
|
+
let at = 0;
|
|
298
|
+
const readLine = (): string | null => (at >= lines.length ? null : lines[at++]);
|
|
299
|
+
|
|
300
|
+
const pages: AtlasPage[] = [];
|
|
301
|
+
const regions: AtlasRegion[] = [];
|
|
302
|
+
|
|
303
|
+
let line = readLine();
|
|
304
|
+
// Ignore empty lines before the first entry.
|
|
305
|
+
while (line !== null && line.trim().length === 0) line = readLine();
|
|
306
|
+
// Header entries, which the runtime silently ignores. A first line with no
|
|
307
|
+
// colon IS the first page name and ends this loop without being consumed.
|
|
308
|
+
while (line !== null && line.trim().length > 0 && readEntry(line) !== null) line = readLine();
|
|
309
|
+
|
|
310
|
+
let page: AtlasPage | null = null;
|
|
311
|
+
for (;;) {
|
|
312
|
+
if (line === null) break;
|
|
313
|
+
if (line.trim().length === 0) {
|
|
314
|
+
page = null;
|
|
315
|
+
line = readLine();
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!page) {
|
|
319
|
+
page = { name: line.trim(), nameLine: at - 1, width: 0, height: 0, pma: false, scale: 1, regions: [] };
|
|
320
|
+
for (;;) {
|
|
321
|
+
line = readLine();
|
|
322
|
+
const entry = readEntry(line);
|
|
323
|
+
if (entry === null) break;
|
|
324
|
+
if (entry.key === 'size') {
|
|
325
|
+
page.width = int(entry.values[0]);
|
|
326
|
+
page.height = int(entry.values[1]);
|
|
327
|
+
} else if (entry.key === 'pma') {
|
|
328
|
+
page.pma = entry.values[0] === 'true';
|
|
329
|
+
} else if (entry.key === 'scale') {
|
|
330
|
+
// The one key this reader takes that the runtime's `pageFields` does
|
|
331
|
+
// not — see `AtlasPage.scale`. A value that is not a positive finite
|
|
332
|
+
// number leaves the default of 1 rather than poisoning every size
|
|
333
|
+
// derived from it: `scale: 0` would divide the pack into infinity, and
|
|
334
|
+
// a page whose own scale line is unreadable is a page whose texels are
|
|
335
|
+
// the only measurement left.
|
|
336
|
+
const value = Number(entry.values[0]);
|
|
337
|
+
if (Number.isFinite(value) && value > 0) page.scale = value;
|
|
338
|
+
}
|
|
339
|
+
// `format`, `filter` and `repeat` are read by the runtime into fields no
|
|
340
|
+
// consumer here has; they pass through `rewritePageNames` untouched.
|
|
341
|
+
}
|
|
342
|
+
pages.push(page);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const region: AtlasRegion = {
|
|
346
|
+
name: line,
|
|
347
|
+
x: 0,
|
|
348
|
+
y: 0,
|
|
349
|
+
width: 0,
|
|
350
|
+
height: 0,
|
|
351
|
+
offsetX: 0,
|
|
352
|
+
offsetY: 0,
|
|
353
|
+
originalWidth: 0,
|
|
354
|
+
originalHeight: 0,
|
|
355
|
+
degrees: 0,
|
|
356
|
+
index: 0,
|
|
357
|
+
};
|
|
358
|
+
for (;;) {
|
|
359
|
+
line = readLine();
|
|
360
|
+
const entry = readEntry(line);
|
|
361
|
+
if (entry === null) break;
|
|
362
|
+
switch (entry.key) {
|
|
363
|
+
case 'xy':
|
|
364
|
+
region.x = int(entry.values[0]);
|
|
365
|
+
region.y = int(entry.values[1]);
|
|
366
|
+
break;
|
|
367
|
+
case 'size':
|
|
368
|
+
region.width = int(entry.values[0]);
|
|
369
|
+
region.height = int(entry.values[1]);
|
|
370
|
+
break;
|
|
371
|
+
case 'bounds':
|
|
372
|
+
region.x = int(entry.values[0]);
|
|
373
|
+
region.y = int(entry.values[1]);
|
|
374
|
+
region.width = int(entry.values[2]);
|
|
375
|
+
region.height = int(entry.values[3]);
|
|
376
|
+
break;
|
|
377
|
+
case 'offset':
|
|
378
|
+
region.offsetX = int(entry.values[0]);
|
|
379
|
+
region.offsetY = int(entry.values[1]);
|
|
380
|
+
break;
|
|
381
|
+
case 'orig':
|
|
382
|
+
region.originalWidth = int(entry.values[0]);
|
|
383
|
+
region.originalHeight = int(entry.values[1]);
|
|
384
|
+
break;
|
|
385
|
+
case 'offsets':
|
|
386
|
+
region.offsetX = int(entry.values[0]);
|
|
387
|
+
region.offsetY = int(entry.values[1]);
|
|
388
|
+
region.originalWidth = int(entry.values[2]);
|
|
389
|
+
region.originalHeight = int(entry.values[3]);
|
|
390
|
+
break;
|
|
391
|
+
case 'rotate':
|
|
392
|
+
if (entry.values[0] === 'true') region.degrees = 90;
|
|
393
|
+
else if (entry.values[0] !== 'false') region.degrees = int(entry.values[0]);
|
|
394
|
+
break;
|
|
395
|
+
case 'index':
|
|
396
|
+
region.index = int(entry.values[0]);
|
|
397
|
+
break;
|
|
398
|
+
default:
|
|
399
|
+
break; // an unknown field becomes names/values in the runtime; nothing here reads them
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// BOTH zero, not either: a region 40 wide and 0 tall keeps its declared zero.
|
|
403
|
+
if (region.originalWidth === 0 && region.originalHeight === 0) {
|
|
404
|
+
region.originalWidth = region.width;
|
|
405
|
+
region.originalHeight = region.height;
|
|
406
|
+
}
|
|
407
|
+
page.regions.push(region);
|
|
408
|
+
regions.push(region);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return { pages, regions, lines };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The region an attachment draws under a name, and the page it sits on —
|
|
416
|
+
* `TextureAtlas.findRegion` and `region.page`, as the loader resolves a
|
|
417
|
+
* region, a mesh and each frame of a series (issue #967).
|
|
418
|
+
*
|
|
419
|
+
* The FIRST region of that name in file order: an atlas naming two regions
|
|
420
|
+
* alike draws the first (measured through spine-core 4.3.13's loader, whose
|
|
421
|
+
* attachment drew the first of two regions named `seq`, and whose `index:`
|
|
422
|
+
* lines did not enter the lookup — a series' frame is found by its NAME,
|
|
423
|
+
* `frameRegionName` in [`src/core/uvs.ts`](core/uvs.ts)). The name is matched
|
|
424
|
+
* exactly as `parseAtlasText` keeps it, the raw line: a region line `art `
|
|
425
|
+
* is not found as `art`, as the runtime does not find it.
|
|
426
|
+
*
|
|
427
|
+
* Returned as a function over the parsed atlas so the core — which reads no
|
|
428
|
+
* file and links nothing — takes it as an input (`UvLookup`); the page UVs
|
|
429
|
+
* themselves are the core's (`regionPageUvs`, `computeUvs`), measured there.
|
|
430
|
+
*/
|
|
431
|
+
export function atlasRegionLookup(parsed: ParsedAtlas): (name: string) => { page: AtlasPage; region: AtlasRegion } | null {
|
|
432
|
+
const first = new Map<string, { page: AtlasPage; region: AtlasRegion }>();
|
|
433
|
+
for (const page of parsed.pages) {
|
|
434
|
+
for (const region of page.regions) if (!first.has(region.name)) first.set(region.name, { page, region });
|
|
435
|
+
}
|
|
436
|
+
return (name) => first.get(name) ?? null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The same atlas text with every page's name line replaced.
|
|
441
|
+
*
|
|
442
|
+
* This is how `--atlas-in` emits: the imported atlas passes through verbatim —
|
|
443
|
+
* every field, every region, every page, in its own order — and only the page
|
|
444
|
+
* NAMES move, because they are paths and the file has been re-anchored to a new
|
|
445
|
+
* directory. Its blank lines are then put in rigc's own shape by
|
|
446
|
+
* `canonicalAtlasShape` (issue #803); this function leaves them as they were. Rewriting by line index rather than by
|
|
447
|
+
* re-serialising is the point: a re-serialiser would have to understand every
|
|
448
|
+
* field it re-emits, and the ones it did not understand would quietly vanish
|
|
449
|
+
* (`scale:` is the expensive example — [`atlasScales`](render.ts) reports it, and
|
|
450
|
+
* a pack that is coarser than its drawings would stop saying so).
|
|
451
|
+
*
|
|
452
|
+
* `rename` is handed the page's INDEX as well as its name, because a name is not
|
|
453
|
+
* a key: nothing in the format forbids two pages spelling the same path, and a
|
|
454
|
+
* caller that has already decided one new name per page (`copyAtlasPages` in
|
|
455
|
+
* [`emit.ts`](emit.ts) assigns the copies' filenames in page order) would then
|
|
456
|
+
* hand both of them the first decision. The index is the page's identity here;
|
|
457
|
+
* the name is data.
|
|
458
|
+
*/
|
|
459
|
+
export function rewritePageNames(parsed: ParsedAtlas, rename: (name: string, index: number) => string): string {
|
|
460
|
+
const out = parsed.lines.slice();
|
|
461
|
+
parsed.pages.forEach((page, index) => {
|
|
462
|
+
out[page.nameLine] = rename(page.name, index);
|
|
463
|
+
});
|
|
464
|
+
return out.join('\n');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The same atlas text in the whitespace shape `writeAtlasText` writes: no blank
|
|
469
|
+
* line before the first entry, exactly one between two blocks, none after the
|
|
470
|
+
* last, and one trailing newline. Every non-blank line is kept byte for byte and
|
|
471
|
+
* in its own order — only blank lines are dropped or collapsed — so a text
|
|
472
|
+
* already in that shape comes back unchanged.
|
|
473
|
+
*
|
|
474
|
+
* This is what `--atlas-in` re-emits (issue #803). A pack's blank lines are its
|
|
475
|
+
* packer's, not its content: a 3.8-era packer begins every file with one, and
|
|
476
|
+
* `A07_ATLAS_TEXT_SHAPE` — which checks the text rigc writes — refuses a blank
|
|
477
|
+
* line before the first page block, two side by side, and one after the last
|
|
478
|
+
* page block, each by its own sentence. What makes dropping them safe is what
|
|
479
|
+
* they mean to the reader that owns the format: in `TextureAtlas` a run of
|
|
480
|
+
* blank lines ends a page block exactly as one blank line does, and a run before
|
|
481
|
+
* the first page is read as nothing. Measured through the runtime, `"\n" + text`, `"\n\n" + text`
|
|
482
|
+
* and `text` load to the same pages and regions (`PKR63`, `PKR64`).
|
|
483
|
+
*
|
|
484
|
+
* ⚠️ One reading is NOT the same, and it moves toward rigc's: the runtime's
|
|
485
|
+
* leading-blank loop (`while (line && …)`) stops on an empty string, so a file
|
|
486
|
+
* that opens `"\n"` and then a header entry (`key: value` before the first page
|
|
487
|
+
* name) reads the header as a page name there, while `parseAtlasText` — which the
|
|
488
|
+
* compile took its geometry from — skips the blank and reads it as a header.
|
|
489
|
+
* Emitting without the blank makes the file say to the runtime what rigc measured.
|
|
490
|
+
*
|
|
491
|
+
* A blank line is one the runtime reads as blank: `trim()` is empty. One that
|
|
492
|
+
* carries only spaces is written as the empty line, because that is the blank
|
|
493
|
+
* line's one spelling in `writeAtlasText`.
|
|
494
|
+
*/
|
|
495
|
+
export function canonicalAtlasShape(text: string): string {
|
|
496
|
+
const out: string[] = [];
|
|
497
|
+
for (const line of text.split(/\r\n|\r|\n/)) {
|
|
498
|
+
if (line.trim().length > 0) out.push(line);
|
|
499
|
+
else if (out.length > 0 && out[out.length - 1] !== '') out.push('');
|
|
500
|
+
}
|
|
501
|
+
while (out.length > 0 && out[out.length - 1] === '') out.pop();
|
|
502
|
+
return out.length === 0 ? '' : `${out.join('\n')}\n`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// writing
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
/** A region as the emitter states it: everything `writeAtlasText` puts on the page. */
|
|
510
|
+
export interface EmitRegion {
|
|
511
|
+
name: string;
|
|
512
|
+
x: number;
|
|
513
|
+
y: number;
|
|
514
|
+
width: number;
|
|
515
|
+
height: number;
|
|
516
|
+
offsetX: number;
|
|
517
|
+
offsetY: number;
|
|
518
|
+
originalWidth: number;
|
|
519
|
+
originalHeight: number;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export interface EmitPage {
|
|
523
|
+
name: string;
|
|
524
|
+
width: number;
|
|
525
|
+
height: number;
|
|
526
|
+
regions: EmitRegion[];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* ⛔ The packer never rotates, so `rotate: 0` is a fact rather than a field.
|
|
531
|
+
*
|
|
532
|
+
* The format supports `rotate: 90` and the Spine packer uses it — every official
|
|
533
|
+
* example that has a tall thin part ships one. rigc's does not, for reasons that
|
|
534
|
+
* are about honesty rather than difficulty:
|
|
535
|
+
*
|
|
536
|
+
* * `artUvsOf` ([`src/render.ts`](render.ts)) already refuses a rotated region,
|
|
537
|
+
* because `RegionAttachment.computeUVs` assigns a different corner order at
|
|
538
|
+
* 90° and `TextureAtlas` transposes `u2/v2` at 90 and not at 270. A packer
|
|
539
|
+
* that emitted rotation would be writing atlases its own `--atlas`
|
|
540
|
+
* substitution cannot read;
|
|
541
|
+
* * the whole feature is gated on how closely the packed render matches the
|
|
542
|
+
* unpacked one, and a transposed region is sampled through a different
|
|
543
|
+
* mapping — the gate would then be measuring the mapping rather than the pack.
|
|
544
|
+
*
|
|
545
|
+
* Rotation buys page area on a set of parts whose aspect ratios differ a lot. It
|
|
546
|
+
* is not free and it is not implemented; a page that runs out of room spills to a
|
|
547
|
+
* second page instead.
|
|
548
|
+
*
|
|
549
|
+
* 🔬 **Measured for issue #860, and held back on the measurement.** A pack that
|
|
550
|
+
* turned regions `rotate: 90` lifted every one of them back byte for byte through
|
|
551
|
+
* `extractRegion` (22 of 22 and 20 of 20 on two painting rigs, 12 turned on each),
|
|
552
|
+
* and rigc's renderer drew the turned region attachments with 0 differing pixels
|
|
553
|
+
* against the unturned pack. But on neither rig did a turn shrink the page: both
|
|
554
|
+
* were already on the smallest power-of-two page their area allows, and a turn
|
|
555
|
+
* that buys no area only moves bytes. Shipping it would also need two clauses
|
|
556
|
+
* changed that say rigc never turns a region — `A06`'s under `spine-html`
|
|
557
|
+
* ([`src/validate.ts`](validate.ts)) and `artUvsOf`
|
|
558
|
+
* ([`src/render.ts`](render.ts)), which returns no art UVs for a turned region
|
|
559
|
+
* attachment, so `check`'s texture substitution would report it unmatched.
|
|
560
|
+
*
|
|
561
|
+
* 📏 **Measured again for issue #866, on twelve region sets, and held back again.**
|
|
562
|
+
* The sets are the two painting rigs above and ten production rigs of 20 to 22
|
|
563
|
+
* parts. Each was packed by this file's own search at padding 2 twice: as
|
|
564
|
+
* shipped, and with every cell also tried turned under the same BSSF score
|
|
565
|
+
* (ties go to the unturned cell). The shipped search reproduced the page every
|
|
566
|
+
* one of the twelve atlases was written at. Area change from turning:
|
|
567
|
+
*
|
|
568
|
+
* | set | `pot` | `pot` + turn | `free` | `free` + turn |
|
|
569
|
+
* | --- | --- | --- | --- | --- |
|
|
570
|
+
* | 22-part painting | 1024x2048 | 0.00 % | 1888x697 | −1.14 % |
|
|
571
|
+
* | 20-part painting | 512x2048 | 0.00 % | 480x1166 | +2.31 % |
|
|
572
|
+
* | P1 | 512x2048 | 0.00 % | 416x1633 | +1.43 % |
|
|
573
|
+
* | P2 | 1024x1024 | 0.00 % | 1184x810 | +0.70 % |
|
|
574
|
+
* | P3 | 512x1024 | 0.00 % | 480x1000 | +2.96 % |
|
|
575
|
+
* | P4 | 1024x2048 | 0.00 % | 864x1346 | −6.16 % |
|
|
576
|
+
* | P5 | 2048x2048 | 0.00 % | 1344x1768 | −13.37 % |
|
|
577
|
+
* | P6 | 1024x2048 | 0.00 % | 800x1934 | −1.62 % |
|
|
578
|
+
* | P7 | 512x2048 | 0.00 % | 1024x928 | −0.05 % |
|
|
579
|
+
* | P8 | 512x2048 | 0.00 % | 608x1250 | −4.96 % |
|
|
580
|
+
* | P9 | 1024x2048 | +100.00 % | 1024x2037 | +6.51 % |
|
|
581
|
+
* | P10 | 512x2048 | 0.00 % | 352x1433 | +0.70 % |
|
|
582
|
+
*
|
|
583
|
+
* Under `pot` a turn shrank no page on any set, and on P9 an ungated one doubled
|
|
584
|
+
* it. Under `free` the largest gain, P5's 13.37 %, is inside the search's own
|
|
585
|
+
* noise: the nearest other width on the 32-px grid the search then used moves
|
|
586
|
+
* P5's unturned page by 18.13 %, and searching every width instead of every
|
|
587
|
+
* 32nd finds an unturned P5 page 10.22 % smaller with nothing turned. With every
|
|
588
|
+
* width searched, the largest turn gain on any set is 5.05 % (P5), then 4.96 %
|
|
589
|
+
* (P8) and 4.22 % (P4). That is not a page worth two readers changing, so the
|
|
590
|
+
* packer still never turns a region.
|
|
591
|
+
*
|
|
592
|
+
* ⚠️ The `free` column and the 18.13 % are measurements of the search as it was
|
|
593
|
+
* on that day, a 32-px width grid. Issue #872 replaced it with every width (see
|
|
594
|
+
* `FREE_EDGE_STEP`), so today's `free` pages for these sets are the every-width
|
|
595
|
+
* ones the paragraph above compares against, and the 5.05 % is the turn gain
|
|
596
|
+
* measured on the search that ships.
|
|
597
|
+
*/
|
|
598
|
+
export const PACK_NO_ROTATE = 0;
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The atlas text for these pages — the ONE emitter, for both atlas shapes.
|
|
602
|
+
*
|
|
603
|
+
* Two text-shape traps are load-bearing (A07 checks both): a region name is the
|
|
604
|
+
* RAW line, so it carries no indentation, and a blank line closes a page block,
|
|
605
|
+
* so there is none between a page header and its regions. Exactly one blank line
|
|
606
|
+
* sits BETWEEN pages and none trails the last.
|
|
607
|
+
*
|
|
608
|
+
* The unpacked default goes through here too (`buildAtlasText` builds one page
|
|
609
|
+
* per image and calls this), which is what makes "the defaults change nothing" a
|
|
610
|
+
* property of one function instead of a promise made by two.
|
|
611
|
+
*
|
|
612
|
+
* ⭐ **No pages is the empty FILE, not a blank line** (issue #608). A compile that
|
|
613
|
+
* measured no art — a rig whose skins fill no slot with anything that needs a
|
|
614
|
+
* page — used to come out of here as `"\n"`, because `[].join('\n')` is `''` and
|
|
615
|
+
* the trailing newline was appended unconditionally. That one byte contradicts
|
|
616
|
+
* the paragraph above it: a blank line is the separator that sits BETWEEN page
|
|
617
|
+
* blocks, so a file consisting of one is a separator with nothing on either side.
|
|
618
|
+
* `A07_ATLAS_TEXT_SHAPE` reads a file with no non-blank line as having no page
|
|
619
|
+
* block and reports SKIP, and refuses a blank line that trails a page block as
|
|
620
|
+
* `the file ends with a blank line`.
|
|
621
|
+
*
|
|
622
|
+
* The runtime cannot tell the two apart — `new TextureAtlas('')`,
|
|
623
|
+
* `new TextureAtlas('\n')` and `new TextureAtlas('\n\n')` all come back with
|
|
624
|
+
* `pages.length === 0` and `regions.length === 0`, and the constructor
|
|
625
|
+
* (`TextureAtlas.js:97-174`) has no `throw` in it at all — so the runtime is no
|
|
626
|
+
* help in choosing, and the choice is made on what the text SAYS. Zero bytes has
|
|
627
|
+
* exactly one reading; a blank line has two, and the wrong one is the one A07 was
|
|
628
|
+
* built to catch.
|
|
629
|
+
*/
|
|
630
|
+
export function writeAtlasText(pages: EmitPage[]): string {
|
|
631
|
+
if (pages.length === 0) return '';
|
|
632
|
+
const lines: string[] = [];
|
|
633
|
+
pages.forEach((page, i) => {
|
|
634
|
+
if (i > 0) lines.push(''); // exactly one blank line BETWEEN pages
|
|
635
|
+
lines.push(page.name);
|
|
636
|
+
lines.push(`size: ${page.width}, ${page.height}`);
|
|
637
|
+
lines.push('filter: Linear, Linear');
|
|
638
|
+
lines.push('pma: false');
|
|
639
|
+
for (const region of page.regions) {
|
|
640
|
+
lines.push(region.name);
|
|
641
|
+
lines.push(`bounds: ${region.x}, ${region.y}, ${region.width}, ${region.height}`);
|
|
642
|
+
lines.push(`offsets: ${region.offsetX}, ${region.offsetY}, ${region.originalWidth}, ${region.originalHeight}`);
|
|
643
|
+
lines.push(`rotate: ${PACK_NO_ROTATE}`);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
return `${lines.join('\n')}\n`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
// packing
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* What `--page-size` defaults to: the largest edge a page may have.
|
|
655
|
+
*
|
|
656
|
+
* 2048 rather than the Spine packer's 1024, and the corpus is the reason. A
|
|
657
|
+
* ceiling of 1024 refuses four of the thirteen rigs in this repository whose art
|
|
658
|
+
* is on disk — `1-weight-and-mass`'s `ground-bg` is 1251x394 and `6-arcs`'s
|
|
659
|
+
* `platform` is 1064x396, because the shipped examples pack at `scale: 0.5` and
|
|
660
|
+
* the loose sources are twice the packed size. A default that cannot pack the
|
|
661
|
+
* project's own examples is the wrong default.
|
|
662
|
+
*
|
|
663
|
+
* It costs nothing on small rigs: this is a CEILING, and `packAtlas` writes the
|
|
664
|
+
* smallest power-of-two page the set actually fits (a two-part rig gets 1024x256,
|
|
665
|
+
* not 2048x2048). 2048 is also inside every GL implementation's guaranteed
|
|
666
|
+
* maximum texture size that any consumer of a Spine atlas runs on.
|
|
667
|
+
*/
|
|
668
|
+
export const DEFAULT_PAGE_SIZE = 2048;
|
|
669
|
+
/** What `--padding` defaults to. See `extrudeCell` for why it is not 0 and not 1. */
|
|
670
|
+
export const DEFAULT_PADDING = 2;
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* What a packed page's edges may be — `--page-edges`. See `packAtlas`, *Page size*.
|
|
674
|
+
*
|
|
675
|
+
* `pot` is the default and the only value until issue #860: both edges are powers
|
|
676
|
+
* of two. `free` lets the width be any whole number of pixels and the height
|
|
677
|
+
* whatever the placement needs. What `free` costs is measured rather than
|
|
678
|
+
* conceded: a region attachment's sampling coordinate stops being exact and moves
|
|
679
|
+
* to `PK05`'s bound of one least significant bit, the bound a mesh already had.
|
|
680
|
+
*/
|
|
681
|
+
export const PAGE_EDGES = ['pot', 'free'] as const;
|
|
682
|
+
export type PageEdges = (typeof PAGE_EDGES)[number];
|
|
683
|
+
export const DEFAULT_PAGE_EDGES: PageEdges = 'pot';
|
|
684
|
+
/**
|
|
685
|
+
* The step a `free` page's width is tried on: **1, every width** (issue #872).
|
|
686
|
+
* See `smallestFreePageFor` for the search and `packAtlas`, *Page size*, for
|
|
687
|
+
* what it buys.
|
|
688
|
+
*
|
|
689
|
+
* It was 32 until #872, and the grid was the cost. On twelve region sets (two
|
|
690
|
+
* painting rigs and ten production rigs of 20 to 22 parts) the 32-px grid's page
|
|
691
|
+
* was larger than the every-width page on eleven, by up to 11.38 % (P5: 1344x1768
|
|
692
|
+
* against 1427x1495). Finer fixed steps and coarse-to-fine searches were measured
|
|
693
|
+
* against the every-width page too, and none of them is safe: the area as a
|
|
694
|
+
* function of the width has minima one pixel wide, so a step-4 grid refined
|
|
695
|
+
* over ±3 px of its best four widths still misses one (the fetched
|
|
696
|
+
* `5-squash-and-stretch` atlas: 867x415 against 863x415), and the coarse-to-fine
|
|
697
|
+
* search that was exact on all twelve sets missed by 0.82 % on the first
|
|
698
|
+
* held-out atlas it met. Every width is exact by construction; what keeps it
|
|
699
|
+
* cheap is the two bounds `smallestFreePageFor` prunes with, and they are exact
|
|
700
|
+
* too.
|
|
701
|
+
*
|
|
702
|
+
* Kept as a named constant rather than deleted because it is importable, and a
|
|
703
|
+
* reader of the old value should find the new one rather than a missing name.
|
|
704
|
+
*/
|
|
705
|
+
export const FREE_EDGE_STEP = 1;
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* What a packed region's rectangle may share with its neighbours' — `--pack-shape`
|
|
709
|
+
* (issue #1099, stage 1 of #1093). See `packAtlas`, *`shape: 'polygon'`*.
|
|
710
|
+
*
|
|
711
|
+
* `rect` is the default and is the packer exactly as it was before the option
|
|
712
|
+
* existed: no two cells overlap. `polygon` packs every region whose every
|
|
713
|
+
* attachment is a mesh by that mesh's emitted hull, so two rectangles may
|
|
714
|
+
* overlap where neither region's footprint is; a region attachment's footprint
|
|
715
|
+
* stays its rectangle, because it draws its whole quad.
|
|
716
|
+
*/
|
|
717
|
+
export const PACK_SHAPES = ['rect', 'polygon'] as const;
|
|
718
|
+
export type PackShape = (typeof PACK_SHAPES)[number];
|
|
719
|
+
export const DEFAULT_PACK_SHAPE: PackShape = 'rect';
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Where a `polygon` candidate may put a cell against the free rectangle it is
|
|
723
|
+
* placed in (issue #1104) — `packOnePageByFootprint`, *Candidates*.
|
|
724
|
+
*
|
|
725
|
+
* `box` puts the cell's owned box on the free rectangle's corner — the only
|
|
726
|
+
* rule before #1104. The other three may also put the cell's own corner there
|
|
727
|
+
* (`packOnePageByFootprint`, *Candidates*):
|
|
728
|
+
*
|
|
729
|
+
* * `box-or-cell` — on every free rectangle;
|
|
730
|
+
* * `box-else-cell` — only on a free rectangle where the owned box's anchor
|
|
731
|
+
* would put the cell off the page;
|
|
732
|
+
* * `best-of` — the footprint pass run with `box` and with `box-or-cell` on
|
|
733
|
+
* the same page and the better kept (more cells, then the higher bottom
|
|
734
|
+
* edge, then `box`), as `placePass` keeps the better of `rect` and a
|
|
735
|
+
* footprint pass.
|
|
736
|
+
*
|
|
737
|
+
* No single rule is never-larger than `box` — `box-or-cell` packs `PK78`'s
|
|
738
|
+
* wedge set 7.2 % larger — so `polygon` does not pick one: it packs the whole
|
|
739
|
+
* set as `rect`, under `box` and under `box-else-cell` and keeps the least Σ
|
|
740
|
+
* page area (`POLYGON_CANDIDATE_RULES`, `packAtlas`). `box-else-cell` is the
|
|
741
|
+
* third candidate on measurement: over 96 packs (seeds 1100–1124, the tree's
|
|
742
|
+
* 21 sets, the wedge and spill sets, `pot` and `free`) the choice with it sums
|
|
743
|
+
* to 212,607,001 texels, with `best-of` 214,407,755 and with `box-or-cell` the
|
|
744
|
+
* same. `best-of` is never worse than `box` on one page, but against
|
|
745
|
+
* `box-else-cell` it gives the smaller pack on 12 of the 27 sets where the two
|
|
746
|
+
* differ and the larger on 15 — neither dominates, and the sum decides.
|
|
747
|
+
*
|
|
748
|
+
* The CLI never sets one rule; `PackOptions.footprintAnchors` is the instrument's and the plant's.
|
|
749
|
+
*/
|
|
750
|
+
export const FOOTPRINT_ANCHORS = ['box', 'box-or-cell', 'box-else-cell', 'best-of'] as const;
|
|
751
|
+
export type FootprintAnchors = (typeof FOOTPRINT_ANCHORS)[number];
|
|
752
|
+
/** The rule a footprint search uses when it is given none — `freePageSearch`'s and `footprintPass`'s callers outside `packAtlas`. */
|
|
753
|
+
export const DEFAULT_FOOTPRINT_ANCHORS: FootprintAnchors = 'box';
|
|
754
|
+
/** The footprint rules `polygon` packs under besides `rect`, in the order a tie goes to (`packAtlas`). */
|
|
755
|
+
export const POLYGON_CANDIDATE_RULES: readonly FootprintAnchors[] = ['box', 'box-else-cell'];
|
|
756
|
+
/** Which whole pack a `polygon` pack kept: `rect`'s, or one footprint rule's. */
|
|
757
|
+
export type PackCandidate = 'rect' | FootprintAnchors;
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The part of a region its attachments draw, in the region's own texels — x
|
|
761
|
+
* right and y down from the drawing's top-left corner, so `(u · width, v ·
|
|
762
|
+
* height)` for a mesh UV `(u, v)`. Each polygon is a flat `[x0, y0, x1, y1, …]`
|
|
763
|
+
* loop, closed from its last vertex back to its first: a mesh's hull loop, and
|
|
764
|
+
* each of its triangles. Absent on a `PackInput`, the footprint is the whole
|
|
765
|
+
* rectangle, which is what every region attachment's is.
|
|
766
|
+
*/
|
|
767
|
+
export interface PackFootprint {
|
|
768
|
+
polygons: ReadonlyArray<readonly number[]>;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** The part a page is packed from: its region name and its pixels. */
|
|
772
|
+
export interface PackInput {
|
|
773
|
+
region: string;
|
|
774
|
+
/** Absolute path, for the message that names a part the pack could not fit. */
|
|
775
|
+
absPath: string;
|
|
776
|
+
width: number;
|
|
777
|
+
height: number;
|
|
778
|
+
/**
|
|
779
|
+
* What the region's attachments draw (`packFootprints`), read only under
|
|
780
|
+
* `shape: 'polygon'`. Absent means the rectangle.
|
|
781
|
+
*/
|
|
782
|
+
footprint?: PackFootprint;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export interface PackOptions {
|
|
786
|
+
/** Largest page edge. The page actually written is the smallest power of two that holds the pack. */
|
|
787
|
+
pageSize?: number;
|
|
788
|
+
/** Gutter each region reserves on every side. */
|
|
789
|
+
padding?: number;
|
|
790
|
+
/** Page filenames are `<stem>.png`, `<stem>2.png`, … — libgdx's own numbering. */
|
|
791
|
+
pageStem?: string;
|
|
792
|
+
/** What the page's edges may be (default `pot`). See `PAGE_EDGES`. */
|
|
793
|
+
pageEdges?: PageEdges;
|
|
794
|
+
/** What two rectangles may share (default `rect`). See `PACK_SHAPES`. */
|
|
795
|
+
shape?: PackShape;
|
|
796
|
+
/**
|
|
797
|
+
* One footprint rule alone instead of `polygon`'s choice between whole packs
|
|
798
|
+
* (`FOOTPRINT_ANCHORS`) — for `tools/pack_anchor.ts` and the selftest's plant
|
|
799
|
+
* (`PK97`); the CLI never sets it. Absent, `polygon` keeps the least Σ page
|
|
800
|
+
* area of `rect` and `POLYGON_CANDIDATE_RULES`.
|
|
801
|
+
*/
|
|
802
|
+
footprintAnchors?: FootprintAnchors;
|
|
803
|
+
/** Where to count what every search the pack runs placed (`PassTally`) — the controls' and the instrument's cost reading. */
|
|
804
|
+
tally?: PassTally;
|
|
805
|
+
/**
|
|
806
|
+
* `false` runs every footprint pass of every search to its end — issue
|
|
807
|
+
* #1102's early stop off, for the selftest's plant (`PK95`) only. The pages are
|
|
808
|
+
* the same either way (`PK94`); only the cost differs.
|
|
809
|
+
*/
|
|
810
|
+
stopAtMiss?: boolean;
|
|
811
|
+
/**
|
|
812
|
+
* `false` splits every band of every cell a footprint pass places over the
|
|
813
|
+
* whole free list — issue #1165's partition off (`splitFreeByCell`), for the
|
|
814
|
+
* selftest's plant (`PK106`) only. The pages are the same either way; only
|
|
815
|
+
* the cost differs.
|
|
816
|
+
*/
|
|
817
|
+
partition?: boolean;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Where one region landed. `x`/`y` are the REGION's own corner, not its cell's. */
|
|
821
|
+
export interface Placement {
|
|
822
|
+
region: string;
|
|
823
|
+
page: number;
|
|
824
|
+
x: number;
|
|
825
|
+
y: number;
|
|
826
|
+
width: number;
|
|
827
|
+
height: number;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export interface PackedPage {
|
|
831
|
+
/** The page's filename, which is also its name in the atlas text. */
|
|
832
|
+
name: string;
|
|
833
|
+
width: number;
|
|
834
|
+
height: number;
|
|
835
|
+
/** The page's pixels, ready to `writePng`. */
|
|
836
|
+
plate: Plate;
|
|
837
|
+
/** Fraction of the page area the regions themselves cover, 0..1. */
|
|
838
|
+
occupancy: number;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export interface PackResult {
|
|
842
|
+
pages: PackedPage[];
|
|
843
|
+
/** Every placement, in the packer's own (sorted) order. */
|
|
844
|
+
placements: Placement[];
|
|
845
|
+
/** The atlas text for the packed pages. */
|
|
846
|
+
atlasText: string;
|
|
847
|
+
padding: number;
|
|
848
|
+
/** The shape the pack was made under — what the `pack:` line's last field states. */
|
|
849
|
+
shape: PackShape;
|
|
850
|
+
/** The whole pack that was kept: `rect` under `shape: 'rect'`, and under `polygon` the candidate of least Σ page area (`packAtlas`). */
|
|
851
|
+
candidate: PackCandidate;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** A free rectangle in the MaxRects free list. */
|
|
855
|
+
interface Rect {
|
|
856
|
+
x: number;
|
|
857
|
+
y: number;
|
|
858
|
+
w: number;
|
|
859
|
+
h: number;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* The largest power of two that is at most `n`.
|
|
864
|
+
*
|
|
865
|
+
* `--page-size 1000` therefore means 512, not 1024: the flag is a ceiling the
|
|
866
|
+
* page may not exceed, and page edges are powers of two (see `packAtlas`). A
|
|
867
|
+
* value that is already a power of two passes through unchanged, which is every
|
|
868
|
+
* value anybody types.
|
|
869
|
+
*/
|
|
870
|
+
function floorPowerOfTwo(n: number): number {
|
|
871
|
+
let p = 1;
|
|
872
|
+
while (p * 2 <= n) p *= 2;
|
|
873
|
+
return p;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* The smallest power-of-two page that holds these cells, and where they land on
|
|
878
|
+
* it — or `null` when they do not fit `maxEdge x maxEdge` at all.
|
|
879
|
+
*
|
|
880
|
+
* Every power-of-two pair up to the maximum is tried in order of increasing
|
|
881
|
+
* area, then increasing width, so the answer is a total order and two packs of
|
|
882
|
+
* the same set choose the same page. Powers of two are not decoration:
|
|
883
|
+
* `region.x / page.width` is the coordinate every texel is read through, and a
|
|
884
|
+
* power-of-two denominator makes that division exact in binary floating point.
|
|
885
|
+
*
|
|
886
|
+
* ⭐ One search, two callers, and that is the point. It picks the single page a
|
|
887
|
+
* set that fits gets, and it picks each SPILLED page's own size — so "the page
|
|
888
|
+
* written is the smallest that holds what is on it" is one rule with one
|
|
889
|
+
* implementation rather than a rule and an exception.
|
|
890
|
+
*/
|
|
891
|
+
function smallestPageFor(
|
|
892
|
+
cells: Array<{ w: number; h: number }>,
|
|
893
|
+
maxEdge: number,
|
|
894
|
+
pageEdges: PageEdges,
|
|
895
|
+
shapes?: readonly CellShape[],
|
|
896
|
+
anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
|
|
897
|
+
tally?: PassTally,
|
|
898
|
+
stopAtMiss = true,
|
|
899
|
+
partition = true,
|
|
900
|
+
): { width: number; height: number; rects: Rect[] } | null {
|
|
901
|
+
if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes, anchors, tally, stopAtMiss, partition);
|
|
902
|
+
const edges: number[] = [];
|
|
903
|
+
for (let e = 1; e <= maxEdge; e *= 2) edges.push(e);
|
|
904
|
+
const candidates: Array<{ w: number; h: number }> = [];
|
|
905
|
+
for (const w of edges) for (const h of edges) candidates.push({ w, h });
|
|
906
|
+
candidates.sort((a, b) => a.w * a.h - b.w * b.h || a.w - b.w);
|
|
907
|
+
for (const candidate of candidates) {
|
|
908
|
+
const attempt = placePass(cells, shapes, candidate.w, candidate.h, Infinity, { wholeOrNothing: stopAtMiss, anchors, tally, partition });
|
|
909
|
+
if (attempt.some((r) => r === null)) continue;
|
|
910
|
+
return { width: candidate.w, height: candidate.h, rects: attempt as Rect[] };
|
|
911
|
+
}
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* The smallest `free` page that holds these cells, and where they land on it —
|
|
917
|
+
* or `null` when they do not fit `maxEdge x maxEdge` at all.
|
|
918
|
+
*
|
|
919
|
+
* The rule, which `docs/AUTHORING.md` §0.1 states in the same words:
|
|
920
|
+
*
|
|
921
|
+
* * the candidate WIDTHS are every whole width from the widest cell up to
|
|
922
|
+
* `maxEdge` (`FREE_EDGE_STEP` is 1);
|
|
923
|
+
* * at each width the cells are placed by the same MaxRects pass as a `pot`
|
|
924
|
+
* page, on a page `maxEdge` tall, and the HEIGHT is the bottom edge of the
|
|
925
|
+
* lowest cell — what that placement needs, not rounded;
|
|
926
|
+
* * the page with the least area wins, then the squarer (smaller |width −
|
|
927
|
+
* height|), then the narrower. Every candidate has a distinct width, so that
|
|
928
|
+
* last key makes the order total and the answer a function of the cells.
|
|
929
|
+
*
|
|
930
|
+
* ⚠️ The height is read off one placement rather than searched for, and that is
|
|
931
|
+
* a definition, not an approximation of one: MaxRects' success is not monotonic
|
|
932
|
+
* in the page height (a shorter page changes which free rectangle scores best),
|
|
933
|
+
* so "the least height that fits" is not a quantity a bisection could find, and a
|
|
934
|
+
* definition that could not be computed exactly would not be deterministic.
|
|
935
|
+
*
|
|
936
|
+
* ⭐ **Two bounds make every width cheap, and neither can change the answer**
|
|
937
|
+
* (issue #872). Both compare against the best page found so far, and both are
|
|
938
|
+
* strict, so a width that could still TIE the best — and win on the squarer or
|
|
939
|
+
* narrower key — is always placed to the end:
|
|
940
|
+
*
|
|
941
|
+
* * a width is not placed at all when the cells' own area exceeds
|
|
942
|
+
* `width x maxEdge` (it cannot fit), or when `width x` the tallest cell
|
|
943
|
+
* already exceeds the best area (no page at that width is shorter than its
|
|
944
|
+
* tallest cell);
|
|
945
|
+
* * a placement is abandoned the moment its lowest cell's bottom edge makes
|
|
946
|
+
* `width x bottom` exceed the best area. The bottom edge only grows as cells
|
|
947
|
+
* are added, so the page that placement would have finished is larger still.
|
|
948
|
+
*
|
|
949
|
+
* Neither changes a placement that runs to the end — the pass is the `pot` pass,
|
|
950
|
+
* on the same page, and abandoning it only stops it early — so the winner is the
|
|
951
|
+
* every-width winner, and `PK75` compares the two on every set it builds. What
|
|
952
|
+
* they save, measured on the twelve sets: 391,854 cell placements for an
|
|
953
|
+
* unbounded every-width search, 47,978 bounded, against 12,361 for the retired
|
|
954
|
+
* 32-px grid.
|
|
955
|
+
*/
|
|
956
|
+
function smallestFreePageFor(
|
|
957
|
+
cells: Array<{ w: number; h: number }>,
|
|
958
|
+
maxEdge: number,
|
|
959
|
+
shapes?: readonly CellShape[],
|
|
960
|
+
anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
|
|
961
|
+
tally?: PassTally,
|
|
962
|
+
stopAtMiss = true,
|
|
963
|
+
partition = true,
|
|
964
|
+
): { width: number; height: number; rects: Rect[] } | null {
|
|
965
|
+
const search = freePageSearch(cells, maxEdge, shapes, { anchors, stopAtMiss, partition });
|
|
966
|
+
if (tally !== undefined) for (const key of Object.keys(tally) as Array<keyof PassTally>) tally[key] += search.tally[key];
|
|
967
|
+
return search.page;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** What one `free` page search did: the page it chose, and what each width cost. */
|
|
971
|
+
export interface FreePageSearch {
|
|
972
|
+
page: { width: number; height: number; rects: Array<{ x: number; y: number; w: number; h: number }> } | null;
|
|
973
|
+
/** Candidate widths, from the widest cell to `maxEdge`. */
|
|
974
|
+
widths: number;
|
|
975
|
+
/** Widths whose placement ran to the end. */
|
|
976
|
+
completed: number;
|
|
977
|
+
/** Widths whose placement stopped once its lowest cell made the page larger than the best so far. */
|
|
978
|
+
abandoned: number;
|
|
979
|
+
/** Widths never placed: too narrow for the cells' area, or wide enough that the tallest cell alone loses. */
|
|
980
|
+
skipped: number;
|
|
981
|
+
/** What the passes placed, by kind (`PassTally`). */
|
|
982
|
+
tally: PassTally;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* How `freePageSearch` runs its passes — for the selftest's plants only
|
|
987
|
+
* (`PK94`, `PK95`, `PK106`). Each turns one saving off: `stopAtMiss: false`
|
|
988
|
+
* runs every footprint pass to its end and `edgeIndex: false` splits and
|
|
989
|
+
* prunes the whole free list by scanning all of it (issue #1102's two);
|
|
990
|
+
* `partition: false` splits every band over the whole list but keeps the edge
|
|
991
|
+
* index (issue #1165's). The page is the same either way; only what it costs
|
|
992
|
+
* differs, and the plants hold that.
|
|
993
|
+
*/
|
|
994
|
+
export interface FreePageSearchOptions {
|
|
995
|
+
stopAtMiss?: boolean;
|
|
996
|
+
/** `false` splits the whole free list and scans all of it for a piece's containers (`splitFree`), as the prune did before issue #1102. */
|
|
997
|
+
edgeIndex?: boolean;
|
|
998
|
+
/** `false` splits every band over the whole free list (`splitFreeByCell`), as before issue #1165 — the plant of `PK106`. */
|
|
999
|
+
partition?: boolean;
|
|
1000
|
+
/** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`) — `tools/pack_anchor.ts`'s candidate rules; default `box`. */
|
|
1001
|
+
anchors?: FootprintAnchors;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* `smallestFreePageFor`'s search with its bookkeeping — exported so the
|
|
1006
|
+
* selftest can compare its page against an unbounded every-width reference and
|
|
1007
|
+
* see that the bounds did prune (`PK75`). The page is the whole of what the
|
|
1008
|
+
* packer uses; the counts are for the control. `shapes`, one per cell, is
|
|
1009
|
+
* `shape: 'polygon'`'s placement (`placePass`); absent, the rectangle pass.
|
|
1010
|
+
*/
|
|
1011
|
+
export function freePageSearch(
|
|
1012
|
+
cells: Array<{ w: number; h: number }>,
|
|
1013
|
+
maxEdge: number,
|
|
1014
|
+
shapes?: readonly CellShape[],
|
|
1015
|
+
options: FreePageSearchOptions = {},
|
|
1016
|
+
): FreePageSearch {
|
|
1017
|
+
const wholeOrNothing = options.stopAtMiss ?? true;
|
|
1018
|
+
let widest = 0;
|
|
1019
|
+
let tallest = 0;
|
|
1020
|
+
let cellArea = 0;
|
|
1021
|
+
for (const cell of cells) {
|
|
1022
|
+
widest = Math.max(widest, cell.w);
|
|
1023
|
+
tallest = Math.max(tallest, cell.h);
|
|
1024
|
+
cellArea += cell.w * cell.h;
|
|
1025
|
+
}
|
|
1026
|
+
// 🔒 Under `polygon` cells may overlap, so their area is no bound on the page;
|
|
1027
|
+
// what is, is the texels they OWN, which are disjoint (issue #1099). Bounding
|
|
1028
|
+
// by the cells' area skipped every width a polygon spill page needed, and a
|
|
1029
|
+
// spilled page whose cells overlapped was refused as fitting no page at all.
|
|
1030
|
+
if (shapes !== undefined) {
|
|
1031
|
+
cellArea = 0;
|
|
1032
|
+
for (const shape of shapes) cellArea += shape.owned;
|
|
1033
|
+
}
|
|
1034
|
+
const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0, tally: emptyTally() };
|
|
1035
|
+
let best: { width: number; height: number; rects: Rect[] } | null = null;
|
|
1036
|
+
for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
|
|
1037
|
+
out.widths++;
|
|
1038
|
+
const bestArea = best === null ? Infinity : best.width * best.height;
|
|
1039
|
+
if (cellArea > width * maxEdge || width * tallest > bestArea) {
|
|
1040
|
+
out.skipped++;
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
|
|
1044
|
+
const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true, anchors: options.anchors, partition: options.partition ?? true });
|
|
1045
|
+
let height = 0;
|
|
1046
|
+
for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
|
|
1047
|
+
if (height > maxBottom) {
|
|
1048
|
+
out.abandoned++;
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
out.completed++;
|
|
1052
|
+
if (attempt.some((r) => r === null)) continue;
|
|
1053
|
+
const rects = attempt as Rect[];
|
|
1054
|
+
if (best !== null) {
|
|
1055
|
+
const area = width * height;
|
|
1056
|
+
if (area > bestArea) continue;
|
|
1057
|
+
if (area === bestArea) {
|
|
1058
|
+
const square = Math.abs(width - height);
|
|
1059
|
+
const bestSquare = Math.abs(best.width - best.height);
|
|
1060
|
+
if (square > bestSquare) continue;
|
|
1061
|
+
if (square === bestSquare && width >= best.width) continue;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
best = { width, height, rects };
|
|
1065
|
+
}
|
|
1066
|
+
out.page = best;
|
|
1067
|
+
return out;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* One `free` candidate, unbounded: the cells placed at `width` on a page
|
|
1072
|
+
* `maxEdge` tall, and the height that placement needs — or `null` when they do
|
|
1073
|
+
* not all fit. It is the pass `freePageSearch` runs at each width with no
|
|
1074
|
+
* bound, exported so the selftest can rebuild a reference search from the same
|
|
1075
|
+
* placement (`PK74`'s 32-px grid, `PK75`'s every width, `PK76`'s `pot` search).
|
|
1076
|
+
*/
|
|
1077
|
+
export function placeAtWidth(
|
|
1078
|
+
cells: Array<{ w: number; h: number }>,
|
|
1079
|
+
width: number,
|
|
1080
|
+
height: number,
|
|
1081
|
+
): { width: number; height: number; rects: Array<{ x: number; y: number; w: number; h: number }> } | null {
|
|
1082
|
+
const attempt = packOnePage(cells, width, height);
|
|
1083
|
+
if (attempt.some((r) => r === null)) return null;
|
|
1084
|
+
const rects = attempt as Rect[];
|
|
1085
|
+
let bottom = 0;
|
|
1086
|
+
for (const r of rects) bottom = Math.max(bottom, r.y + r.h);
|
|
1087
|
+
return { width, height: bottom, rects };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* MaxRects with Best Short Side Fit, no rotation.
|
|
1092
|
+
*
|
|
1093
|
+
* The free list starts as the whole page and every placement splits every free
|
|
1094
|
+
* rectangle it overlaps into up to four new ones, after which rectangles wholly
|
|
1095
|
+
* contained in another are pruned. It is the standard formulation (Jylänki 2010)
|
|
1096
|
+
* and it is here rather than in a dependency because rigc has one dependency and
|
|
1097
|
+
* a bin packer is 90 lines.
|
|
1098
|
+
*
|
|
1099
|
+
* 🔒 **Determinism.** BSSF's score can tie, and a tie broken by "whichever came
|
|
1100
|
+
* first in the free list" makes the output depend on the order splits happened
|
|
1101
|
+
* to be pushed. So the tie-break is spelled out and total — smallest long-side
|
|
1102
|
+
* leftover, then topmost, then leftmost — and the caller sorts its input before
|
|
1103
|
+
* calling. Same inputs, byte-identical page.
|
|
1104
|
+
*
|
|
1105
|
+
* `maxBottom` is the `free` search's abandon bound (see `freePageSearch`): once a
|
|
1106
|
+
* placed cell's bottom edge passes it the pass stops, and the cells not placed
|
|
1107
|
+
* are `null`. The default, `Infinity`, never stops a pass — which is every `pot`
|
|
1108
|
+
* call, so a `pot` page is placed exactly as it was before the bound existed.
|
|
1109
|
+
*/
|
|
1110
|
+
function packOnePage(
|
|
1111
|
+
cells: Array<{ w: number; h: number }>,
|
|
1112
|
+
pageW: number,
|
|
1113
|
+
pageH: number,
|
|
1114
|
+
maxBottom = Infinity,
|
|
1115
|
+
): Array<Rect | null> {
|
|
1116
|
+
const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
|
|
1117
|
+
const placed: Array<Rect | null> = [];
|
|
1118
|
+
|
|
1119
|
+
for (const cell of cells) {
|
|
1120
|
+
let best: Rect | null = null;
|
|
1121
|
+
let bestShort = Infinity;
|
|
1122
|
+
let bestLong = Infinity;
|
|
1123
|
+
for (const fr of free) {
|
|
1124
|
+
if (fr.w < cell.w || fr.h < cell.h) continue;
|
|
1125
|
+
const leftoverW = fr.w - cell.w;
|
|
1126
|
+
const leftoverH = fr.h - cell.h;
|
|
1127
|
+
const short = Math.min(leftoverW, leftoverH);
|
|
1128
|
+
const long = Math.max(leftoverW, leftoverH);
|
|
1129
|
+
if (best !== null) {
|
|
1130
|
+
if (short > bestShort) continue;
|
|
1131
|
+
if (short === bestShort) {
|
|
1132
|
+
if (long > bestLong) continue;
|
|
1133
|
+
if (long === bestLong) {
|
|
1134
|
+
if (fr.y > best.y) continue;
|
|
1135
|
+
if (fr.y === best.y && fr.x >= best.x) continue;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
best = fr;
|
|
1140
|
+
bestShort = short;
|
|
1141
|
+
bestLong = long;
|
|
1142
|
+
}
|
|
1143
|
+
if (best === null) {
|
|
1144
|
+
placed.push(null);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
const put: Rect = { x: best.x, y: best.y, w: cell.w, h: cell.h };
|
|
1148
|
+
placed.push(put);
|
|
1149
|
+
if (put.y + put.h > maxBottom) {
|
|
1150
|
+
while (placed.length < cells.length) placed.push(null);
|
|
1151
|
+
return placed;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Split every free rectangle the placement overlaps, then prune.
|
|
1155
|
+
const next: Rect[] = [];
|
|
1156
|
+
for (const fr of free) {
|
|
1157
|
+
const overlaps = put.x < fr.x + fr.w && put.x + put.w > fr.x && put.y < fr.y + fr.h && put.y + put.h > fr.y;
|
|
1158
|
+
if (!overlaps) {
|
|
1159
|
+
next.push(fr);
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
if (put.x > fr.x) next.push({ x: fr.x, y: fr.y, w: put.x - fr.x, h: fr.h });
|
|
1163
|
+
if (put.x + put.w < fr.x + fr.w) {
|
|
1164
|
+
next.push({ x: put.x + put.w, y: fr.y, w: fr.x + fr.w - (put.x + put.w), h: fr.h });
|
|
1165
|
+
}
|
|
1166
|
+
if (put.y > fr.y) next.push({ x: fr.x, y: fr.y, w: fr.w, h: put.y - fr.y });
|
|
1167
|
+
if (put.y + put.h < fr.y + fr.h) {
|
|
1168
|
+
next.push({ x: fr.x, y: put.y + put.h, w: fr.w, h: fr.y + fr.h - (put.y + put.h) });
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const contains = (a: Rect, b: Rect): boolean =>
|
|
1172
|
+
b.x >= a.x && b.y >= a.y && b.x + b.w <= a.x + a.w && b.y + b.h <= a.y + a.h;
|
|
1173
|
+
free.length = 0;
|
|
1174
|
+
for (let i = 0; i < next.length; i++) {
|
|
1175
|
+
if (next[i].w <= 0 || next[i].h <= 0) continue;
|
|
1176
|
+
let contained = false;
|
|
1177
|
+
for (let j = 0; j < next.length && !contained; j++) {
|
|
1178
|
+
if (i === j || next[j].w <= 0 || next[j].h <= 0) continue;
|
|
1179
|
+
// On a mutual containment (two identical rectangles) the later index
|
|
1180
|
+
// loses, so exactly one survives and it is always the same one.
|
|
1181
|
+
if (contains(next[j], next[i]) && (j < i || !contains(next[i], next[j]))) contained = true;
|
|
1182
|
+
}
|
|
1183
|
+
if (!contained) free.push(next[i]);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return placed;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* One placement pass: the rectangle pass (`packOnePage`) when `shapes` is
|
|
1191
|
+
* absent, which is every `shape: 'rect'` call and so every pack made before the
|
|
1192
|
+
* option existed, and the footprint pass (`packOnePageByFootprint`) when it is
|
|
1193
|
+
* given.
|
|
1194
|
+
*/
|
|
1195
|
+
function placePass(
|
|
1196
|
+
cells: Array<{ w: number; h: number }>,
|
|
1197
|
+
shapes: readonly CellShape[] | undefined,
|
|
1198
|
+
pageW: number,
|
|
1199
|
+
pageH: number,
|
|
1200
|
+
maxBottom = Infinity,
|
|
1201
|
+
pass: PassOptions = {},
|
|
1202
|
+
): Array<Rect | null> {
|
|
1203
|
+
const wholeOrNothing = pass.wholeOrNothing ?? false;
|
|
1204
|
+
const tally = pass.tally;
|
|
1205
|
+
if (shapes === undefined || shapes.every((s) => s.whole)) {
|
|
1206
|
+
const byRect = packOnePage(cells, pageW, pageH, maxBottom);
|
|
1207
|
+
if (tally !== undefined) tally.rectPlaced += placedCount(byRect);
|
|
1208
|
+
return byRect;
|
|
1209
|
+
}
|
|
1210
|
+
// ⭐ `wholeOrNothing` is the page searches' question (issue #1102): they keep
|
|
1211
|
+
// a pass only when it placed every cell, so a pass that has missed one is
|
|
1212
|
+
// already discarded, whatever it does next. Two savings follow, and neither
|
|
1213
|
+
// can change a page the search keeps:
|
|
1214
|
+
//
|
|
1215
|
+
// * the rectangle pass is not run when the cells' own area exceeds the
|
|
1216
|
+
// page — disjoint rectangles cannot all fit, so it could not place every
|
|
1217
|
+
// cell, and the pass it would have lost or won against is discarded either
|
|
1218
|
+
// way (a footprint pass that placed every cell beats a rectangle pass that
|
|
1219
|
+
// did not, on the first key);
|
|
1220
|
+
// * the footprint pass stops at its first miss (`packOnePageByFootprint`'s
|
|
1221
|
+
// `stopAtMiss`), returning the cells after it as not placed.
|
|
1222
|
+
//
|
|
1223
|
+
// What is returned can then differ from the full answer only in which of two
|
|
1224
|
+
// failed passes it is, or in how many cells a failed pass placed — and the
|
|
1225
|
+
// searches read neither. The spill's assignment pass, which keeps the cells
|
|
1226
|
+
// a pass did place, never asks this (`packAtlas`).
|
|
1227
|
+
//
|
|
1228
|
+
// Measured on a replica of a production rig (31 meshes, a two-page `free`
|
|
1229
|
+
// spill at 2048, padding 2), where the pack took 260–304 s against `rect`'s
|
|
1230
|
+
// 0.14–0.24 s: the single-page search ran a full footprint pass at every one of its
|
|
1231
|
+
// 854 widths (54 % of the time), because nothing fits and so no best page
|
|
1232
|
+
// ever bounds a width, and the first page's own search ran one at every width
|
|
1233
|
+
// it did not abandon (46 %). The largest cell's owned box does not start at
|
|
1234
|
+
// its cell's corner, so on an empty page that cell is every footprint pass's
|
|
1235
|
+
// first miss — all 1,709 of them — and every one of those passes was thrown
|
|
1236
|
+
// away (issue #1104 measures the anchor that causes it). With the early stop the pack takes 0.15–0.7 s by the load, the
|
|
1237
|
+
// same pages.
|
|
1238
|
+
const rectCanFit = !wholeOrNothing || cellAreaOf(cells) <= pageW * pageH;
|
|
1239
|
+
const byRect = rectCanFit ? packOnePage(cells, pageW, pageH, maxBottom) : null;
|
|
1240
|
+
if (tally !== undefined && byRect !== null) tally.rectPlaced += placedCount(byRect);
|
|
1241
|
+
// ⭐ `polygon` never costs a page anything `rect` would have saved. Both passes
|
|
1242
|
+
// are run on the same page and the better one is kept: more cells placed,
|
|
1243
|
+
// then the higher bottom edge, then — on a tie — the rectangle pass. Two
|
|
1244
|
+
// disjoint cells never share a protected texel, so the rectangle pass is
|
|
1245
|
+
// itself a legal footprint placement, and keeping it where it is better is
|
|
1246
|
+
// choosing between two legal answers, not giving the mode up. Measured
|
|
1247
|
+
// before this rule existed: the footprint pass alone wrote larger pages
|
|
1248
|
+
// than `rect` on two of the five gallery rigs that carry a mesh, because a
|
|
1249
|
+
// greedy pass that places one cell differently places every later one
|
|
1250
|
+
// differently too.
|
|
1251
|
+
const anchors = pass.anchors ?? DEFAULT_FOOTPRINT_ANCHORS;
|
|
1252
|
+
let byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, anchors === 'best-of' ? 'box' : anchors, pass.partition ?? true);
|
|
1253
|
+
if (anchors === 'best-of') {
|
|
1254
|
+
// Issue #1104's fourth candidate: both searches on the same page, the
|
|
1255
|
+
// better kept and the first anchor's on a tie — so a pass is never worse
|
|
1256
|
+
// than the first anchor's on that page.
|
|
1257
|
+
if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
|
|
1258
|
+
const byTwo = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, 'box-or-cell', pass.partition ?? true);
|
|
1259
|
+
const bottom = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
|
|
1260
|
+
const placedBox = placedCount(byFootprint);
|
|
1261
|
+
const placedTwo = placedCount(byTwo);
|
|
1262
|
+
if (placedTwo > placedBox || (placedTwo === placedBox && bottom(byTwo) < bottom(byFootprint))) byFootprint = byTwo;
|
|
1263
|
+
if (tally !== undefined) tally.footprintPlaced += placedTwo;
|
|
1264
|
+
} else if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
|
|
1265
|
+
if (byRect === null) return byFootprint;
|
|
1266
|
+
const bottomOf = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
|
|
1267
|
+
const placedRect = placedCount(byRect);
|
|
1268
|
+
const placedFoot = placedCount(byFootprint);
|
|
1269
|
+
if (placedFoot !== placedRect) return placedFoot > placedRect ? byFootprint : byRect;
|
|
1270
|
+
return bottomOf(byFootprint) < bottomOf(byRect) ? byFootprint : byRect;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** How `placePass` runs — what the page searches ask of it (issue #1102). */
|
|
1274
|
+
interface PassOptions {
|
|
1275
|
+
/** Only a pass that places every cell is kept by the caller. See `placePass`. */
|
|
1276
|
+
wholeOrNothing?: boolean;
|
|
1277
|
+
/** Where to count what the passes placed. */
|
|
1278
|
+
tally?: PassTally;
|
|
1279
|
+
/** `splitFree`'s edge index (default on); off only for the selftest's plant. */
|
|
1280
|
+
edgeIndex?: boolean;
|
|
1281
|
+
/** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`). */
|
|
1282
|
+
anchors?: FootprintAnchors;
|
|
1283
|
+
/** `splitFreeByCell`'s partition (default on); off only for the selftest's plant. */
|
|
1284
|
+
partition?: boolean;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** How many cells a pass placed. */
|
|
1288
|
+
function placedCount(pass: ReadonlyArray<Rect | null>): number {
|
|
1289
|
+
let n = 0;
|
|
1290
|
+
for (const r of pass) if (r !== null) n++;
|
|
1291
|
+
return n;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** The cells' own area — what disjoint rectangles need of a page at least. */
|
|
1295
|
+
function cellAreaOf(cells: ReadonlyArray<{ w: number; h: number }>): number {
|
|
1296
|
+
let area = 0;
|
|
1297
|
+
for (const cell of cells) area += cell.w * cell.h;
|
|
1298
|
+
return area;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* What a page search's passes placed, by kind — the operation count a search's
|
|
1303
|
+
* cost is held by (`PK93`), since a footprint placement splits the free list
|
|
1304
|
+
* once per band of what the cell owns and a rectangle placement once.
|
|
1305
|
+
*/
|
|
1306
|
+
export interface PassTally {
|
|
1307
|
+
/** Cells placed by rectangle passes (`packOnePage`). */
|
|
1308
|
+
rectPlaced: number;
|
|
1309
|
+
/** Cells placed by footprint passes (`packOnePageByFootprint`). */
|
|
1310
|
+
footprintPlaced: number;
|
|
1311
|
+
/** Free-list splits the footprint passes made — one per band of every cell they placed (`splitFree`). */
|
|
1312
|
+
bandSplits: number;
|
|
1313
|
+
/** Containment tests the footprint passes' prune made (`splitFree`). */
|
|
1314
|
+
containmentTests: number;
|
|
1315
|
+
/**
|
|
1316
|
+
* Free-list entries the footprint passes' splits read: the list's length at
|
|
1317
|
+
* every band split, and once per placed cell the whole list the partition
|
|
1318
|
+
* reads (`splitFreeByCell`) — the work issue #1165 measured, held by `PK106`.
|
|
1319
|
+
*/
|
|
1320
|
+
entriesRead: number;
|
|
1321
|
+
/**
|
|
1322
|
+
* Free rectangles that held a cell's owned box and were refused as its
|
|
1323
|
+
* first-anchor candidate only because the cell, anchored there, would leave
|
|
1324
|
+
* the page — at negative x or y, or past the right or bottom edge (issue
|
|
1325
|
+
* #1104, `tools/pack_anchor.ts`).
|
|
1326
|
+
*/
|
|
1327
|
+
boxAnchorRefused: number;
|
|
1328
|
+
/** Cells a footprint pass missed although some free rectangle held their owned box. */
|
|
1329
|
+
missedByAnchor: number;
|
|
1330
|
+
/** Cells a footprint pass placed at the second anchor — the cell's own corner on the free rectangle's — under a candidate rule that has one (`FOOTPRINT_ANCHORS`). */
|
|
1331
|
+
secondAnchorPlaced: number;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** A tally with nothing counted. */
|
|
1335
|
+
export function emptyTally(): PassTally {
|
|
1336
|
+
return { rectPlaced: 0, footprintPlaced: 0, bandSplits: 0, containmentTests: 0, entriesRead: 0, boxAnchorRefused: 0, missedByAnchor: 0, secondAnchorPlaced: 0 };
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// ---------------------------------------------------------------------------
|
|
1340
|
+
// footprints — `shape: 'polygon'` (issue #1099)
|
|
1341
|
+
// ---------------------------------------------------------------------------
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* A cell as `shape: 'polygon'` places it: the region plus `padding` on every
|
|
1345
|
+
* side, and the cell texels the region OWNS — its protected set.
|
|
1346
|
+
*
|
|
1347
|
+
* ## The protected set, and why it is the footprint test
|
|
1348
|
+
*
|
|
1349
|
+
* A region attachment owns its whole cell, exactly the cell `shape: 'rect'`
|
|
1350
|
+
* keeps apart from every other: it draws its whole quad, and its gutter is what
|
|
1351
|
+
* makes its edge sample as the loose page's does (`extrudeCell`).
|
|
1352
|
+
*
|
|
1353
|
+
* A mesh region owns every cell texel `t` whose unit square, grown by `reach =
|
|
1354
|
+
* max(padding, 1)` texels on every side (a Chebyshev dilation), meets one of
|
|
1355
|
+
* the region's footprint polygons, closed — boundary contact counts. Then:
|
|
1356
|
+
*
|
|
1357
|
+
* * it holds every texel the mesh can sample. A bilinear tap at a point `q`
|
|
1358
|
+
* inside a triangle reads the four texels whose centres are within one
|
|
1359
|
+
* texel of `q`, and each of those squares lies within half a texel of `q`;
|
|
1360
|
+
* `reach` ≥ 1 covers that with room for the float32 page UVs the runtime
|
|
1361
|
+
* stores (`src/atlas.ts`'s header: worst 3.15e-5 texels);
|
|
1362
|
+
* * it keeps the padding the rectangle keeps. A rectangle's cell is the
|
|
1363
|
+
* rectangle grown by `padding`, and this is the hull grown by `padding` —
|
|
1364
|
+
* for a hull that covers its whole drawing, texel for texel the same cell,
|
|
1365
|
+
* which is why such a mesh is placed exactly as a rectangle is;
|
|
1366
|
+
* * it is clipped to the cell, so a region never claims a texel outside the
|
|
1367
|
+
* cell its own pixels are extruded into.
|
|
1368
|
+
*
|
|
1369
|
+
* ⇒ **Two cells may be placed with their rectangles overlapping exactly when
|
|
1370
|
+
* their protected sets share no texel.** For two rectangles that is today's
|
|
1371
|
+
* rule — two cells share no texel — so a pack with no mesh region is placed
|
|
1372
|
+
* by `rect`'s arithmetic, decision for decision. For any two footprints it
|
|
1373
|
+
* means their Chebyshev distance is at least `2 · padding`: if a point of one
|
|
1374
|
+
* were within `2 · padding` of a point of the other, the texel under their
|
|
1375
|
+
* midpoint would be in both protected sets.
|
|
1376
|
+
*
|
|
1377
|
+
* ## Exactness — where it rounds, and in which direction
|
|
1378
|
+
*
|
|
1379
|
+
* The polygons are doubles: a UV as `skeleton.json` states it, times the
|
|
1380
|
+
* region's integer size. The set is computed row by row — the band
|
|
1381
|
+
* `[row − reach, row + 1 + reach]` against each polygon: every edge clipped to
|
|
1382
|
+
* the band, and the polygon's even-odd spans on the band's two lines, which
|
|
1383
|
+
* together are the polygon's exact extent inside the band — and every
|
|
1384
|
+
* comparison that could decide "outside" is made `FOOTPRINT_SLACK` in the
|
|
1385
|
+
* polygon's favour. **The only rounding grows the set**, by at most that slack
|
|
1386
|
+
* (1e-6 texels, against a double's 1e-13 on these magnitudes), so it can make
|
|
1387
|
+
* two footprints keep further apart than they had to and never lets two
|
|
1388
|
+
* touch.
|
|
1389
|
+
*/
|
|
1390
|
+
export interface CellShape {
|
|
1391
|
+
/** The cell: the region plus `padding` on every side. */
|
|
1392
|
+
width: number;
|
|
1393
|
+
height: number;
|
|
1394
|
+
/** 1 on every texel the region owns, row-major over the cell. */
|
|
1395
|
+
mask: Uint8Array;
|
|
1396
|
+
/** `mask` rounded out to bands of `FOOTPRINT_BAND_ROWS` rows, as disjoint rectangles in cell texels, top to bottom — what the free list is split by. */
|
|
1397
|
+
rects: Rect[];
|
|
1398
|
+
/** The bounding box of `mask`, in cell texels. */
|
|
1399
|
+
bbox: Rect;
|
|
1400
|
+
/** The mask is the whole cell: the region is placed exactly as `shape: 'rect'` places it. */
|
|
1401
|
+
whole: boolean;
|
|
1402
|
+
/** How many texels the mask owns — what the `free` search's area bound sums, since owned sets are disjoint and cells may not be. */
|
|
1403
|
+
owned: number;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** How far in a polygon's favour every footprint comparison is made. See `CellShape`, *Exactness*. */
|
|
1407
|
+
export const FOOTPRINT_SLACK = 1e-6;
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* How many rows of a cell's owned set one free-list rectangle spans — the
|
|
1411
|
+
* resolution the footprint pass's free list keeps, not the resolution of the
|
|
1412
|
+
* footprint test (which is the exact set, texel by texel). Splitting the free
|
|
1413
|
+
* list by one rectangle per row of a curved hull made it thousands long: a
|
|
1414
|
+
* 60-part set of ellipse hulls ran over ten minutes on a `free` page. See
|
|
1415
|
+
* `packOnePageByFootprint` for what the bands cost.
|
|
1416
|
+
*/
|
|
1417
|
+
export const FOOTPRINT_BAND_ROWS = 8;
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* The closed x-extent of one polygon inside the band `y0 ≤ y ≤ y1`, as a list
|
|
1421
|
+
* of closed intervals whose union is exactly that extent: each edge clipped to
|
|
1422
|
+
* the band, and the even-odd spans on the band's two lines (a point inside the
|
|
1423
|
+
* polygon and the band either reaches a band line vertically without leaving
|
|
1424
|
+
* the polygon, or meets an edge inside the band on the way).
|
|
1425
|
+
*/
|
|
1426
|
+
function bandExtent(poly: readonly number[], offset: number, y0: number, y1: number, out: Array<[number, number]>): void {
|
|
1427
|
+
const n = poly.length / 2;
|
|
1428
|
+
for (let i = 0; i < n; i++) {
|
|
1429
|
+
const ax = poly[2 * i] + offset;
|
|
1430
|
+
const ay = poly[2 * i + 1] + offset;
|
|
1431
|
+
const j = (i + 1) % n;
|
|
1432
|
+
const bx = poly[2 * j] + offset;
|
|
1433
|
+
const by = poly[2 * j + 1] + offset;
|
|
1434
|
+
if (Math.max(ay, by) < y0 || Math.min(ay, by) > y1) continue;
|
|
1435
|
+
if (ay === by) {
|
|
1436
|
+
out.push([Math.min(ax, bx), Math.max(ax, bx)]);
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
let t0 = (y0 - ay) / (by - ay);
|
|
1440
|
+
let t1 = (y1 - ay) / (by - ay);
|
|
1441
|
+
if (t0 > t1) [t0, t1] = [t1, t0];
|
|
1442
|
+
t0 = Math.max(0, t0);
|
|
1443
|
+
t1 = Math.min(1, t1);
|
|
1444
|
+
if (t0 > t1) continue;
|
|
1445
|
+
const xa = ax + (bx - ax) * t0;
|
|
1446
|
+
const xb = ax + (bx - ax) * t1;
|
|
1447
|
+
out.push([Math.min(xa, xb), Math.max(xa, xb)]);
|
|
1448
|
+
}
|
|
1449
|
+
for (const y of [y0, y1]) {
|
|
1450
|
+
const crossings: number[] = [];
|
|
1451
|
+
for (let i = 0; i < n; i++) {
|
|
1452
|
+
const ay = poly[2 * i + 1] + offset;
|
|
1453
|
+
const j = (i + 1) % n;
|
|
1454
|
+
const by = poly[2 * j + 1] + offset;
|
|
1455
|
+
if ((ay <= y && y < by) || (by <= y && y < ay)) {
|
|
1456
|
+
const ax = poly[2 * i] + offset;
|
|
1457
|
+
const bx = poly[2 * j] + offset;
|
|
1458
|
+
crossings.push(ax + ((y - ay) * (bx - ax)) / (by - ay));
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
crossings.sort((a, b) => a - b);
|
|
1462
|
+
for (let k = 0; k + 1 < crossings.length; k += 2) out.push([crossings[k], crossings[k + 1]]);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* A region's cell and protected set (`CellShape`) — the rectangle when
|
|
1468
|
+
* `footprint` is absent, the dilated footprint polygons when it is given.
|
|
1469
|
+
* Exported so the selftest can hold the pages to the sets the packer kept
|
|
1470
|
+
* apart (`PK79`) without restating the rule.
|
|
1471
|
+
*/
|
|
1472
|
+
export function footprintCell(width: number, height: number, padding: number, footprint?: PackFootprint): CellShape {
|
|
1473
|
+
const cw = width + 2 * padding;
|
|
1474
|
+
const ch = height + 2 * padding;
|
|
1475
|
+
const whole = (): CellShape => {
|
|
1476
|
+
const cell = { x: 0, y: 0, w: cw, h: ch };
|
|
1477
|
+
return { width: cw, height: ch, mask: new Uint8Array(cw * ch).fill(1), rects: [cell], bbox: { ...cell }, whole: true, owned: cw * ch };
|
|
1478
|
+
};
|
|
1479
|
+
if (footprint === undefined) return whole();
|
|
1480
|
+
const mask = new Uint8Array(cw * ch);
|
|
1481
|
+
const reach = Math.max(padding, 1);
|
|
1482
|
+
const spans: Array<[number, number]> = [];
|
|
1483
|
+
for (let row = 0; row < ch; row++) {
|
|
1484
|
+
spans.length = 0;
|
|
1485
|
+
const y0 = row - reach - FOOTPRINT_SLACK;
|
|
1486
|
+
const y1 = row + 1 + reach + FOOTPRINT_SLACK;
|
|
1487
|
+
for (const poly of footprint.polygons) if (poly.length >= 6) bandExtent(poly, padding, y0, y1, spans);
|
|
1488
|
+
const at = row * cw;
|
|
1489
|
+
for (const [a, b] of spans) {
|
|
1490
|
+
const from = Math.max(0, Math.ceil(a - 1 - reach - FOOTPRINT_SLACK));
|
|
1491
|
+
const to = Math.min(cw - 1, Math.floor(b + reach + FOOTPRINT_SLACK));
|
|
1492
|
+
for (let x = from; x <= to; x++) mask[at + x] = 1;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
if (mask.every((v) => v === 1)) return whole();
|
|
1496
|
+
// The rectangles the free list is split by: the set rounded OUT to bands of
|
|
1497
|
+
// `FOOTPRINT_BAND_ROWS` rows — each band the columns any of its rows owns,
|
|
1498
|
+
// as runs, merged downwards while a run repeats exactly. Disjoint, a superset
|
|
1499
|
+
// of the set, and in a fixed order (top to bottom, then left to right), so
|
|
1500
|
+
// the free list is split the same way every time. Only the free list reads
|
|
1501
|
+
// them; what a cell owns, what is drawn and what the footprint test compares
|
|
1502
|
+
// is the exact set.
|
|
1503
|
+
const rects: Rect[] = [];
|
|
1504
|
+
let open = new Map<number, Rect>();
|
|
1505
|
+
const columns = new Uint8Array(cw);
|
|
1506
|
+
for (let band = 0; band < ch; band += FOOTPRINT_BAND_ROWS) {
|
|
1507
|
+
const rows = Math.min(FOOTPRINT_BAND_ROWS, ch - band);
|
|
1508
|
+
columns.fill(0);
|
|
1509
|
+
for (let row = band; row < band + rows; row++) for (let x = 0; x < cw; x++) if (mask[row * cw + x] === 1) columns[x] = 1;
|
|
1510
|
+
const next = new Map<number, Rect>();
|
|
1511
|
+
let x = 0;
|
|
1512
|
+
while (x < cw) {
|
|
1513
|
+
if (columns[x] === 0) {
|
|
1514
|
+
x++;
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
const start = x;
|
|
1518
|
+
while (x < cw && columns[x] === 1) x++;
|
|
1519
|
+
const key = start * (cw + 1) + x;
|
|
1520
|
+
const continued = open.get(key);
|
|
1521
|
+
if (continued !== undefined && continued.y + continued.h === band) {
|
|
1522
|
+
continued.h += rows;
|
|
1523
|
+
next.set(key, continued);
|
|
1524
|
+
} else {
|
|
1525
|
+
const rect = { x: start, y: band, w: x - start, h: rows };
|
|
1526
|
+
rects.push(rect);
|
|
1527
|
+
next.set(key, rect);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
open = next;
|
|
1531
|
+
}
|
|
1532
|
+
// The bounding box is the exact set's: a candidate is placed so that box
|
|
1533
|
+
// lies in a free rectangle, which no other cell's bands reach.
|
|
1534
|
+
let minX = cw;
|
|
1535
|
+
let minY = ch;
|
|
1536
|
+
let maxX = 0;
|
|
1537
|
+
let maxY = 0;
|
|
1538
|
+
for (let y = 0; y < ch; y++) {
|
|
1539
|
+
for (let x = 0; x < cw; x++) {
|
|
1540
|
+
if (mask[y * cw + x] === 0) continue;
|
|
1541
|
+
minX = Math.min(minX, x);
|
|
1542
|
+
minY = Math.min(minY, y);
|
|
1543
|
+
maxX = Math.max(maxX, x + 1);
|
|
1544
|
+
maxY = Math.max(maxY, y + 1);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// A footprint that owns nothing (no polygon of three vertices) still owns a
|
|
1548
|
+
// texel: a cell that claimed no texel could be placed on top of another and
|
|
1549
|
+
// the packer would have nothing to keep apart. Its top-left texel is the
|
|
1550
|
+
// smallest claim, and it is the cell's own.
|
|
1551
|
+
if (rects.length === 0) {
|
|
1552
|
+
mask[0] = 1;
|
|
1553
|
+
rects.push({ x: 0, y: 0, w: 1, h: 1 });
|
|
1554
|
+
minX = 0;
|
|
1555
|
+
minY = 0;
|
|
1556
|
+
maxX = 1;
|
|
1557
|
+
maxY = 1;
|
|
1558
|
+
}
|
|
1559
|
+
let count = 0;
|
|
1560
|
+
for (const v of mask) count += v;
|
|
1561
|
+
return { width: cw, height: ch, mask, rects, bbox: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, whole: false, owned: count };
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** Whether two placed cells' protected sets share a texel. */
|
|
1565
|
+
function shapesMeet(a: CellShape, ax: number, ay: number, b: CellShape, bx: number, by: number): boolean {
|
|
1566
|
+
const x0 = Math.max(ax, bx);
|
|
1567
|
+
const y0 = Math.max(ay, by);
|
|
1568
|
+
const x1 = Math.min(ax + a.width, bx + b.width);
|
|
1569
|
+
const y1 = Math.min(ay + a.height, by + b.height);
|
|
1570
|
+
for (let y = y0; y < y1; y++) {
|
|
1571
|
+
const rowA = (y - ay) * a.width - ax;
|
|
1572
|
+
const rowB = (y - by) * b.width - bx;
|
|
1573
|
+
for (let x = x0; x < x1; x++) if (a.mask[rowA + x] === 1 && b.mask[rowB + x] === 1) return true;
|
|
1574
|
+
}
|
|
1575
|
+
return false;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* MaxRects' split of every free rectangle `put` overlaps, then its prune —
|
|
1580
|
+
* `packOnePage`'s, over the first `length` entries of `free`, written back in
|
|
1581
|
+
* place; returns the new length. Four savings, none of which changes a
|
|
1582
|
+
* rectangle or the list's order:
|
|
1583
|
+
*
|
|
1584
|
+
* * a free rectangle the placement did not touch is never tested for
|
|
1585
|
+
* containment. Before the split no free rectangle lay inside another (the
|
|
1586
|
+
* prune's own postcondition), and every new piece lies inside the
|
|
1587
|
+
* rectangle it was cut from, so an untouched rectangle inside a new piece
|
|
1588
|
+
* would have lain inside that one — the test `packOnePage` makes for it
|
|
1589
|
+
* always answers "not contained";
|
|
1590
|
+
* * ⭐ (issue #1099, measured on production rigs) a free rectangle narrower
|
|
1591
|
+
* than `minW` or shorter than `minH` is dropped. The caller passes the
|
|
1592
|
+
* least bounding box of every cell the pass places, so such a rectangle can
|
|
1593
|
+
* never be a candidate, and neither can any piece later cut from it (a
|
|
1594
|
+
* piece lies inside the rectangle it was cut from); and a rectangle a
|
|
1595
|
+
* dropped one contains is itself too small. So every rectangle that could
|
|
1596
|
+
* ever be a candidate is kept, in the order the full prune keeps it. What
|
|
1597
|
+
* it removes is the staircase of slivers an irregular hull's bands cut
|
|
1598
|
+
* along its edge: on a production-shaped set (30 regions, 200 to 900 px,
|
|
1599
|
+
* hulls near their rectangles, a `free` page about 2023x2046) the pack took
|
|
1600
|
+
* 18.5 s with the slivers kept and spent 95 % of it in this prune. A piece
|
|
1601
|
+
* that would be dropped is not made at all (issue #1165): it takes part in
|
|
1602
|
+
* no test and is not written back, so it changes no other entry's index
|
|
1603
|
+
* order either;
|
|
1604
|
+
* * the edge index (issue #1102), below;
|
|
1605
|
+
* * (issue #1165) the rebuilt list, each entry's side and the edge lists are
|
|
1606
|
+
* kept between calls (`splitNext` and its neighbours) and the list is
|
|
1607
|
+
* written back over itself by index, never emptied and refilled: on the
|
|
1608
|
+
* production rigs the emptying and the pushes that refilled it were most
|
|
1609
|
+
* of this function's time, and none of it was arithmetic.
|
|
1610
|
+
*/
|
|
1611
|
+
function splitFree(free: Rect[], length: number, put: Rect, minW: number, minH: number, edgeIndex: boolean, tally?: PassTally): number {
|
|
1612
|
+
const px0 = put.x;
|
|
1613
|
+
const py0 = put.y;
|
|
1614
|
+
const px1 = put.x + put.w;
|
|
1615
|
+
const py1 = put.y + put.h;
|
|
1616
|
+
// A touched entry yields at most four pieces.
|
|
1617
|
+
if (splitSide.length < length * 4) {
|
|
1618
|
+
const capacity = Math.max(64, length * 8);
|
|
1619
|
+
splitSide = new Int8Array(capacity);
|
|
1620
|
+
splitLeft = new Int32Array(capacity);
|
|
1621
|
+
splitRight = new Int32Array(capacity);
|
|
1622
|
+
splitAbove = new Int32Array(capacity);
|
|
1623
|
+
splitBelow = new Int32Array(capacity);
|
|
1624
|
+
}
|
|
1625
|
+
const next = splitNext;
|
|
1626
|
+
const side = splitSide;
|
|
1627
|
+
let n = 0;
|
|
1628
|
+
for (let k = 0; k < length; k++) {
|
|
1629
|
+
const fr = free[k];
|
|
1630
|
+
const fx1 = fr.x + fr.w;
|
|
1631
|
+
const fy1 = fr.y + fr.h;
|
|
1632
|
+
if (!(px0 < fx1 && px1 > fr.x && py0 < fy1 && py1 > fr.y)) {
|
|
1633
|
+
next[n] = fr;
|
|
1634
|
+
side[n++] = UNTOUCHED;
|
|
1635
|
+
continue;
|
|
1636
|
+
}
|
|
1637
|
+
if (px0 > fr.x && px0 - fr.x >= minW && fr.h >= minH) {
|
|
1638
|
+
next[n] = { x: fr.x, y: fr.y, w: px0 - fr.x, h: fr.h };
|
|
1639
|
+
side[n++] = LEFT_OF_PUT;
|
|
1640
|
+
}
|
|
1641
|
+
if (px1 < fx1 && fx1 - px1 >= minW && fr.h >= minH) {
|
|
1642
|
+
next[n] = { x: px1, y: fr.y, w: fx1 - px1, h: fr.h };
|
|
1643
|
+
side[n++] = RIGHT_OF_PUT;
|
|
1644
|
+
}
|
|
1645
|
+
if (py0 > fr.y && fr.w >= minW && py0 - fr.y >= minH) {
|
|
1646
|
+
next[n] = { x: fr.x, y: fr.y, w: fr.w, h: py0 - fr.y };
|
|
1647
|
+
side[n++] = ABOVE_PUT;
|
|
1648
|
+
}
|
|
1649
|
+
if (py1 < fy1 && fr.w >= minW && fy1 - py1 >= minH) {
|
|
1650
|
+
next[n] = { x: fr.x, y: py1, w: fr.w, h: fy1 - py1 };
|
|
1651
|
+
side[n++] = BELOW_PUT;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
// ⭐ The edge index (issue #1102): a piece's containers are looked for only
|
|
1655
|
+
// among the entries that share the edge it was cut along. A piece cut from
|
|
1656
|
+
// the left of `put` ends at `put.x` and keeps its parent's rows, which meet
|
|
1657
|
+
// `put`'s rows (the parent overlapped `put`). A rectangle containing it covers
|
|
1658
|
+
// those rows from the piece's left edge to at least `put.x`; if it reached
|
|
1659
|
+
// past `put.x` it would overlap `put` — and no entry of `next` does (an
|
|
1660
|
+
// untouched rectangle by definition, a piece by construction). So every
|
|
1661
|
+
// container of a left piece ENDS at `put.x`; likewise a right piece's starts
|
|
1662
|
+
// at `put.x + put.w`, an upper piece's ends at `put.y` and a lower piece's
|
|
1663
|
+
// starts at `put.y + put.h`. The test is the full prune's test, asked of the
|
|
1664
|
+
// only entries that can answer it yes, so `contained` is the same boolean and
|
|
1665
|
+
// the list the same list in the same order. Measured where the footprint
|
|
1666
|
+
// pass does work (`fixtures/polypack_shapes.ts`, seed 1105, the searches a
|
|
1667
|
+
// `polygon` pack of it runs): 415,916,651 containment tests by the full scan,
|
|
1668
|
+
// 15,407,179 by the index, 3.2 s → 2.3 s; with the early stop off as well,
|
|
1669
|
+
// 11,928,721,590 → 537,215,014 and 101 s → 14.5 s. `edgeIndex: false` is
|
|
1670
|
+
// that scan — every usable entry in `left` — kept so the selftest can hold
|
|
1671
|
+
// that the index finds what the scan finds (`PK94`) and for nothing else.
|
|
1672
|
+
const left = splitLeft;
|
|
1673
|
+
const right = splitRight;
|
|
1674
|
+
const above = splitAbove;
|
|
1675
|
+
const below = splitBelow;
|
|
1676
|
+
let nLeft = 0;
|
|
1677
|
+
let nRight = 0;
|
|
1678
|
+
let nAbove = 0;
|
|
1679
|
+
let nBelow = 0;
|
|
1680
|
+
for (let j = 0; j < n; j++) {
|
|
1681
|
+
const r = next[j];
|
|
1682
|
+
if (r.w < minW || r.h < minH) continue;
|
|
1683
|
+
if (!edgeIndex) {
|
|
1684
|
+
left[nLeft++] = j;
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
if (r.x + r.w === px0) left[nLeft++] = j;
|
|
1688
|
+
if (r.x === px1) right[nRight++] = j;
|
|
1689
|
+
if (r.y + r.h === py0) above[nAbove++] = j;
|
|
1690
|
+
if (r.y === py1) below[nBelow++] = j;
|
|
1691
|
+
}
|
|
1692
|
+
let w = 0;
|
|
1693
|
+
let tests = 0;
|
|
1694
|
+
for (let i = 0; i < n; i++) {
|
|
1695
|
+
const piece = next[i];
|
|
1696
|
+
if (piece.w < minW || piece.h < minH) continue;
|
|
1697
|
+
const cut = side[i];
|
|
1698
|
+
if (cut === UNTOUCHED) {
|
|
1699
|
+
free[w++] = piece;
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
const list = !edgeIndex || cut === LEFT_OF_PUT ? left : cut === RIGHT_OF_PUT ? right : cut === ABOVE_PUT ? above : below;
|
|
1703
|
+
const count = !edgeIndex || cut === LEFT_OF_PUT ? nLeft : cut === RIGHT_OF_PUT ? nRight : cut === ABOVE_PUT ? nAbove : nBelow;
|
|
1704
|
+
let contained = false;
|
|
1705
|
+
for (let t = 0; t < count; t++) {
|
|
1706
|
+
const j = list[t];
|
|
1707
|
+
if (i === j) continue;
|
|
1708
|
+
tests++;
|
|
1709
|
+
const other = next[j];
|
|
1710
|
+
// On a mutual containment (two identical rectangles) the later index
|
|
1711
|
+
// loses, so exactly one survives and it is always the same one.
|
|
1712
|
+
if (rectContains(other, piece) && (j < i || !rectContains(piece, other))) {
|
|
1713
|
+
contained = true;
|
|
1714
|
+
break;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
if (!contained) free[w++] = piece;
|
|
1718
|
+
}
|
|
1719
|
+
if (tally !== undefined) {
|
|
1720
|
+
tally.bandSplits++;
|
|
1721
|
+
tally.containmentTests += tests;
|
|
1722
|
+
tally.entriesRead += length;
|
|
1723
|
+
}
|
|
1724
|
+
return w;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** Whether `a` contains `b`. */
|
|
1728
|
+
function rectContains(a: Rect, b: Rect): boolean {
|
|
1729
|
+
return b.x >= a.x && b.y >= a.y && b.x + b.w <= a.x + a.w && b.y + b.h <= a.y + a.h;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* `splitFree`'s working storage, kept between calls so a split allocates
|
|
1734
|
+
* nothing but the pieces it cuts (issue #1165). `splitFree` is never
|
|
1735
|
+
* re-entered and reads no entry past the `n` it wrote this call, so what an
|
|
1736
|
+
* earlier call left behind is never read.
|
|
1737
|
+
*/
|
|
1738
|
+
const splitNext: Rect[] = [];
|
|
1739
|
+
let splitSide = new Int8Array(0);
|
|
1740
|
+
let splitLeft = new Int32Array(0);
|
|
1741
|
+
let splitRight = new Int32Array(0);
|
|
1742
|
+
let splitAbove = new Int32Array(0);
|
|
1743
|
+
let splitBelow = new Int32Array(0);
|
|
1744
|
+
/** `splitFreeByCell`'s near list, kept between calls for the same reason. */
|
|
1745
|
+
const splitNear: Rect[] = [];
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Every band of one placed cell split out of the first `length` entries of
|
|
1749
|
+
* `free` (`splitFree`, once per band, top to bottom); returns the new length.
|
|
1750
|
+
*
|
|
1751
|
+
* ⭐ **The list is split near the cell only** (issue #1165). Before a cell's
|
|
1752
|
+
* bands are split, the list is parted once into the entries that touch the
|
|
1753
|
+
* bands' bounding box — overlapping it or sharing an edge with it — and the
|
|
1754
|
+
* rest; every band is then split over the near part alone, and the far part is
|
|
1755
|
+
* written back first, the near part after it. Nothing the far part holds could
|
|
1756
|
+
* have been read:
|
|
1757
|
+
*
|
|
1758
|
+
* * a far entry does not overlap any band (every band lies inside the box),
|
|
1759
|
+
* so every split leaves it untouched — kept as it is, never tested;
|
|
1760
|
+
* * a far entry contains no piece any band cuts. A piece cut from the left of
|
|
1761
|
+
* a band ends at the band's left edge `x` and keeps a row `y` of the band
|
|
1762
|
+
* (its parent overlapped the band), so a container of it holds the texel
|
|
1763
|
+
* `(x − 1, y)` — inside the box or against its left edge, which an entry
|
|
1764
|
+
* that neither overlaps the box nor shares an edge with it cannot hold.
|
|
1765
|
+
* Likewise for the other three sides. And every piece is cut from a near
|
|
1766
|
+
* entry, so it is in the near part for the next band.
|
|
1767
|
+
*
|
|
1768
|
+
* So every split makes the decisions over the near part that it makes over the
|
|
1769
|
+
* whole list, and the far part comes through every split unchanged. ⚠️ What
|
|
1770
|
+
* changes is the ORDER of the list: the far entries move ahead of the near ones.
|
|
1771
|
+
* That decides nothing, because nothing that reads the list reads its order:
|
|
1772
|
+
*
|
|
1773
|
+
* * a split keeps every untouched entry, and keeps a piece unless another
|
|
1774
|
+
* usable entry contains it, the later index losing only between two equal
|
|
1775
|
+
* rectangles — and which of two equal rectangles survives is invisible,
|
|
1776
|
+
* they are the same rectangle — so the SET of rectangles after a split is
|
|
1777
|
+
* a function of the set before it;
|
|
1778
|
+
* * a candidate is chosen by a total order on its score, its free
|
|
1779
|
+
* rectangle's corner and its anchor (`packOnePageByFootprint`); two free
|
|
1780
|
+
* rectangles that tie on all of those put the cell at the same place.
|
|
1781
|
+
*
|
|
1782
|
+
* So the placements are the ones the whole-list split makes, decision for
|
|
1783
|
+
* decision. What it saves: on three production rigs the near part is 10 to 22 %
|
|
1784
|
+
* of the list (about 90 entries of 940 on the slowest), and each of a cell's
|
|
1785
|
+
* 50 or so bands read the whole list. `partition: false` splits every band over
|
|
1786
|
+
* the whole list in order, as before — kept so the selftest can plant the
|
|
1787
|
+
* work back (`PK106`) and hold that the pages do not move (`PK94`, through
|
|
1788
|
+
* `edgeIndex: false`, which splits the whole list too).
|
|
1789
|
+
*/
|
|
1790
|
+
function splitFreeByCell(
|
|
1791
|
+
free: Rect[],
|
|
1792
|
+
length: number,
|
|
1793
|
+
cellX: number,
|
|
1794
|
+
cellY: number,
|
|
1795
|
+
rects: readonly Rect[],
|
|
1796
|
+
minW: number,
|
|
1797
|
+
minH: number,
|
|
1798
|
+
edgeIndex: boolean,
|
|
1799
|
+
partition: boolean,
|
|
1800
|
+
tally?: PassTally,
|
|
1801
|
+
): number {
|
|
1802
|
+
if (!edgeIndex || !partition) {
|
|
1803
|
+
let n = length;
|
|
1804
|
+
for (const r of rects) n = splitFree(free, n, { x: cellX + r.x, y: cellY + r.y, w: r.w, h: r.h }, minW, minH, edgeIndex, tally);
|
|
1805
|
+
return n;
|
|
1806
|
+
}
|
|
1807
|
+
let x0 = Infinity;
|
|
1808
|
+
let y0 = Infinity;
|
|
1809
|
+
let x1 = -Infinity;
|
|
1810
|
+
let y1 = -Infinity;
|
|
1811
|
+
for (const r of rects) {
|
|
1812
|
+
x0 = Math.min(x0, cellX + r.x);
|
|
1813
|
+
y0 = Math.min(y0, cellY + r.y);
|
|
1814
|
+
x1 = Math.max(x1, cellX + r.x + r.w);
|
|
1815
|
+
y1 = Math.max(y1, cellY + r.y + r.h);
|
|
1816
|
+
}
|
|
1817
|
+
const near = splitNear;
|
|
1818
|
+
let nNear = 0;
|
|
1819
|
+
let nFar = 0;
|
|
1820
|
+
for (let k = 0; k < length; k++) {
|
|
1821
|
+
const fr = free[k];
|
|
1822
|
+
if (fr.x <= x1 && fr.x + fr.w >= x0 && fr.y <= y1 && fr.y + fr.h >= y0) near[nNear++] = fr;
|
|
1823
|
+
else free[nFar++] = fr;
|
|
1824
|
+
}
|
|
1825
|
+
if (tally !== undefined) tally.entriesRead += length;
|
|
1826
|
+
for (const r of rects) nNear = splitFree(near, nNear, { x: cellX + r.x, y: cellY + r.y, w: r.w, h: r.h }, minW, minH, true, tally);
|
|
1827
|
+
for (let k = 0; k < nNear; k++) free[nFar + k] = near[k];
|
|
1828
|
+
return nFar + nNear;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/** Which side of the placed rectangle a free-list piece was cut from (`splitFree`) — an index into its edge lists. */
|
|
1832
|
+
const LEFT_OF_PUT = 0;
|
|
1833
|
+
const RIGHT_OF_PUT = 1;
|
|
1834
|
+
const ABOVE_PUT = 2;
|
|
1835
|
+
const BELOW_PUT = 3;
|
|
1836
|
+
const UNTOUCHED = -1;
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* `packOnePage` with the free-space test replaced by the footprint test
|
|
1840
|
+
* (issue #1099) — the placement `shape: 'polygon'` makes.
|
|
1841
|
+
*
|
|
1842
|
+
* What changes is what the free list is the free space OF. `packOnePage`
|
|
1843
|
+
* splits it by each placed cell; this splits it by each placed cell's
|
|
1844
|
+
* protected set rounded out to bands of `FOOTPRINT_BAND_ROWS` rows
|
|
1845
|
+
* (`CellShape.rects`), so a free rectangle may lie inside an earlier cell
|
|
1846
|
+
* wherever that cell's region draws nothing. The bands only make the free list
|
|
1847
|
+
* coarser — a free rectangle is still clear of every owned texel — and they
|
|
1848
|
+
* are what keeps it short: per row, a 60-part set of ellipse hulls packed on a
|
|
1849
|
+
* `free` page in over ten minutes; in bands of 8 rows, in 14.9 s against the
|
|
1850
|
+
* rectangle pass's 0.6 s, the same page — and with `splitFree`'s sliver drop
|
|
1851
|
+
* beside them, in 5.8 s against 2.2 s (a loaded machine). Everything else is
|
|
1852
|
+
* MaxRects as `packOnePage` runs it:
|
|
1853
|
+
*
|
|
1854
|
+
* * a candidate is a free rectangle that holds the cell's protected set's
|
|
1855
|
+
* bounding box, anchored at its top-left corner — the cell placed so that
|
|
1856
|
+
* box's corner lands there, which may put the cell's own unclaimed
|
|
1857
|
+
* texels over a neighbour's — and whose cell stays on the page
|
|
1858
|
+
* (*Candidates*, below, for the rules measured beside it);
|
|
1859
|
+
* * the score is Best Short Side Fit over the part of the free rectangle the
|
|
1860
|
+
* box uses from its corner (the box itself, under the default anchor),
|
|
1861
|
+
* with the same total tie-break (smallest long-side leftover, then
|
|
1862
|
+
* topmost, then leftmost, then the first anchor);
|
|
1863
|
+
* * the free list is split and pruned by the same code (`splitFree`), once
|
|
1864
|
+
* per rectangle of the protected set.
|
|
1865
|
+
*
|
|
1866
|
+
* ⇒ For a cell whose set is the whole cell (`CellShape.whole`), the box IS the
|
|
1867
|
+
* cell, the anchor IS the free rectangle's corner and the split IS the cell's:
|
|
1868
|
+
* a pack with no mesh region makes `packOnePage`'s decisions one for one
|
|
1869
|
+
* (`PK82`).
|
|
1870
|
+
*
|
|
1871
|
+
* ## Candidates — the second anchor (issue #1104)
|
|
1872
|
+
*
|
|
1873
|
+
* The first anchor puts the owned box's corner on the free rectangle's corner,
|
|
1874
|
+
* the cell then reaching up and left of it by the box's offset. So a cell whose
|
|
1875
|
+
* box is inset by `(dx, dy)` can only go where a free rectangle starts at least
|
|
1876
|
+
* `dx` from the page's left and `dy` from its top — on an empty page, nowhere.
|
|
1877
|
+
* A large region whose mesh draws a small part of it is then every footprint
|
|
1878
|
+
* pass's first miss: on `fixtures/polypack_shapes.ts`'s seed 1107, and on the
|
|
1879
|
+
* production rig it stands for, every footprint pass lost to the rectangle pass
|
|
1880
|
+
* and the polygon pack was the `rect` pack to the texel.
|
|
1881
|
+
*
|
|
1882
|
+
* The second anchor puts the cell's own corner there instead, the box inset by
|
|
1883
|
+
* its offset, and is a candidate when the free rectangle holds the box where it
|
|
1884
|
+
* then lies. The two coincide when the box starts at the cell's corner, so it
|
|
1885
|
+
* is only tried for an inset box — never for a whole cell, which is why a pack
|
|
1886
|
+
* with no mesh region is unchanged by it. Both keep the box inside a free
|
|
1887
|
+
* rectangle and the cell on the page, so no texel test, gutter or atlas field
|
|
1888
|
+
* changes: `bounds` stays inside `size`, as the runtime's UVs assume. Which
|
|
1889
|
+
* free rectangles get it is the rule (`FOOTPRINT_ANCHORS`); which rule's pack
|
|
1890
|
+
* is written is `packAtlas`'s choice by Σ page area.
|
|
1891
|
+
*
|
|
1892
|
+
* 🔒 **The footprint test is then stated outright rather than trusted to the
|
|
1893
|
+
* bookkeeping.** A candidate inside a free rectangle cannot meet a placed set,
|
|
1894
|
+
* because no free rectangle overlaps one; every placement is nevertheless
|
|
1895
|
+
* checked texel for texel against every set already placed, and a meeting is
|
|
1896
|
+
* an internal error, not a placement.
|
|
1897
|
+
*
|
|
1898
|
+
* It terminates and is deterministic for `packOnePage`'s reasons: one pass
|
|
1899
|
+
* over the cells in the caller's order, each over a finite free list, every
|
|
1900
|
+
* choice made by a total order, and no input but the cells and the page.
|
|
1901
|
+
*/
|
|
1902
|
+
function packOnePageByFootprint(
|
|
1903
|
+
shapes: readonly CellShape[],
|
|
1904
|
+
pageW: number,
|
|
1905
|
+
pageH: number,
|
|
1906
|
+
maxBottom = Infinity,
|
|
1907
|
+
stopAtMiss = false,
|
|
1908
|
+
edgeIndex = true,
|
|
1909
|
+
tally?: PassTally,
|
|
1910
|
+
anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
|
|
1911
|
+
partition = true,
|
|
1912
|
+
): Array<Rect | null> {
|
|
1913
|
+
// The free list is `free`'s first `freeLength` entries (`splitFree`).
|
|
1914
|
+
const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
|
|
1915
|
+
let freeLength = 1;
|
|
1916
|
+
const placed: Array<Rect | null> = [];
|
|
1917
|
+
const owned: Array<{ shape: CellShape; x: number; y: number }> = [];
|
|
1918
|
+
// The least box any cell of this pass needs — what a free rectangle must
|
|
1919
|
+
// hold to be a candidate for anything (`splitFree`, the second saving).
|
|
1920
|
+
let minW = Infinity;
|
|
1921
|
+
let minH = Infinity;
|
|
1922
|
+
for (const shape of shapes) {
|
|
1923
|
+
minW = Math.min(minW, shape.bbox.w);
|
|
1924
|
+
minH = Math.min(minH, shape.bbox.h);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
for (const shape of shapes) {
|
|
1928
|
+
const box = shape.bbox;
|
|
1929
|
+
let best: { x: number; y: number; frX: number; frY: number; anchor: number } | null = null;
|
|
1930
|
+
let bestShort = Infinity;
|
|
1931
|
+
let bestLong = Infinity;
|
|
1932
|
+
let held = false;
|
|
1933
|
+
// The second anchor differs from the first only when the box is inset.
|
|
1934
|
+
const anchorCount = anchors !== 'box' && (box.x !== 0 || box.y !== 0) ? 2 : 1;
|
|
1935
|
+
for (let f = 0; f < freeLength; f++) {
|
|
1936
|
+
const fr = free[f];
|
|
1937
|
+
if (fr.w < box.w || fr.h < box.h) continue;
|
|
1938
|
+
held = true;
|
|
1939
|
+
let firstOffPage = false;
|
|
1940
|
+
for (let anchor = 0; anchor < anchorCount; anchor++) {
|
|
1941
|
+
// Anchor 0: the owned box's corner on the free corner. Anchor 1: the
|
|
1942
|
+
// cell's own corner there, the box then inset by its own offset.
|
|
1943
|
+
const x = anchor === 0 ? fr.x - box.x : fr.x;
|
|
1944
|
+
const y = anchor === 0 ? fr.y - box.y : fr.y;
|
|
1945
|
+
const usedW = anchor === 0 ? box.w : box.x + box.w;
|
|
1946
|
+
const usedH = anchor === 0 ? box.h : box.y + box.h;
|
|
1947
|
+
if (usedW > fr.w || usedH > fr.h) continue;
|
|
1948
|
+
const offPage = x < 0 || y < 0 || x + shape.width > pageW || y + shape.height > pageH;
|
|
1949
|
+
if (anchor === 0) firstOffPage = offPage;
|
|
1950
|
+
// `box-else-cell`: the second anchor only where the first left the page.
|
|
1951
|
+
if (anchor === 1 && anchors === 'box-else-cell' && !firstOffPage) continue;
|
|
1952
|
+
if (offPage) {
|
|
1953
|
+
if (anchor === 0 && tally !== undefined) tally.boxAnchorRefused++;
|
|
1954
|
+
continue;
|
|
1955
|
+
}
|
|
1956
|
+
const leftoverW = fr.w - usedW;
|
|
1957
|
+
const leftoverH = fr.h - usedH;
|
|
1958
|
+
const short = Math.min(leftoverW, leftoverH);
|
|
1959
|
+
const long = Math.max(leftoverW, leftoverH);
|
|
1960
|
+
if (best !== null) {
|
|
1961
|
+
if (short > bestShort) continue;
|
|
1962
|
+
if (short === bestShort) {
|
|
1963
|
+
if (long > bestLong) continue;
|
|
1964
|
+
if (long === bestLong) {
|
|
1965
|
+
if (fr.y > best.frY) continue;
|
|
1966
|
+
if (fr.y === best.frY) {
|
|
1967
|
+
if (fr.x > best.frX) continue;
|
|
1968
|
+
if (fr.x === best.frX && anchor >= best.anchor) continue;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
best = { x, y, frX: fr.x, frY: fr.y, anchor };
|
|
1974
|
+
bestShort = short;
|
|
1975
|
+
bestLong = long;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
if (best === null) {
|
|
1979
|
+
if (held && tally !== undefined) tally.missedByAnchor++;
|
|
1980
|
+
placed.push(null);
|
|
1981
|
+
// A caller that keeps only a pass placing every cell has its answer
|
|
1982
|
+
// (`placePass`, `wholeOrNothing`): the rest are not placed.
|
|
1983
|
+
if (stopAtMiss) {
|
|
1984
|
+
while (placed.length < shapes.length) placed.push(null);
|
|
1985
|
+
return placed;
|
|
1986
|
+
}
|
|
1987
|
+
continue;
|
|
1988
|
+
}
|
|
1989
|
+
if (best.anchor === 1 && tally !== undefined) tally.secondAnchorPlaced++;
|
|
1990
|
+
const put: Rect = { x: best.x, y: best.y, w: shape.width, h: shape.height };
|
|
1991
|
+
for (const other of owned) {
|
|
1992
|
+
// Two cells that do not overlap cannot share a texel, and that answer
|
|
1993
|
+
// takes no texel to read; only an overlap is scanned, and only over the
|
|
1994
|
+
// overlap's own box (`shapesMeet`).
|
|
1995
|
+
const apart =
|
|
1996
|
+
put.x >= other.x + other.shape.width || other.x >= put.x + put.w || put.y >= other.y + other.shape.height || other.y >= put.y + put.h;
|
|
1997
|
+
if (apart) continue;
|
|
1998
|
+
if (shapesMeet(shape, put.x, put.y, other.shape, other.x, other.y)) {
|
|
1999
|
+
throw new CompileError(
|
|
2000
|
+
`internal: the footprint pass placed a ${shape.width}x${shape.height} cell at ${put.x},${put.y} over ` +
|
|
2001
|
+
`the footprint of the ${other.shape.width}x${other.shape.height} cell at ${other.x},${other.y}`,
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
placed.push(put);
|
|
2006
|
+
owned.push({ shape, x: put.x, y: put.y });
|
|
2007
|
+
if (put.y + put.h > maxBottom) {
|
|
2008
|
+
while (placed.length < shapes.length) placed.push(null);
|
|
2009
|
+
return placed;
|
|
2010
|
+
}
|
|
2011
|
+
freeLength = splitFreeByCell(free, freeLength, put.x, put.y, shape.rects, minW, minH, edgeIndex, partition, tally);
|
|
2012
|
+
}
|
|
2013
|
+
return placed;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/**
|
|
2017
|
+
* One footprint pass on an empty `pageW x pageH` page, run to its end, with
|
|
2018
|
+
* what it counted — exported for `tools/pack_anchor.ts` (issue #1104).
|
|
2019
|
+
*/
|
|
2020
|
+
export function footprintPass(
|
|
2021
|
+
shapes: readonly CellShape[],
|
|
2022
|
+
pageW: number,
|
|
2023
|
+
pageH: number,
|
|
2024
|
+
anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
|
|
2025
|
+
): { rects: Array<{ x: number; y: number; w: number; h: number } | null>; tally: PassTally } {
|
|
2026
|
+
const tally = emptyTally();
|
|
2027
|
+
const rects = packOnePageByFootprint(shapes, pageW, pageH, Infinity, false, true, tally, anchors);
|
|
2028
|
+
return { rects, tally };
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
2032
|
+
const isNumberList = (v: unknown): v is number[] => Array.isArray(v) && v.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* What every packed region's attachments draw, read off the `skeleton.json` the
|
|
2036
|
+
* build emitted — the footprints `shape: 'polygon'` packs by (issue #1099).
|
|
2037
|
+
*
|
|
2038
|
+
* The emitted text and not the model, because the footprint has to be what the
|
|
2039
|
+
* runtime will sample: the mesh's UVs as the file writes them.
|
|
2040
|
+
*
|
|
2041
|
+
* * The region an attachment samples is its `path`, else its `name`, else its
|
|
2042
|
+
* key — the runtime's rule — and a `sequence` samples `that + (start +
|
|
2043
|
+
* frame)` zero-padded to `digits`, for every frame (`start` 1 and `digits` 0
|
|
2044
|
+
* when the file omits them, the parser's defaults).
|
|
2045
|
+
* * A **region** attachment's footprint is its rectangle (`null` here).
|
|
2046
|
+
* * A **mesh**'s is the polygon of its first `hull` vertices, closed from the
|
|
2047
|
+
* last back to the first, and each of its triangles — the hull is the outer
|
|
2048
|
+
* loop of the triangles' union, so for a well-formed mesh the triangles add
|
|
2049
|
+
* nothing, and for one whose triangles reach outside its loop they protect
|
|
2050
|
+
* what is drawn. In the region's own texels: `(u · width, v · height)`.
|
|
2051
|
+
* * A **linked mesh** draws its source's geometry over its own region: the
|
|
2052
|
+
* mesh keyed `source` (`parent` before 4.3) in the slot `slot` names (its
|
|
2053
|
+
* own by default) of the skin `skin` names (`default` by default).
|
|
2054
|
+
* * A region with **any** rectangle use is a rectangle, and so is every
|
|
2055
|
+
* region whose drawing cannot be read as a polygon here — a mesh whose UVs
|
|
2056
|
+
* leave 0..1 (it samples outside its own rectangle, which only the
|
|
2057
|
+
* rectangle keeps the same), a hull or triangle list that does not index its
|
|
2058
|
+
* vertices, a link whose source is not a mesh. Several meshes over one
|
|
2059
|
+
* region give it the union of their polygons.
|
|
2060
|
+
*
|
|
2061
|
+
* Returns, per region name, its footprint, or `null` for a rectangle; a region
|
|
2062
|
+
* no attachment names is absent, and the packer treats it as a rectangle.
|
|
2063
|
+
* Nothing read here can be invented: every fallback is the rectangle, which is
|
|
2064
|
+
* the footprint `shape: 'rect'` gives everything.
|
|
2065
|
+
*/
|
|
2066
|
+
export function packFootprints(
|
|
2067
|
+
skeletonText: string,
|
|
2068
|
+
sizeOf: (region: string) => { width: number; height: number } | undefined,
|
|
2069
|
+
): Map<string, PackFootprint | null> {
|
|
2070
|
+
const doc: unknown = JSON.parse(skeletonText);
|
|
2071
|
+
const skins = isObject(doc) && Array.isArray(doc.skins) ? doc.skins : [];
|
|
2072
|
+
const tableOf = (skin: string, slot: string): Record<string, unknown> | undefined => {
|
|
2073
|
+
for (const s of skins) {
|
|
2074
|
+
if (!isObject(s) || s.name !== skin || !isObject(s.attachments)) continue;
|
|
2075
|
+
const table = s.attachments[slot];
|
|
2076
|
+
return isObject(table) ? table : undefined;
|
|
2077
|
+
}
|
|
2078
|
+
return undefined;
|
|
2079
|
+
};
|
|
2080
|
+
/** A mesh's UV polygons — hull loop, then triangles — or null when they cannot be read as polygons over its own rectangle. */
|
|
2081
|
+
const uvPolygonsOf = (mesh: Record<string, unknown>): number[][] | null => {
|
|
2082
|
+
const { uvs, hull, triangles } = mesh;
|
|
2083
|
+
if (!isNumberList(uvs) || uvs.length % 2 !== 0 || typeof hull !== 'number' || !isNumberList(triangles)) return null;
|
|
2084
|
+
const vertices = uvs.length / 2;
|
|
2085
|
+
if (!Number.isInteger(hull) || hull < 3 || hull > vertices || triangles.length % 3 !== 0) return null;
|
|
2086
|
+
if (uvs.some((n) => n < 0 || n > 1)) return null;
|
|
2087
|
+
if (triangles.some((t) => !Number.isInteger(t) || t < 0 || t >= vertices)) return null;
|
|
2088
|
+
const out: number[][] = [uvs.slice(0, 2 * hull)];
|
|
2089
|
+
for (let t = 0; t < triangles.length; t += 3) {
|
|
2090
|
+
const [a, b, c] = [triangles[t], triangles[t + 1], triangles[t + 2]];
|
|
2091
|
+
out.push([uvs[2 * a], uvs[2 * a + 1], uvs[2 * b], uvs[2 * b + 1], uvs[2 * c], uvs[2 * c + 1]]);
|
|
2092
|
+
}
|
|
2093
|
+
return out;
|
|
2094
|
+
};
|
|
2095
|
+
const uses = new Map<string, number[][][] | null>();
|
|
2096
|
+
const use = (region: string, polygons: number[][] | null): void => {
|
|
2097
|
+
const held = uses.get(region);
|
|
2098
|
+
if (held === null) return;
|
|
2099
|
+
if (polygons === null) uses.set(region, null);
|
|
2100
|
+
else if (held === undefined) uses.set(region, [polygons]);
|
|
2101
|
+
else held.push(polygons);
|
|
2102
|
+
};
|
|
2103
|
+
for (const s of skins) {
|
|
2104
|
+
if (!isObject(s) || !isObject(s.attachments)) continue;
|
|
2105
|
+
for (const [slot, table] of Object.entries(s.attachments)) {
|
|
2106
|
+
if (!isObject(table)) continue;
|
|
2107
|
+
for (const [key, att] of Object.entries(table)) {
|
|
2108
|
+
if (!isObject(att)) continue;
|
|
2109
|
+
const type = att.type ?? 'region';
|
|
2110
|
+
if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
|
|
2111
|
+
const base = typeof att.path === 'string' ? att.path : typeof att.name === 'string' ? att.name : key;
|
|
2112
|
+
const regions: string[] = [];
|
|
2113
|
+
const seq = att.sequence;
|
|
2114
|
+
if (isObject(seq) && typeof seq.count === 'number') {
|
|
2115
|
+
const start = typeof seq.start === 'number' ? seq.start : 1;
|
|
2116
|
+
const digits = typeof seq.digits === 'number' ? seq.digits : 0;
|
|
2117
|
+
for (let f = 0; f < seq.count; f++) regions.push(base + String(start + f).padStart(digits, '0'));
|
|
2118
|
+
} else regions.push(base);
|
|
2119
|
+
let polygons: number[][] | null = null;
|
|
2120
|
+
if (type === 'mesh') polygons = uvPolygonsOf(att);
|
|
2121
|
+
else if (type === 'linkedmesh') {
|
|
2122
|
+
const sourceKey = typeof att.source === 'string' ? att.source : typeof att.parent === 'string' ? att.parent : null;
|
|
2123
|
+
const source =
|
|
2124
|
+
sourceKey === null
|
|
2125
|
+
? undefined
|
|
2126
|
+
: tableOf(typeof att.skin === 'string' ? att.skin : 'default', typeof att.slot === 'string' ? att.slot : slot)?.[sourceKey];
|
|
2127
|
+
polygons = isObject(source) && source.type === 'mesh' ? uvPolygonsOf(source) : null;
|
|
2128
|
+
}
|
|
2129
|
+
for (const region of regions) use(region, polygons);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
const out = new Map<string, PackFootprint | null>();
|
|
2134
|
+
for (const [region, held] of uses) {
|
|
2135
|
+
const size = sizeOf(region);
|
|
2136
|
+
if (held === null || size === undefined) {
|
|
2137
|
+
out.set(region, null);
|
|
2138
|
+
continue;
|
|
2139
|
+
}
|
|
2140
|
+
const polygons = held.flat().map((poly) => poly.map((n, i) => n * (i % 2 === 0 ? size.width : size.height)));
|
|
2141
|
+
out.set(region, { polygons });
|
|
2142
|
+
}
|
|
2143
|
+
return out;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
/**
|
|
2147
|
+
* Copy the texels a region owns (`CellShape.mask`) onto a page, with the
|
|
2148
|
+
* values `extrudeCell` gives them — the second of `shape: 'polygon'`'s two
|
|
2149
|
+
* drawing passes (see `packAtlas`).
|
|
2150
|
+
*/
|
|
2151
|
+
function extrudeOwned(page: Plate, source: Plate, cellX: number, cellY: number, padding: number, shape: CellShape): void {
|
|
2152
|
+
const w = source.width;
|
|
2153
|
+
const h = source.height;
|
|
2154
|
+
const dst = page.data;
|
|
2155
|
+
const src = source.data;
|
|
2156
|
+
for (let cy = 0; cy < shape.height; cy++) {
|
|
2157
|
+
const sy = Math.max(0, Math.min(h - 1, cy - padding));
|
|
2158
|
+
const srcRow = sy * w * 4;
|
|
2159
|
+
const dstRow = (cellY + cy) * page.width * 4;
|
|
2160
|
+
for (let cx = 0; cx < shape.width; cx++) {
|
|
2161
|
+
if (shape.mask[cy * shape.width + cx] === 0) continue;
|
|
2162
|
+
const sx = Math.max(0, Math.min(w - 1, cx - padding));
|
|
2163
|
+
const s = srcRow + sx * 4;
|
|
2164
|
+
const d = dstRow + (cellX + cx) * 4;
|
|
2165
|
+
dst[d] = src[s];
|
|
2166
|
+
dst[d + 1] = src[s + 1];
|
|
2167
|
+
dst[d + 2] = src[s + 2];
|
|
2168
|
+
dst[d + 3] = src[s + 3];
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
/**
|
|
2174
|
+
* The packing order, and it is stated rather than inherited.
|
|
2175
|
+
*
|
|
2176
|
+
* Descending by long side then by area is what makes a shelf packer behave; the
|
|
2177
|
+
* name is the final tie-break so that two parts of identical size always pack in
|
|
2178
|
+
* the same order. Region names are unique within a compile (a region name IS the
|
|
2179
|
+
* PNG basename, and `addImage` refuses a duplicate), so this is a TOTAL order —
|
|
2180
|
+
* which is the property `sort` needs for its result not to depend on the order
|
|
2181
|
+
* it was handed. `Bun.Glob` and `readdirSync` are both unsorted; nothing here
|
|
2182
|
+
* relies on the caller having sorted anything.
|
|
2183
|
+
*/
|
|
2184
|
+
function packOrder(a: PackInput, b: PackInput): number {
|
|
2185
|
+
const longA = Math.max(a.width, a.height);
|
|
2186
|
+
const longB = Math.max(b.width, b.height);
|
|
2187
|
+
if (longA !== longB) return longB - longA;
|
|
2188
|
+
const areaA = a.width * a.height;
|
|
2189
|
+
const areaB = b.width * b.height;
|
|
2190
|
+
if (areaA !== areaB) return areaB - areaA;
|
|
2191
|
+
return a.region < b.region ? -1 : a.region > b.region ? 1 : 0;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
/**
|
|
2195
|
+
* Copy one region's pixels onto a page, and fill its gutter by extending its
|
|
2196
|
+
* edges outwards.
|
|
2197
|
+
*
|
|
2198
|
+
* ⭐ **This is what makes the packed render agree with the unpacked one, and it is
|
|
2199
|
+
* not an optimisation.**
|
|
2200
|
+
* The rasteriser samples a page bilinearly and `bilinear` CLAMPS its taps to the
|
|
2201
|
+
* page's bounds ([`src/render.ts`](render.ts)) — so on an unpacked page, where
|
|
2202
|
+
* the region IS the page, a sample at the region's outer edge reads that edge
|
|
2203
|
+
* twice. Pack the same region into the middle of a bigger page and the clamp
|
|
2204
|
+
* stops happening: the second tap is now whatever is next door. Transparent
|
|
2205
|
+
* gutter is not a fix, it is a different wrong answer — the edge would fade.
|
|
2206
|
+
*
|
|
2207
|
+
* Extending the edge outwards reproduces the clamp exactly, because it makes the
|
|
2208
|
+
* neighbouring texel equal to the edge texel, which is what the clamp returned.
|
|
2209
|
+
* One pixel is all bilinear can reach; the gutter is `padding` pixels because a
|
|
2210
|
+
* consumer that mipmaps the page averages 2x2 blocks and 2 keeps that average
|
|
2211
|
+
* inside the region's own colours one level down. Hence `DEFAULT_PADDING = 2`:
|
|
2212
|
+
* 1 is the correctness floor, 2 is the floor plus one level of headroom, and 0
|
|
2213
|
+
* would put two unrelated drawings in adjacent texels.
|
|
2214
|
+
*/
|
|
2215
|
+
function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, padding: number): void {
|
|
2216
|
+
const w = source.width;
|
|
2217
|
+
const h = source.height;
|
|
2218
|
+
// Straight into the two buffers: a 1024x1024 page is a million pixels and
|
|
2219
|
+
// `Plate.get` allocates a tuple per read.
|
|
2220
|
+
const dst = page.data;
|
|
2221
|
+
const src = source.data;
|
|
2222
|
+
for (let cy = 0; cy < h + 2 * padding; cy++) {
|
|
2223
|
+
const sy = Math.max(0, Math.min(h - 1, cy - padding));
|
|
2224
|
+
const srcRow = sy * w * 4;
|
|
2225
|
+
const dstRow = (cellY + cy) * page.width * 4;
|
|
2226
|
+
for (let cx = 0; cx < w + 2 * padding; cx++) {
|
|
2227
|
+
const sx = Math.max(0, Math.min(w - 1, cx - padding));
|
|
2228
|
+
const s = srcRow + sx * 4;
|
|
2229
|
+
const d = dstRow + (cellX + cx) * 4;
|
|
2230
|
+
dst[d] = src[s];
|
|
2231
|
+
dst[d + 1] = src[s + 1];
|
|
2232
|
+
dst[d + 2] = src[s + 2];
|
|
2233
|
+
dst[d + 3] = src[s + 3];
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
/** Where every part lands and the size each page is written at — one candidate's whole pack, before anything is drawn. */
|
|
2239
|
+
interface PackLayout {
|
|
2240
|
+
/** page index -> the placements on it, in packing order. */
|
|
2241
|
+
perPage: Placement[][];
|
|
2242
|
+
/** page index -> the size that page is written at. */
|
|
2243
|
+
pageSizes: Array<{ width: number; height: number }>;
|
|
2244
|
+
placements: Placement[];
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* One whole pack's layout under one placement rule: the single-page search,
|
|
2249
|
+
* or — when the set does not fit one page — the spill, each page then shrunk
|
|
2250
|
+
* to the smallest that holds it. `shapes` absent is the rectangle pass
|
|
2251
|
+
* throughout (`rect`, and the first of `polygon`'s candidates); given, the
|
|
2252
|
+
* footprint pass under `anchors`.
|
|
2253
|
+
*/
|
|
2254
|
+
function layoutPack(
|
|
2255
|
+
sorted: readonly PackInput[],
|
|
2256
|
+
cells: Array<{ w: number; h: number }>,
|
|
2257
|
+
shapes: readonly CellShape[] | undefined,
|
|
2258
|
+
maxEdge: number,
|
|
2259
|
+
pageEdges: PageEdges,
|
|
2260
|
+
padding: number,
|
|
2261
|
+
anchors: FootprintAnchors,
|
|
2262
|
+
tally?: PassTally,
|
|
2263
|
+
stopAtMiss = true,
|
|
2264
|
+
partition = true,
|
|
2265
|
+
): PackLayout {
|
|
2266
|
+
const single = smallestPageFor(cells, maxEdge, pageEdges, shapes, anchors, tally, stopAtMiss, partition);
|
|
2267
|
+
|
|
2268
|
+
/** page index -> the placements on it, in packing order. */
|
|
2269
|
+
const perPage: Placement[][] = [];
|
|
2270
|
+
/** page index -> the size that page is written at. */
|
|
2271
|
+
const pageSizes: Array<{ width: number; height: number }> = [];
|
|
2272
|
+
const placements: Placement[] = [];
|
|
2273
|
+
if (single !== null) {
|
|
2274
|
+
perPage.push([]);
|
|
2275
|
+
pageSizes.push({ width: single.width, height: single.height });
|
|
2276
|
+
single.rects.forEach((rect, i) => {
|
|
2277
|
+
const place: Placement = {
|
|
2278
|
+
region: sorted[i].region,
|
|
2279
|
+
page: 0,
|
|
2280
|
+
x: rect.x + padding,
|
|
2281
|
+
y: rect.y + padding,
|
|
2282
|
+
width: sorted[i].width,
|
|
2283
|
+
height: sorted[i].height,
|
|
2284
|
+
};
|
|
2285
|
+
perPage[0].push(place);
|
|
2286
|
+
placements.push(place);
|
|
2287
|
+
});
|
|
2288
|
+
} else {
|
|
2289
|
+
// Spill. Which parts share a page is decided at the MAXIMUM size — that is
|
|
2290
|
+
// what makes the boundary deterministic and independent of the shrink below
|
|
2291
|
+
// — and parts are taken in packing order, whatever will not fit the current
|
|
2292
|
+
// page opening the next one.
|
|
2293
|
+
let remaining = sorted.map((input, i) => ({ input, cell: cells[i], shape: shapes?.[i] }));
|
|
2294
|
+
const shapesOf = (list: typeof remaining): CellShape[] | undefined =>
|
|
2295
|
+
shapes === undefined ? undefined : list.map((r) => r.shape ?? footprintCell(r.input.width, r.input.height, padding));
|
|
2296
|
+
while (remaining.length > 0) {
|
|
2297
|
+
const pageIndex = perPage.length;
|
|
2298
|
+
const attempt = placePass(
|
|
2299
|
+
remaining.map((r) => r.cell),
|
|
2300
|
+
shapesOf(remaining),
|
|
2301
|
+
maxEdge,
|
|
2302
|
+
maxEdge,
|
|
2303
|
+
Infinity,
|
|
2304
|
+
{ anchors, tally, partition },
|
|
2305
|
+
);
|
|
2306
|
+
const onPage: typeof remaining = [];
|
|
2307
|
+
const leftOver: typeof remaining = [];
|
|
2308
|
+
attempt.forEach((rect, i) => {
|
|
2309
|
+
if (rect === null) leftOver.push(remaining[i]);
|
|
2310
|
+
else onPage.push(remaining[i]);
|
|
2311
|
+
});
|
|
2312
|
+
if (onPage.length === 0) {
|
|
2313
|
+
// Unreachable: every cell was proven to fit an empty page above. Kept as
|
|
2314
|
+
// a named stop rather than an infinite loop if that ever stops holding.
|
|
2315
|
+
throw new CompileError(
|
|
2316
|
+
`packing stalled with ${remaining.length} region(s) left and an empty ${maxEdge}x${maxEdge} page`,
|
|
2317
|
+
);
|
|
2318
|
+
}
|
|
2319
|
+
// ⭐ Then the page is written at the smallest power-of-two pair that holds
|
|
2320
|
+
// the cells assigned to it, not at the maximum (issue #266, follow-up 3).
|
|
2321
|
+
// A spill used to write `pageSize x pageSize` for every page, so a set that
|
|
2322
|
+
// overflowed by one small part paid for a second full page of transparency
|
|
2323
|
+
// — 4 MiB of decoded RAM at the 2048 default for a part that might be
|
|
2324
|
+
// 64x64. Re-packing through the same search the single-page case uses is
|
|
2325
|
+
// what keeps "the page written is the smallest that holds what is on it"
|
|
2326
|
+
// one rule; a page whose own cells need the maximum simply gets it back.
|
|
2327
|
+
const shrunk = smallestPageFor(
|
|
2328
|
+
onPage.map((r) => r.cell),
|
|
2329
|
+
maxEdge,
|
|
2330
|
+
pageEdges,
|
|
2331
|
+
shapesOf(onPage),
|
|
2332
|
+
anchors,
|
|
2333
|
+
tally,
|
|
2334
|
+
stopAtMiss,
|
|
2335
|
+
partition,
|
|
2336
|
+
);
|
|
2337
|
+
if (shrunk === null) {
|
|
2338
|
+
// Unreachable for the same reason as the stall above: these cells were
|
|
2339
|
+
// just placed on a maxEdge page.
|
|
2340
|
+
throw new CompileError(`page ${pageIndex + 1} of the spill holds ${onPage.length} region(s) that no page fits`);
|
|
2341
|
+
}
|
|
2342
|
+
const onThisPage: Placement[] = [];
|
|
2343
|
+
shrunk.rects.forEach((rect, i) => {
|
|
2344
|
+
const place: Placement = {
|
|
2345
|
+
region: onPage[i].input.region,
|
|
2346
|
+
page: pageIndex,
|
|
2347
|
+
x: rect.x + padding,
|
|
2348
|
+
y: rect.y + padding,
|
|
2349
|
+
width: onPage[i].input.width,
|
|
2350
|
+
height: onPage[i].input.height,
|
|
2351
|
+
};
|
|
2352
|
+
onThisPage.push(place);
|
|
2353
|
+
placements.push(place);
|
|
2354
|
+
});
|
|
2355
|
+
perPage.push(onThisPage);
|
|
2356
|
+
pageSizes.push({ width: shrunk.width, height: shrunk.height });
|
|
2357
|
+
remaining = leftOver;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
return { perPage, pageSizes, placements };
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* Pack these parts onto shared pages.
|
|
2366
|
+
*
|
|
2367
|
+
* ## Page size
|
|
2368
|
+
*
|
|
2369
|
+
* `pageSize` is a MAXIMUM, not the size written. Both page edges are powers of
|
|
2370
|
+
* two and the pack is tried at every power-of-two pair up to that maximum, in
|
|
2371
|
+
* order of increasing area, so a four-part rig gets a 128x64 page rather than a
|
|
2372
|
+
* megabyte of transparency. Powers of two are not decoration: `region.x /
|
|
2373
|
+
* page.width` is the sampling coordinate every texel is read through, and a
|
|
2374
|
+
* power-of-two denominator makes that division exact in binary floating point —
|
|
2375
|
+
* which is what keeps a packed region's samples on the same grid as the unpacked
|
|
2376
|
+
* page's. With a non-power-of-two page the region origin itself would round, and
|
|
2377
|
+
* the one-bit residual described in this file's header would be two roundings
|
|
2378
|
+
* deep instead of one.
|
|
2379
|
+
*
|
|
2380
|
+
* Only when the whole set will not fit one page at the maximum does it spill.
|
|
2381
|
+
* **Which parts share a page is then decided at the maximum size** — that is what
|
|
2382
|
+
* makes the boundary deterministic — **and each page is written at the smallest
|
|
2383
|
+
* power-of-two pair that holds the cells assigned to it** (issue #266). So the
|
|
2384
|
+
* rule is the same one either way: the page written is the smallest that holds
|
|
2385
|
+
* what is on it. A spill used to write `pageSize x pageSize` for every page,
|
|
2386
|
+
* which charged a set that overflowed by one small part a second full page of
|
|
2387
|
+
* transparency — 4 MiB of decoded RAM at the 2048 default. A single part whose
|
|
2388
|
+
* cell is bigger than the maximum is refused by name — silently splitting one
|
|
2389
|
+
* drawing across two pages is not a thing the format can express.
|
|
2390
|
+
*
|
|
2391
|
+
* ## `pageEdges: 'free'` — a page sized to the parts rather than to a power of two (issue #860)
|
|
2392
|
+
*
|
|
2393
|
+
* Opt-in, and the default stays `pot`: every runtime accepts a power-of-two page
|
|
2394
|
+
* and the editor's own packer writes one by default — 9 of the 10 atlases in the
|
|
2395
|
+
* fetched `examples/` corpus are power-of-two on both edges. Under `free` the
|
|
2396
|
+
* search is `smallestFreePageFor`: every width from the widest cell up (issue
|
|
2397
|
+
* #872; a 32-px grid until then), the height the placement needs, least area
|
|
2398
|
+
* first, then squarer, then narrower, with two exact bounds that keep it cheap. Everything
|
|
2399
|
+
* else is shared — the MaxRects pass, the packing order, the spill rule (which
|
|
2400
|
+
* parts share a page is still decided at `maxEdge x maxEdge`) and `--page-size`
|
|
2401
|
+
* as the ceiling on both edges, floored to a power of two exactly as under `pot`.
|
|
2402
|
+
*
|
|
2403
|
+
* What it buys and what it costs, observed on two 20- and 22-part painting rigs:
|
|
2404
|
+
* the page goes from 1024x2048 to 967x1338 (2,097,152 to 1,293,846 texels,
|
|
2405
|
+
* -38.3 %) and from 512x2048 to 479x1166 (1,048,576 to 558,514, -46.7 %). Under
|
|
2406
|
+
* the 32-px grid #860 shipped with they were 1888x697 (-37.3 %) and 480x1166
|
|
2407
|
+
* (-46.6 %). The cost is the exactness the paragraph above describes:
|
|
2408
|
+
* `x / pageWidth` is no longer exact, so a REGION attachment's sampling
|
|
2409
|
+
* coordinate joins a mesh's at `PK05`'s bound of one least significant bit
|
|
2410
|
+
* against the loose build. It does not go past it, and no region's bytes change
|
|
2411
|
+
* (`PK02`'s lift-back holds under both).
|
|
2412
|
+
*
|
|
2413
|
+
* ## `shape: 'polygon'` — rectangles that overlap where nothing is drawn (issue #1099)
|
|
2414
|
+
*
|
|
2415
|
+
* Opt-in, and the default stays `rect`, which is this function exactly as it
|
|
2416
|
+
* was before the option: under `rect` no footprint is computed and every pass
|
|
2417
|
+
* is `packOnePage`. Under `polygon` every input carries the footprint its
|
|
2418
|
+
* attachments draw (`packFootprints`: a mesh's emitted hull, a region
|
|
2419
|
+
* attachment's rectangle), and:
|
|
2420
|
+
*
|
|
2421
|
+
* * **what a cell owns** is `footprintCell`'s set — the footprint grown by the
|
|
2422
|
+
* padding, clipped to the cell; two cells may overlap when they own no texel
|
|
2423
|
+
* in common, which between two rectangles is `rect`'s rule and between any
|
|
2424
|
+
* two footprints keeps them `2 · padding` apart (`CellShape`);
|
|
2425
|
+
* * **the placement** is `packOnePageByFootprint`, MaxRects with the free list
|
|
2426
|
+
* split by what each cell owns, run beside the rectangle pass on every page
|
|
2427
|
+
* tried and kept only when it is better (`placePass`);
|
|
2428
|
+
* * **the pack written** is the least Σ page area — over every page, a spill's
|
|
2429
|
+
* included — of three whole packs: `rect`'s, the footprint pack under the
|
|
2430
|
+
* owned box's anchor, and under `box-else-cell` (issue #1104), the earlier
|
|
2431
|
+
* on a tie. So a `polygon` pack is never larger in page area than `rect`'s
|
|
2432
|
+
* on any set, by construction, and a set with no mesh footprint is `rect`'s
|
|
2433
|
+
* pack to the byte (`PK82`). The per-page rule alone never promised that
|
|
2434
|
+
* across a spill: seed 1105 under `pot` packed 8,388,608 texels against
|
|
2435
|
+
* `rect`'s 6,291,456 before it (`PK96`). It costs the three packs' searches
|
|
2436
|
+
* — no search is shared, since a free search's bounds depend on the best
|
|
2437
|
+
* page its own passes found;
|
|
2438
|
+
* * **the pixels** are drawn in two passes: every cell whole in packing order,
|
|
2439
|
+
* as under `rect`, then every region's owned texels again with its own
|
|
2440
|
+
* values. The owned sets are disjoint, so the second pass's order decides
|
|
2441
|
+
* nothing and every texel a region can sample is its own (`PK79`); a texel
|
|
2442
|
+
* nobody owns carries the last cell drawn over it, which nothing samples.
|
|
2443
|
+
*
|
|
2444
|
+
* What it costs is measured, not conceded: a region that moves samples through
|
|
2445
|
+
* a different `x / pageWidth`, so on the two gallery rigs whose pack moved,
|
|
2446
|
+
* 207 and 36 channel samples differ from the `rect` render, every one by 1 —
|
|
2447
|
+
* the bit two `rect` packs of the same rig differ by when one is `pot` and one
|
|
2448
|
+
* `free` (155 and 44). `--page-edges`, the width search and the spill rule are
|
|
2449
|
+
* unchanged.
|
|
2450
|
+
*
|
|
2451
|
+
* ⚠️ `pot` is not the smaller answer's poor relation, and its search was never
|
|
2452
|
+
* "double until it fits": it tries every power-of-two pair in order of area.
|
|
2453
|
+
* On both rigs above the cells' own area (1,206,755 and 540,793 texels at
|
|
2454
|
+
* padding 2) already exceeds the next power-of-two page down, so no `pot`
|
|
2455
|
+
* packer could do better; the only lever left was the power of two itself.
|
|
2456
|
+
*/
|
|
2457
|
+
export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResult {
|
|
2458
|
+
const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
2459
|
+
const padding = opts.padding ?? DEFAULT_PADDING;
|
|
2460
|
+
const stem = opts.pageStem ?? 'skeleton';
|
|
2461
|
+
const pageEdges = opts.pageEdges ?? DEFAULT_PAGE_EDGES;
|
|
2462
|
+
if (!PAGE_EDGES.includes(pageEdges)) {
|
|
2463
|
+
throw new CompileError(`--page-edges ${JSON.stringify(String(opts.pageEdges))}; known values: ${PAGE_EDGES.join(', ')}`);
|
|
2464
|
+
}
|
|
2465
|
+
const shape = opts.shape ?? DEFAULT_PACK_SHAPE;
|
|
2466
|
+
if (!PACK_SHAPES.includes(shape)) {
|
|
2467
|
+
throw new CompileError(`--pack-shape ${JSON.stringify(String(opts.shape))}; known values: ${PACK_SHAPES.join(', ')}`);
|
|
2468
|
+
}
|
|
2469
|
+
if (!Number.isInteger(pageSize) || pageSize < 1) {
|
|
2470
|
+
throw new CompileError(`--page-size must be a positive integer, got ${String(opts.pageSize)}`);
|
|
2471
|
+
}
|
|
2472
|
+
if (!Number.isInteger(padding) || padding < 0) {
|
|
2473
|
+
throw new CompileError(`--padding must be a non-negative integer, got ${String(opts.padding)}`);
|
|
2474
|
+
}
|
|
2475
|
+
if (inputs.length === 0) throw new CompileError('nothing to pack: this compile emitted no images');
|
|
2476
|
+
|
|
2477
|
+
const maxEdge = floorPowerOfTwo(pageSize);
|
|
2478
|
+
|
|
2479
|
+
const sorted = inputs.slice().sort(packOrder);
|
|
2480
|
+
const cells = sorted.map((input) => ({ w: input.width + 2 * padding, h: input.height + 2 * padding }));
|
|
2481
|
+
|
|
2482
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
2483
|
+
if (cells[i].w <= maxEdge && cells[i].h <= maxEdge) continue;
|
|
2484
|
+
throw new CompileError(
|
|
2485
|
+
`"${sorted[i].region}" is ${sorted[i].width}x${sorted[i].height} and with --padding ${padding} needs a ` +
|
|
2486
|
+
`${cells[i].w}x${cells[i].h} cell, which does not fit a ${maxEdge}x${maxEdge} page ` +
|
|
2487
|
+
`(${sorted[i].absPath}). Raise --page-size, lower --padding, or leave this build unpacked — a packer ` +
|
|
2488
|
+
'cannot split one drawing across two pages.',
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
// `polygon` only: every cell's protected set. Under `rect` there are none and
|
|
2493
|
+
// every pass below is the rectangle pass, the code it was before #1099.
|
|
2494
|
+
const shapes =
|
|
2495
|
+
shape === 'polygon' ? sorted.map((input) => footprintCell(input.width, input.height, padding, input.footprint)) : undefined;
|
|
2496
|
+
|
|
2497
|
+
// ⭐ `polygon` is a choice between whole packs (issue #1104), made on the
|
|
2498
|
+
// one figure the mode exists to lower — the sum of the pages' areas — and
|
|
2499
|
+
// made over every page, a spill's included. The candidates, in order: the
|
|
2500
|
+
// rectangle pack (`rect`'s own, to the byte); the footprint pack with the
|
|
2501
|
+
// owned box's anchor (`box`); and the footprint pack that may also put a
|
|
2502
|
+
// cell's own corner where the first anchor would leave the page
|
|
2503
|
+
// (`box-else-cell`). The least Σ area wins, the earlier candidate on a tie.
|
|
2504
|
+
// So a `polygon` pack is never larger than `rect`'s, nor than the one-anchor
|
|
2505
|
+
// pack, on any set — by construction, across a spill as well as on one page,
|
|
2506
|
+
// which no single greedy rule gives: one more candidate position placed
|
|
2507
|
+
// PK78's gem differently and every tile after it (one page 7.2 % larger),
|
|
2508
|
+
// and the first page of a spill choosing differently left seed 1105 under
|
|
2509
|
+
// `pot` on two 2048x2048 pages against `rect`'s 2048x2048 + 1024x2048.
|
|
2510
|
+
// `footprintAnchors` names one rule alone instead (the instrument, the plant).
|
|
2511
|
+
const candidates: Array<{ name: PackCandidate; layout: PackLayout }> = [];
|
|
2512
|
+
if (shapes === undefined) candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
|
|
2513
|
+
else if (opts.footprintAnchors !== undefined) candidates.push({ name: opts.footprintAnchors, layout: layoutPack(sorted, cells, shapes, maxEdge, pageEdges, padding, opts.footprintAnchors, opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
|
|
2514
|
+
else {
|
|
2515
|
+
candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
|
|
2516
|
+
for (const rule of POLYGON_CANDIDATE_RULES) candidates.push({ name: rule, layout: layoutPack(sorted, cells, shapes, maxEdge, pageEdges, padding, rule, opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
|
|
2517
|
+
}
|
|
2518
|
+
const areaOf = (layout: PackLayout): number => layout.pageSizes.reduce((n, p) => n + p.width * p.height, 0);
|
|
2519
|
+
let chosen = candidates[0];
|
|
2520
|
+
for (const c of candidates) if (areaOf(c.layout) < areaOf(chosen.layout)) chosen = c;
|
|
2521
|
+
const { perPage, pageSizes, placements } = chosen.layout;
|
|
2522
|
+
|
|
2523
|
+
// Draw. Reading each source once, in packing order, keeps the decode count at
|
|
2524
|
+
// one per part whatever the page layout turned out to be.
|
|
2525
|
+
const byRegion = new Map(sorted.map((input) => [input.region, input]));
|
|
2526
|
+
const shapeByRegion = new Map<string, CellShape>();
|
|
2527
|
+
if (shapes !== undefined) sorted.forEach((input, i) => shapeByRegion.set(input.region, shapes[i]));
|
|
2528
|
+
const pages: PackedPage[] = [];
|
|
2529
|
+
const emitPages: EmitPage[] = [];
|
|
2530
|
+
perPage.forEach((onPage, index) => {
|
|
2531
|
+
const { width: pageW, height: pageH } = pageSizes[index];
|
|
2532
|
+
const plate = new Plate(pageW, pageH);
|
|
2533
|
+
let covered = 0;
|
|
2534
|
+
/** `polygon` only: each region's source and protected set, for the second pass. */
|
|
2535
|
+
const owners: Array<{ source: Plate; place: Placement; shape: CellShape }> = [];
|
|
2536
|
+
for (const place of onPage) {
|
|
2537
|
+
const input = byRegion.get(place.region)!;
|
|
2538
|
+
const source = readPlate(input.absPath);
|
|
2539
|
+
if (source.width !== input.width || source.height !== input.height) {
|
|
2540
|
+
// The size in the atlas came from the PNG's IHDR (`readPngInfo`); this is
|
|
2541
|
+
// the decoded image. They disagreeing means the file changed between the
|
|
2542
|
+
// two reads, or one of the two readers is wrong about it.
|
|
2543
|
+
throw new CompileError(
|
|
2544
|
+
`"${place.region}" measured ${input.width}x${input.height} from its PNG header and decodes to ` +
|
|
2545
|
+
`${source.width}x${source.height} (${input.absPath})`,
|
|
2546
|
+
);
|
|
2547
|
+
}
|
|
2548
|
+
extrudeCell(plate, source, place.x - padding, place.y - padding, padding);
|
|
2549
|
+
covered += place.width * place.height;
|
|
2550
|
+
const owned = shapeByRegion.get(place.region);
|
|
2551
|
+
if (owned !== undefined) owners.push({ source, place, shape: owned });
|
|
2552
|
+
}
|
|
2553
|
+
// `polygon`'s second pass. The first wrote every cell whole, in packing
|
|
2554
|
+
// order, so where two cells overlap the later one's texels lie on top; this
|
|
2555
|
+
// one writes each region's protected set again, with its own values. The
|
|
2556
|
+
// sets are pairwise disjoint (the footprint test), so the order of this pass
|
|
2557
|
+
// decides nothing, and every texel a region owns ends as its own: a texel
|
|
2558
|
+
// under some region's footprint carries that region's value, and a texel
|
|
2559
|
+
// under none carries the last cell drawn over it.
|
|
2560
|
+
// Every region, a whole cell's included: a rectangle's texels are what a
|
|
2561
|
+
// later mesh's cell is most likely to have been drawn over. A page whose
|
|
2562
|
+
// every cell is whole had no overlap to undo and is left as the first pass
|
|
2563
|
+
// drew it, which is `rect`'s page.
|
|
2564
|
+
if (owners.some((o) => !o.shape.whole)) {
|
|
2565
|
+
for (const { source, place, shape: owned } of owners) extrudeOwned(plate, source, place.x - padding, place.y - padding, padding, owned);
|
|
2566
|
+
}
|
|
2567
|
+
const name = index === 0 ? `${stem}.png` : `${stem}${index + 1}.png`;
|
|
2568
|
+
pages.push({ name, width: pageW, height: pageH, plate, occupancy: covered / (pageW * pageH) });
|
|
2569
|
+
emitPages.push({
|
|
2570
|
+
name,
|
|
2571
|
+
width: pageW,
|
|
2572
|
+
height: pageH,
|
|
2573
|
+
// Within a page, regions are listed by name. Placement order is equally
|
|
2574
|
+
// deterministic; a name order makes two packs of the same set diffable
|
|
2575
|
+
// even when a part changed size and moved.
|
|
2576
|
+
regions: onPage
|
|
2577
|
+
.slice()
|
|
2578
|
+
.sort((a, b) => (a.region < b.region ? -1 : a.region > b.region ? 1 : 0))
|
|
2579
|
+
.map((place) => ({
|
|
2580
|
+
name: place.region,
|
|
2581
|
+
x: place.x,
|
|
2582
|
+
y: place.y,
|
|
2583
|
+
width: place.width,
|
|
2584
|
+
height: place.height,
|
|
2585
|
+
// No trim: `offsets` states the drawing's full size and a zero inset,
|
|
2586
|
+
// which is what makes a region's own width/height the attachment's.
|
|
2587
|
+
offsetX: 0,
|
|
2588
|
+
offsetY: 0,
|
|
2589
|
+
originalWidth: place.width,
|
|
2590
|
+
originalHeight: place.height,
|
|
2591
|
+
})),
|
|
2592
|
+
});
|
|
2593
|
+
});
|
|
2594
|
+
|
|
2595
|
+
return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape, candidate: chosen.name };
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
// ---------------------------------------------------------------------------
|
|
2599
|
+
// reading one region back out of a page
|
|
2600
|
+
// ---------------------------------------------------------------------------
|
|
2601
|
+
|
|
2602
|
+
/**
|
|
2603
|
+
* One region's drawing, lifted off its page.
|
|
2604
|
+
*
|
|
2605
|
+
* Two callers, and they are the reason this is a function rather than two loops:
|
|
2606
|
+
* the compiler needs a part's own pixel grid when a generator measures the art
|
|
2607
|
+
* (a contour mesh traces its alpha), and the selftest needs it to assert that a
|
|
2608
|
+
* packed region is a LOSSLESS copy of the loose PNG it came from. One extractor
|
|
2609
|
+
* means the proof and the use cannot drift.
|
|
2610
|
+
*
|
|
2611
|
+
* The result is `originalWidth x originalHeight` — the untrimmed drawing — with
|
|
2612
|
+
* the kept rectangle placed at its trim offset and the rest left transparent.
|
|
2613
|
+
* `offsetY` is measured from the drawing's BOTTOM (the format's convention) and
|
|
2614
|
+
* a plate's rows run downwards, so the kept rectangle's top row is
|
|
2615
|
+
* `originalHeight - offsetY - height`.
|
|
2616
|
+
*
|
|
2617
|
+
* ## A rotated region is MEASURED, not guessed at (issue #570)
|
|
2618
|
+
*
|
|
2619
|
+
* This refused a rotated region until 2026-09-17, on the argument that the
|
|
2620
|
+
* runtime holds "three opinions" about the mapping. Measurement refutes the
|
|
2621
|
+
* argument: the three are not three readings of one mapping, they are one
|
|
2622
|
+
* mapping and two places that do not implement it.
|
|
2623
|
+
*
|
|
2624
|
+
* * `MeshAttachment.computeUVs` (spine-core 4.3.13,
|
|
2625
|
+
* `dist/attachments/MeshAttachment.js:126-162`) is the one routine that
|
|
2626
|
+
* states where a region's texels are for **all four** `degrees`, and it is
|
|
2627
|
+
* the routine `substituteTexture` in [`src/render.ts`](render.ts) already
|
|
2628
|
+
* goes through. The loop below inverts what it samples: `PKR02` asks it,
|
|
2629
|
+
* for every texel of every region of the corpus atlases, which page texel
|
|
2630
|
+
* the runtime samples and compares the lift — 132 regions, 2,848,402
|
|
2631
|
+
* texels, 0 apart, three of them turned (90 twice, 270 once) and each of
|
|
2632
|
+
* those lifting differently with the turn ignored; `PK29` lifts the
|
|
2633
|
+
* packer's fixture at each of 0, 90, 180 and 270 back to its PNG byte for
|
|
2634
|
+
* byte;
|
|
2635
|
+
* * `TextureAtlas`'s `u2`/`v2` (`dist/TextureAtlas.js:164-171`) transpose the
|
|
2636
|
+
* rectangle at 90 and not at 270, so at 270 they describe a rectangle the
|
|
2637
|
+
* page does not have — but `MeshAttachment.computeUVs` never reads them for
|
|
2638
|
+
* an atlas region, and neither does this;
|
|
2639
|
+
* * `RegionAttachment.computeUVs` (`dist/attachments/RegionAttachment.js:156-167`)
|
|
2640
|
+
* assigns the turned corner order at 90 and at nothing else, which is a
|
|
2641
|
+
* region-attachment rendering defect (issue #199) and not a statement about
|
|
2642
|
+
* where the drawing sits.
|
|
2643
|
+
*
|
|
2644
|
+
* On texel centres — at 90 and 270 measured against the runtime's sampling
|
|
2645
|
+
* (`PKR02`), at 180 against the packer fixture's turn (`PK29`) — the mapping
|
|
2646
|
+
* puts kept-rectangle pixel `(x, y)` — `x` from the drawing's left, `y` down from `top` — at page
|
|
2647
|
+
* pixel `(X + x, Y + y)` unturned, `(X + y, Y + width - 1 - x)` at 90,
|
|
2648
|
+
* `(X + width - 1 - x, Y + height - 1 - y)` at 180 and
|
|
2649
|
+
* `(X + height - 1 - y, Y + x)` at 270, writing `X`/`Y` for the region's own
|
|
2650
|
+
* `x`/`y`; the packed footprint is `height x width` for the two quarter turns
|
|
2651
|
+
* and `width x height` for the other two. `repackRotatedTrimmed` in
|
|
2652
|
+
* `selftest.ts` derived the same 270 mapping for issue #199's fixture, and had
|
|
2653
|
+
* been shipping it green, while this comment claimed the mapping was unknowable.
|
|
2654
|
+
*
|
|
2655
|
+
* ⚠️ Any other `degrees` takes the unturned branch, because that is what the
|
|
2656
|
+
* runtime does with it: `regionFields.rotate` (`dist/TextureAtlas.js:87-93`)
|
|
2657
|
+
* `parseInt`s the value without checking it, and `computeUVs` falls to
|
|
2658
|
+
* `default:` for everything that is not 90, 180 or 270. Reading such a region
|
|
2659
|
+
* unturned is not a guess, it is agreement with the thing that will draw it.
|
|
2660
|
+
*
|
|
2661
|
+
* rigc's own packer still never rotates (`PACK_NO_ROTATE`), so only a foreign
|
|
2662
|
+
* atlas reaches any branch but the first.
|
|
2663
|
+
*/
|
|
2664
|
+
export function extractRegion(page: Plate, region: AtlasRegion): Plate {
|
|
2665
|
+
const out = new Plate(region.originalWidth, region.originalHeight);
|
|
2666
|
+
const top = region.originalHeight - region.offsetY - region.height;
|
|
2667
|
+
const { degrees } = region;
|
|
2668
|
+
for (let y = 0; y < region.height; y++) {
|
|
2669
|
+
for (let x = 0; x < region.width; x++) {
|
|
2670
|
+
const px =
|
|
2671
|
+
degrees === 90
|
|
2672
|
+
? region.x + y
|
|
2673
|
+
: degrees === 180
|
|
2674
|
+
? region.x + region.width - 1 - x
|
|
2675
|
+
: degrees === 270
|
|
2676
|
+
? region.x + region.height - 1 - y
|
|
2677
|
+
: region.x + x;
|
|
2678
|
+
const py =
|
|
2679
|
+
degrees === 90
|
|
2680
|
+
? region.y + region.width - 1 - x
|
|
2681
|
+
: degrees === 180
|
|
2682
|
+
? region.y + region.height - 1 - y
|
|
2683
|
+
: degrees === 270
|
|
2684
|
+
? region.y + x
|
|
2685
|
+
: region.y + y;
|
|
2686
|
+
out.set(region.offsetX + x, top + y, page.get(px, py));
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
return out;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
// ---------------------------------------------------------------------------
|
|
2693
|
+
// a page against the file it names
|
|
2694
|
+
// ---------------------------------------------------------------------------
|
|
2695
|
+
|
|
2696
|
+
/**
|
|
2697
|
+
* The one page rectangle every region on a page shares: the size the atlas
|
|
2698
|
+
* declares for it against the size of the file it names.
|
|
2699
|
+
*
|
|
2700
|
+
* ⭐ **What a size that disagrees with the file is and is not**, measured rather
|
|
2701
|
+
* than assumed (issue #715). `TextureAtlas` computes every region's UVs as a
|
|
2702
|
+
* fraction of the DECLARED size — `region.u = region.x / page.width`, spine-core
|
|
2703
|
+
* 4.3.13 `dist/TextureAtlas.js:162-171` — `MeshAttachment.computeUVs` takes its
|
|
2704
|
+
* `textureWidth` from the same field (`dist/attachments/MeshAttachment.js:125`),
|
|
2705
|
+
* `RegionAttachment.computeUVs` reads nothing but `u/v/u2/v2`
|
|
2706
|
+
* (`dist/attachments/RegionAttachment.js:152-167`), and `TextureAtlasPage.setTexture`
|
|
2707
|
+
* never writes `width`/`height`. So **nothing in the region mapping reads the
|
|
2708
|
+
* texture's own size**, and a page whose PNG is the declared page RESCALED is
|
|
2709
|
+
* addressed at the same fraction of the picture whatever size the file is:
|
|
2710
|
+
* measured on a coordinate-ramp page where every texel names its own position,
|
|
2711
|
+
* rigc's own rasteriser drew 116,480 pixels in both and 0 in exactly one at a
|
|
2712
|
+
* uniform 0.5.
|
|
2713
|
+
*
|
|
2714
|
+
* ⇒ That is why this is a clause about **texel** readers rather than about
|
|
2715
|
+
* drawing, and why it is nevertheless not renderer policy. Three readers address
|
|
2716
|
+
* the page at the coordinates the atlas states, and two of them are rigc's own:
|
|
2717
|
+
* `A19`'s alpha scan in [`src/validate.ts`](validate.ts), the region lift
|
|
2718
|
+
* `partPlate` traces a mesh generator over in [`src/compile.ts`](compile.ts), and `spine-html`'s region tier, which
|
|
2719
|
+
* cuts each part with `drawImage(image, x, y, w, h, …)` and says in its own
|
|
2720
|
+
* comment that it tests against the image rather than the `size:` line. Measured
|
|
2721
|
+
* on the same page: the lift returned 768 of 768 texels from somewhere else.
|
|
2722
|
+
*
|
|
2723
|
+
* 🔑 And the format already states coarser texels honestly — `scale:`, which
|
|
2724
|
+
* rigc reads (`AtlasPage.scale`) and `--atlas-in` divides by. The same art
|
|
2725
|
+
* declared that way builds green and renders identically, so this refusal names
|
|
2726
|
+
* a repair the format provides rather than one rigc invented.
|
|
2727
|
+
*/
|
|
2728
|
+
export interface PageGridReading {
|
|
2729
|
+
/** file width / declared width, and the same for height. Both 1 when they agree. */
|
|
2730
|
+
readonly x: number;
|
|
2731
|
+
readonly y: number;
|
|
2732
|
+
/** One ratio for both axes — the only relation a `scale:` line can state. */
|
|
2733
|
+
readonly uniform: boolean;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
/** `null` when the page declares no positive size for the ratios to divide by. */
|
|
2737
|
+
export function pageGridReading(
|
|
2738
|
+
declared: { width: number; height: number },
|
|
2739
|
+
file: { width: number; height: number },
|
|
2740
|
+
): PageGridReading | null {
|
|
2741
|
+
if (declared.width <= 0 || declared.height <= 0) return null;
|
|
2742
|
+
return {
|
|
2743
|
+
x: file.width / declared.width,
|
|
2744
|
+
y: file.height / declared.height,
|
|
2745
|
+
// Cross-multiplied rather than compared as two divisions: the question is
|
|
2746
|
+
// whether one rational number describes both axes, and two floats that
|
|
2747
|
+
// round to the same digits are not that.
|
|
2748
|
+
uniform: file.width * declared.height === file.height * declared.width,
|
|
2749
|
+
};
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
/** A ratio, printed the one way every message here prints one. */
|
|
2753
|
+
function gridRatio(n: number): string {
|
|
2754
|
+
return n.toFixed(4);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/**
|
|
2758
|
+
* The first number on this page that a `scale:` re-declaration could not carry,
|
|
2759
|
+
* or `null` when every one of them lands on a whole texel of the file.
|
|
2760
|
+
*
|
|
2761
|
+
* Every number in a region block is in the page's own texel units — `bounds` and
|
|
2762
|
+
* `offsets` alike — so re-declaring the page at the file's size means scaling all
|
|
2763
|
+
* eight by the same ratio, and a region that then lands between texels is one no
|
|
2764
|
+
* reader could cut out. Stated as integer arithmetic (`n * file % declared`)
|
|
2765
|
+
* rather than as a float test, so the answer does not depend on how the ratio
|
|
2766
|
+
* rounded. ⚠️ One ratio for both axes, so this is the UNIFORM case's question
|
|
2767
|
+
* and is only ever asked there — a page whose two axes differ has no `scale:`
|
|
2768
|
+
* line to be re-declared with at all.
|
|
2769
|
+
*/
|
|
2770
|
+
function firstNumberOffTheCoarseGrid(
|
|
2771
|
+
regions: ReadonlyArray<{
|
|
2772
|
+
name: string;
|
|
2773
|
+
x: number;
|
|
2774
|
+
y: number;
|
|
2775
|
+
width: number;
|
|
2776
|
+
height: number;
|
|
2777
|
+
offsetX: number;
|
|
2778
|
+
offsetY: number;
|
|
2779
|
+
originalWidth: number;
|
|
2780
|
+
originalHeight: number;
|
|
2781
|
+
}>,
|
|
2782
|
+
declared: number,
|
|
2783
|
+
file: number,
|
|
2784
|
+
): string | null {
|
|
2785
|
+
for (const region of regions) {
|
|
2786
|
+
const numbers: Array<[string, number]> = [
|
|
2787
|
+
['bounds x', region.x],
|
|
2788
|
+
['bounds y', region.y],
|
|
2789
|
+
['bounds width', region.width],
|
|
2790
|
+
['bounds height', region.height],
|
|
2791
|
+
['offsets offsetX', region.offsetX],
|
|
2792
|
+
['offsets offsetY', region.offsetY],
|
|
2793
|
+
['offsets originalWidth', region.originalWidth],
|
|
2794
|
+
['offsets originalHeight', region.originalHeight],
|
|
2795
|
+
];
|
|
2796
|
+
for (const [field, value] of numbers) {
|
|
2797
|
+
if ((value * file) % declared === 0) continue;
|
|
2798
|
+
return (
|
|
2799
|
+
`region ${JSON.stringify(region.name.trim())}'s \`${field}\` of ${value} becomes ` +
|
|
2800
|
+
`${((value * file) / declared).toFixed(4)} on the file's own grid, which is not a whole texel`
|
|
2801
|
+
);
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
return null;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
/** What every size-mismatch sentence says before it says what to do about it. */
|
|
2808
|
+
const PAGE_GRID_PREAMBLE =
|
|
2809
|
+
'A runtime does not read the file\'s own size anywhere in the region mapping — `TextureAtlas` computes every ' +
|
|
2810
|
+
"region's UVs as a fraction of the DECLARED size (`region.u = region.x / page.width`, spine-core " +
|
|
2811
|
+
'`dist/TextureAtlas.js:162-171`) — so a page whose PNG is the declared page RESCALED draws the same picture at ' +
|
|
2812
|
+
"the file's resolution, and one whose PNG is anything else draws whatever sits at those fractions. What a " +
|
|
2813
|
+
'declared size that disagrees with the file breaks is every reader that addresses the page in TEXELS: this ' +
|
|
2814
|
+
"validator's own `A19` alpha scan, the region lift rigc's mesh generators trace, and a canvas renderer that " +
|
|
2815
|
+
'cuts each part out of the page by source rectangle.';
|
|
2816
|
+
|
|
2817
|
+
/**
|
|
2818
|
+
* What a page that is not its declared size is, stated as the page, both sizes
|
|
2819
|
+
* and the two ratios — the clause every message about it opens with.
|
|
2820
|
+
*
|
|
2821
|
+
* `A06`'s refusal starts with it, and so does every line in which a reader that
|
|
2822
|
+
* addresses the page in texels declines to (issue #750): `explain` and `build`
|
|
2823
|
+
* print it where they withhold a mesh's fit, and the contour generator's refusal
|
|
2824
|
+
* carries the whole of `pageGridSentence` below. One derivation, so a page is
|
|
2825
|
+
* never described two ways by the two halves of one run.
|
|
2826
|
+
*/
|
|
2827
|
+
export function pageGridSaid(
|
|
2828
|
+
page: { name: string; width: number; height: number },
|
|
2829
|
+
file: { width: number; height: number },
|
|
2830
|
+
): string {
|
|
2831
|
+
const said = `page "${page.name}" declares ${page.width}x${page.height} and its PNG is ${file.width}x${file.height}`;
|
|
2832
|
+
const grid = pageGridReading(page, file);
|
|
2833
|
+
return grid === null
|
|
2834
|
+
? said
|
|
2835
|
+
: `${said} — ${gridRatio(grid.x)} of the declared width and ${gridRatio(grid.y)} of the declared height`;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
/**
|
|
2839
|
+
* `A06`'s whole sentence for a page whose file is not its declared size, or
|
|
2840
|
+
* `null` when the two agree: the page and its ratios (`pageGridSaid`), why a
|
|
2841
|
+
* runtime still draws it and which readers it breaks, and the repair the format
|
|
2842
|
+
* offers — or why it offers none.
|
|
2843
|
+
*
|
|
2844
|
+
* It lives here rather than in [`src/validate.ts`](validate.ts) because the
|
|
2845
|
+
* compiler states it too, and `src/compile.ts` must not link the runtime that
|
|
2846
|
+
* file links. `regions` is the page's own regions; only the uniform case reads
|
|
2847
|
+
* them, to find a number a `scale:` re-declaration could not carry.
|
|
2848
|
+
*/
|
|
2849
|
+
export function pageGridSentence(
|
|
2850
|
+
page: { name: string; width: number; height: number },
|
|
2851
|
+
file: { width: number; height: number },
|
|
2852
|
+
regions: Parameters<typeof firstNumberOffTheCoarseGrid>[0],
|
|
2853
|
+
): string | null {
|
|
2854
|
+
if (page.width === file.width && page.height === file.height) return null;
|
|
2855
|
+
const said = pageGridSaid(page, file);
|
|
2856
|
+
const grid = pageGridReading(page, file);
|
|
2857
|
+
if (grid === null) return `${said}. ${PAGE_GRID_PREAMBLE}`;
|
|
2858
|
+
const off = grid.uniform ? firstNumberOffTheCoarseGrid(regions, page.width, file.width) : null;
|
|
2859
|
+
const repair = !grid.uniform
|
|
2860
|
+
? 'The `scale:` header states one ratio for both axes, so a page whose axes differ cannot be ' +
|
|
2861
|
+
`declared honestly at all: re-export the page at ${page.width}x${page.height}, or repack it.`
|
|
2862
|
+
: off !== null
|
|
2863
|
+
? 'The format states coarser texels with the `scale:` header, but this page cannot be re-declared ' +
|
|
2864
|
+
`that way: ${off}. Re-export the page at ${page.width}x${page.height}, or repack it.`
|
|
2865
|
+
: 'The format states coarser texels with the `scale:` header and rigc builds that: declare ' +
|
|
2866
|
+
`\`size: ${file.width}, ${file.height}\` with \`scale: ${gridRatio(grid.x)}\` and multiply every ` +
|
|
2867
|
+
`\`bounds\`/\`offsets\` on this page by ${gridRatio(grid.x)}, and every part keeps the size it ` +
|
|
2868
|
+
'has now.';
|
|
2869
|
+
return `${said}. ${PAGE_GRID_PREAMBLE} ${repair}`;
|
|
2870
|
+
}
|