rig-c 0.0.0-stage → 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1191 -0
- package/src/meshquality.ts +2051 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1444 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The render's second poser (issue #968, step 3d of issue #380): the posing
|
|
3
|
+
* seam of `src/render.ts` (`Poser` / `Posed`) implemented over rigc's own
|
|
4
|
+
* core — the compiled model document (`skeleton.model.json`) posed by
|
|
5
|
+
* `src/core/`, each drawn region placed on its page by the document's own
|
|
6
|
+
* `pages` section (issue #1016; a `rigc-compiled/1` document, which has none,
|
|
7
|
+
* by the atlas beside it, read by rigc's own reader `src/atlas.ts`). Nothing
|
|
8
|
+
* here links spine-core, and nothing here is reached
|
|
9
|
+
* from `src/core/`: the core stays pure, and this module is the adapter from
|
|
10
|
+
* its raw entry to the renderer's shapes.
|
|
11
|
+
*
|
|
12
|
+
* ⭐ Why a module of its own rather than a second block in `src/render.ts`.
|
|
13
|
+
* `render.ts` links spine-core (an export's atlas pages and texture
|
|
14
|
+
* substitution, and `spinePoser`), so a core poser written there would share an import list
|
|
15
|
+
* with the runtime it replaces — and "the core poser imports nothing from
|
|
16
|
+
* spine-core" could then only be said, not read off the file. Here it can be:
|
|
17
|
+
* the value imports below are the core, the atlas reader and nothing else, and
|
|
18
|
+
* the one thing taken from `render.ts` is its types (erased at run time). The
|
|
19
|
+
* modules that link spine-core stay three (`CUR07`).
|
|
20
|
+
*
|
|
21
|
+
* ## What each part of a pose is read from
|
|
22
|
+
*
|
|
23
|
+
* - **The walk** is `poseRawAnimation` (`src/core/raw.ts`, issue #966) with
|
|
24
|
+
* `count` steps of `1/fps` and the reset taken at the animation
|
|
25
|
+
* (`'animation'`): pose `i` is the running sum of `i` steps, applied at
|
|
26
|
+
* `min(sum, duration)`, physics reset at pose 0 and stepped by `1/fps` after
|
|
27
|
+
* — the recipe `spinePoser` runs, measured bit-exact against it on every
|
|
28
|
+
* frame of the nineteen tree rows by the core suite's `CR03`. The setup pose
|
|
29
|
+
* is `poseRawSetup`. Since issue #1180 the walk is `poseRawAnimationEach`,
|
|
30
|
+
* the same walk handing each pose over as it is posed (`CORE_RAW_WALK`), so
|
|
31
|
+
* one pose is held at a time.
|
|
32
|
+
* - **Vertices, triangles, bones, slot colours, draw order** are the raw
|
|
33
|
+
* pose's, unchanged: the same doubles `computeWorldVertices`,
|
|
34
|
+
* `getWorldRotationX` and the slot pose hold (`CR03`).
|
|
35
|
+
* - **The page and page UVs** are `./core/uvs.ts`'s (issue #967): which
|
|
36
|
+
* region a drawn attachment samples (`drawnRegions` over the pose's `shown`
|
|
37
|
+
* records and draw order), a region's four UVs (`regionPageUvs`) and a
|
|
38
|
+
* mesh's (`meshPageUvs`), each held to `sequence.getUVs(index)` at
|
|
39
|
+
* tolerance 0 on every corpus by `core_gate`'s `uvs` blocks. Where each
|
|
40
|
+
* region sits — its page, `x`, `y`, turn and the page's size — is the
|
|
41
|
+
* document's `pages` section (issue #1016, `placementOf`), the atlas `build`
|
|
42
|
+
* wrote spelled into the document, so a build draws with its `.atlas`
|
|
43
|
+
* removed.
|
|
44
|
+
* - **The tint** is the slot's light colour times the attachment's colour,
|
|
45
|
+
* channel by channel, as `src/render.ts`'s `tintOf` forms it; the dark
|
|
46
|
+
* colour is the slot's, absent where the slot carries none.
|
|
47
|
+
* - **The clip.** Which slot a clip covers is the core's walk
|
|
48
|
+
* (`./core/clipping.ts`, issue #964), whose rows the raw pose carries with
|
|
49
|
+
* the attachment's LOCAL UVs. The render samples PAGE UVs, so each clipped
|
|
50
|
+
* row is cut again with `clipThrough` over the page UVs — the UV rule is
|
|
51
|
+
* linear in the corner UVs, and the vertices and triangles depend on
|
|
52
|
+
* positions alone, so the second cut must return the core's vertices,
|
|
53
|
+
* triangles and verdict to the bit, and a disagreement is thrown as a
|
|
54
|
+
* defect here rather than drawn. The polygon is the clip slot's world
|
|
55
|
+
* polygon from the pose's `clips` rows, and the walk that pairs a slot with
|
|
56
|
+
* the clip over it is the core's rule restated: a clip whose bone is active
|
|
57
|
+
* starts when none is active, and ends after its end slot is walked.
|
|
58
|
+
* - **A concave or inverse clip** is cut through the core's own convex
|
|
59
|
+
* decomposition (`clipThrough`, issue #964), and each drawn triangle carries
|
|
60
|
+
* its source triangle (`clipSourceOf`, `Mesh.source`): the rasteriser
|
|
61
|
+
* samples it at the source triangle's affine UV, so the pixels are the
|
|
62
|
+
* spine-core render's whatever pieces either clipper cut (`RC08`). A clip
|
|
63
|
+
* that is not simple is refused by the raw entry, naming the slot
|
|
64
|
+
* (`CoreInputError`); the render then falls back to `spinePoser` for that
|
|
65
|
+
* input and says so (`throughPoser` in `src/render.ts`).
|
|
66
|
+
*
|
|
67
|
+
* ## The skin
|
|
68
|
+
*
|
|
69
|
+
* `--skin <name>` poses `underSkin(doc, name)` — the named skin's record,
|
|
70
|
+
* else the default skin's; the named skin's bones and constraints —
|
|
71
|
+
* measured against spine-core's `setSkin(name)` by the per-skin gate
|
|
72
|
+
* (issue #932). No `--skin` is spine-core's "no skin set" — a fresh skeleton
|
|
73
|
+
* whose `setSkin` was never called — posed as `underNoSkin` (`noSkinView`,
|
|
74
|
+
* issue #1051): no skin's bones or constraints applied, the default skin's
|
|
75
|
+
* included, and every slot resolved through the default skin alone, or
|
|
76
|
+
* through nothing where the document declares skins and no `default` one
|
|
77
|
+
* (`./core/skins.ts`, *No skin set*, for the measurement). Until #1051 it was
|
|
78
|
+
* posed as `underSkin(doc, 'default')`, and the two documents that reading
|
|
79
|
+
* did not cover — a default skin naming a skin-required member, skins with
|
|
80
|
+
* no default one — were refused here and rendered through spine-core.
|
|
81
|
+
*/
|
|
82
|
+
import type {
|
|
83
|
+
AttachmentPose,
|
|
84
|
+
AttachmentRest,
|
|
85
|
+
BoneSnapshot,
|
|
86
|
+
DrawOptions,
|
|
87
|
+
Piece,
|
|
88
|
+
PieceTexture,
|
|
89
|
+
PoseOptions,
|
|
90
|
+
Posed,
|
|
91
|
+
Poser,
|
|
92
|
+
SkinRoster,
|
|
93
|
+
SlotSubset,
|
|
94
|
+
} from './render.ts';
|
|
95
|
+
import { atlasRegionLookup, parseAtlasText } from './atlas.ts';
|
|
96
|
+
import { pagesOfAtlas, spineFileSha256, type ModelPage } from './model.ts';
|
|
97
|
+
import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.ts';
|
|
98
|
+
import { activeBones, CoreInputError, readModel, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
|
|
99
|
+
import { poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
|
|
100
|
+
import type { TimelinePlant } from './core/animation.ts';
|
|
101
|
+
import { CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
|
|
102
|
+
import { documentPageLookup, drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvRegion, type UvSource } from './core/uvs.ts';
|
|
103
|
+
import { regionCorners, worldVertices } from './core/vertices.ts';
|
|
104
|
+
import type { CoreWorld } from './core/world.ts';
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// the slot subset, spelled once for both posers
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Why a slot subset cannot be drawn — a `--slot`/`--hide` naming no slot, a slot
|
|
112
|
+
* whose art only another skin carries, or both flags at once (issue #835).
|
|
113
|
+
*
|
|
114
|
+
* A class of its own so `cli.ts` can turn it into a usage refusal (exit 2,
|
|
115
|
+
* nothing written) without reading a message to decide what kind it is. It
|
|
116
|
+
* lives here, and `src/render.ts` re-exports it, so both posers throw the one
|
|
117
|
+
* class.
|
|
118
|
+
*/
|
|
119
|
+
export class SlotSubsetError extends Error {}
|
|
120
|
+
|
|
121
|
+
/** The flag spelling each half of a subset is refused under — the UI's, since that is who reads it. */
|
|
122
|
+
const SUBSET_FLAG = { slots: '--slot', hidden: '--hide' } as const;
|
|
123
|
+
|
|
124
|
+
/** What `subsetOver` reads off a skeleton: its slots in draw order, which skins carry a slot's art, and its default skin. */
|
|
125
|
+
export interface SubsetRoster {
|
|
126
|
+
/** Every declared slot, in the setup draw order. */
|
|
127
|
+
declared: readonly string[];
|
|
128
|
+
/** The skins, in the skeleton's order, holding at least one attachment for `slot`. */
|
|
129
|
+
carriers(slot: string): string[];
|
|
130
|
+
/** The default skin's name, or `null` when there is none. */
|
|
131
|
+
defaultSkin: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `slotSubsetOf`'s rule over a roster rather than a parsed Spine skeleton, so
|
|
136
|
+
* the spine-core poser and the core poser refuse a subset in the same words —
|
|
137
|
+
* see `slotSubsetOf` in `src/render.ts` for the rule and its three refusals.
|
|
138
|
+
*/
|
|
139
|
+
export function subsetOver(
|
|
140
|
+
roster: SubsetRoster,
|
|
141
|
+
opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined,
|
|
142
|
+
skin: string | undefined,
|
|
143
|
+
): SlotSubset | undefined {
|
|
144
|
+
if (opts?.slots !== undefined && opts.hidden !== undefined) {
|
|
145
|
+
throw new SlotSubsetError(
|
|
146
|
+
'--slot and --hide are one statement two ways; name the slots to draw or the slots to hide, not both',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const mode = opts?.slots !== undefined ? 'slots' : opts?.hidden !== undefined ? 'hidden' : undefined;
|
|
150
|
+
if (mode === undefined) return undefined;
|
|
151
|
+
const asked = (mode === 'slots' ? opts?.slots : opts?.hidden) ?? [];
|
|
152
|
+
const flag = SUBSET_FLAG[mode];
|
|
153
|
+
const declared = [...roster.declared];
|
|
154
|
+
|
|
155
|
+
const unknown = asked.filter((name) => !declared.includes(name));
|
|
156
|
+
if (unknown.length > 0 || asked.length === 0) {
|
|
157
|
+
const named =
|
|
158
|
+
unknown.length === 0
|
|
159
|
+
? 'was given no slot name'
|
|
160
|
+
: `${unknown.map((name) => JSON.stringify(name)).join(', ')} ${unknown.length === 1 ? 'names' : 'name'} no slot`;
|
|
161
|
+
throw new SlotSubsetError(
|
|
162
|
+
`${flag} ${named}; this skeleton declares, in draw order: ${declared.join(', ') || 'none'} (${declared.length})`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const resolving = new Set([skin ?? null, roster.defaultSkin]);
|
|
167
|
+
const underThisPose =
|
|
168
|
+
skin === undefined ? 'under no skin (the default skin alone)' : `under skin ${JSON.stringify(skin)}`;
|
|
169
|
+
for (const name of asked) {
|
|
170
|
+
const carriers = roster.carriers(name);
|
|
171
|
+
if (carriers.length === 0 || carriers.some((s) => resolving.has(s))) continue;
|
|
172
|
+
const skins = carriers.map((s) => JSON.stringify(s));
|
|
173
|
+
throw new SlotSubsetError(
|
|
174
|
+
`${flag} ${JSON.stringify(name)} draws nothing ${underThisPose}: its attachments are declared only under ` +
|
|
175
|
+
`${skins.length === 1 ? 'skin' : 'skins'} ${skins.join(', ')} — pass --skin ${
|
|
176
|
+
skins.length === 1 ? skins[0] : 'with one of them'
|
|
177
|
+
}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const chosen = new Set(asked);
|
|
181
|
+
return { mode, names: declared.filter((name) => chosen.has(name)) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// a bone the posed skin leaves inactive, spelled once for both posers
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The snapshot of a bone the posed skin leaves UNPOSED — inactive itself (a
|
|
190
|
+
* skin-required bone the skin does not name) or below an inactive bone: the
|
|
191
|
+
* zero transform, every number `0` (issue #968).
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ "Below an inactive bone" is the half the runtime's flag does not say. A
|
|
194
|
+
* bone that is not skin-required reads `active` true under an inactive parent
|
|
195
|
+
* (measured: `arm` skin-required and unnamed, its child `hand` not
|
|
196
|
+
* skin-required — spine-core reads `arm=false hand=true`), yet its matrix is
|
|
197
|
+
* never computed: it stays the zero matrix over every frame unless a
|
|
198
|
+
* constraint writes into it. So the predicate is the bone's own flag and every
|
|
199
|
+
* ancestor's (`unposedBones`).
|
|
200
|
+
*
|
|
201
|
+
* ⭐ Why the seam defines it rather than relaying either runtime. An inactive
|
|
202
|
+
* bone is not posed: neither poser computes its world transform, so its
|
|
203
|
+
* matrix holds whatever a constraint listing it happened to write into a zero
|
|
204
|
+
* matrix. Measured on hand-written rigs under no skin (a skin-required `arm`
|
|
205
|
+
* and its child `hand`, both inactive): a one-bone ik on `hand` left
|
|
206
|
+
* spine-core's matrix at zeros and the core's at NaN; a two-bone ik on `arm,
|
|
207
|
+
* hand` left spine-core's `b` and `d` at `-0` — so `getWorldRotationY` read
|
|
208
|
+
* −179.99999734 (`atan2(−0, …)` at the runtime's pi) — and the core's at `+0`,
|
|
209
|
+
* reading 0; a world transform constraint wrote `worldX` 19.99999979 and a
|
|
210
|
+
* `-0` into both alike. The private corpus met the second case on two rigs:
|
|
211
|
+
* 508 rotation readings of 179.99999734 against 0, every pixel identical. None
|
|
212
|
+
* of those numbers is a pose — a rotation of ±180 is the sign of a zero, and
|
|
213
|
+
* the core's NaN the same degenerate arithmetic taken another way — so the
|
|
214
|
+
* snapshot says what is true of the bone, that it is not posed, in both
|
|
215
|
+
* posers alike. The drawn pieces are untouched: they are posed vertices, not
|
|
216
|
+
* snapshots.
|
|
217
|
+
*/
|
|
218
|
+
export function inactiveBoneSnapshot(name: string): BoneSnapshot {
|
|
219
|
+
return { name, worldX: 0, worldY: 0, a: 0, b: 0, c: 0, d: 0, rotationX: 0, rotationY: 0, scaleX: 0, scaleY: 0 };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The bones the posed skin leaves unposed — inactive, or below an inactive bone — from each bone's parent and flag, parents first. */
|
|
223
|
+
export function unposedBones(bones: ReadonlyArray<{ name: string; parent: string | null; active: boolean }>): Set<string> {
|
|
224
|
+
const out = new Set<string>();
|
|
225
|
+
for (const b of bones) if (!b.active || (b.parent !== null && out.has(b.parent))) out.add(b.name);
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// a clipped piece's source triangles, spelled once for both posers
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* `Mesh.source` of a clipped piece (issue #964): for each drawn triangle, in
|
|
235
|
+
* order, its source triangle's world corners and page UVs — and, with
|
|
236
|
+
* `artUvs`, their original-art UVs — six numbers each, read off the UNCLIPPED
|
|
237
|
+
* piece's own arrays. `sources[i]` is the source triangle of drawn triangle `i`
|
|
238
|
+
* (an index into `triangles` by threes). The rasteriser samples a drawn
|
|
239
|
+
* triangle at that source triangle's affine map, so the pixels do not depend on
|
|
240
|
+
* which convex pieces a clipper cut.
|
|
241
|
+
*/
|
|
242
|
+
export function clipSourceOf(
|
|
243
|
+
world: ArrayLike<number>,
|
|
244
|
+
uvs: ArrayLike<number>,
|
|
245
|
+
triangles: ArrayLike<number>,
|
|
246
|
+
sources: readonly number[],
|
|
247
|
+
artUvs?: ArrayLike<number>,
|
|
248
|
+
): { world: number[]; uvs: number[]; artUvs: number[] | undefined } {
|
|
249
|
+
const w: number[] = [];
|
|
250
|
+
const u: number[] = [];
|
|
251
|
+
const a: number[] | undefined = artUvs === undefined ? undefined : [];
|
|
252
|
+
for (const t of sources) {
|
|
253
|
+
for (let k = 0; k < 3; k++) {
|
|
254
|
+
const i = triangles[3 * t + k];
|
|
255
|
+
w.push(world[2 * i], world[2 * i + 1]);
|
|
256
|
+
u.push(uvs[2 * i], uvs[2 * i + 1]);
|
|
257
|
+
if (a !== undefined && artUvs !== undefined) a.push(artUvs[2 * i], artUvs[2 * i + 1]);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { world: w, uvs: u, artUvs: a };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// the core poser
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/** The runtime's own triangulation of a region's quad — `src/render.ts`'s `QUAD_TRIANGLES`. */
|
|
268
|
+
const QUAD_TRIANGLES: readonly number[] = [0, 1, 2, 2, 3, 0];
|
|
269
|
+
|
|
270
|
+
/** A region as texture substitution reads it: the page-UV rules' numbers, and the name and `index:` its key is made of (`regionKey`). */
|
|
271
|
+
type TextureRegion = UvRegion & { name: string; index: number };
|
|
272
|
+
|
|
273
|
+
/** Everything a core pose reads besides the pose: the document under one skin, and where each region sits on its page. */
|
|
274
|
+
interface CoreInput {
|
|
275
|
+
doc: CompiledDocument;
|
|
276
|
+
source: UvSource;
|
|
277
|
+
/** The region a name draws — the document's `pages`, or for a `/1` document the atlas's — what an original-art UV reads its trim from. */
|
|
278
|
+
region: (name: string) => TextureRegion | null;
|
|
279
|
+
/** The page UVs and triangle lists the pieces hold (`PieceArrays`), one table for every pose of this poser. */
|
|
280
|
+
arrays: PieceArrays;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The two arrays a drawn piece holds that are not a function of the pose
|
|
285
|
+
* (issue #1180): its page UVs — a function of the region it samples and,
|
|
286
|
+
* for a mesh, the attachment's own UVs — and a mesh's triangle list.
|
|
287
|
+
*
|
|
288
|
+
* ⭐ Why a table rather than an array per piece. A render holds its frames
|
|
289
|
+
* before it writes one (every animation at `PROTOCOL_FPS`), and the framing
|
|
290
|
+
* reads every animation at `FRAMING_FPS`; a fresh copy of both arrays in every
|
|
291
|
+
* piece of every frame was what made the core poser's render peak at 3.3x
|
|
292
|
+
* spine-core's resident size on a production rig (4,015 against 1,214 MiB,
|
|
293
|
+
* n = 5), where spine-core's pieces hold the attachment's own `uvs` and
|
|
294
|
+
* `triangles` and only the world vertices are per frame. Held here, the count
|
|
295
|
+
* of these arrays is bounded by what the rig draws — one per region and
|
|
296
|
+
* attachment UVs, one per distinct triangle list of a slot's attachment — and
|
|
297
|
+
* not by how many frames are posed (`RC43`).
|
|
298
|
+
*
|
|
299
|
+
* The values are the same doubles either way: the UVs are computed once by
|
|
300
|
+
* the same call from the same inputs, and a triangle list is reused only where
|
|
301
|
+
* it is equal, index for index, to the one the pose carries. No reader writes
|
|
302
|
+
* into a piece's arrays — spine-core's pieces have always shared theirs.
|
|
303
|
+
*/
|
|
304
|
+
export interface PieceArrays {
|
|
305
|
+
/** The page UVs `found` is drawn with: `regionPageUvs` for a region, `meshPageUvs` over the attachment's UVs for a mesh. */
|
|
306
|
+
uvs(found: DrawnRegion): number[];
|
|
307
|
+
/** A triangle list equal, index for index, to `drawn.triangles`. */
|
|
308
|
+
triangles(drawn: RawDrawn): number[];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** `PieceArrays` kept for the poser's lifetime — what `corePoser` builds unless a plant passes another. */
|
|
312
|
+
export function keptPieceArrays(): PieceArrays {
|
|
313
|
+
// Keyed on the placement's own object (one per region name, `documentPageLookup`/`atlasRegionLookup`) and the
|
|
314
|
+
// document's UV array (or `null` for a region): both live as long as the poser, so a key is never a copy.
|
|
315
|
+
const uvs = new WeakMap<object, Map<readonly number[] | null, number[]>>();
|
|
316
|
+
// Keyed by slot and attachment, and each list compared index for index before it is reused: a key names where
|
|
317
|
+
// a list was drawn, the comparison is what makes reusing it exact.
|
|
318
|
+
const triangles = new Map<string, number[][]>();
|
|
319
|
+
return {
|
|
320
|
+
uvs: (found) => {
|
|
321
|
+
let byArt = uvs.get(found.found);
|
|
322
|
+
if (byArt === undefined) {
|
|
323
|
+
byArt = new Map();
|
|
324
|
+
uvs.set(found.found, byArt);
|
|
325
|
+
}
|
|
326
|
+
let kept = byArt.get(found.art);
|
|
327
|
+
if (kept === undefined) {
|
|
328
|
+
kept = found.art === null ? regionPageUvs(found.found.region, found.found.page) : meshPageUvs(found.found.region, found.found.page, found.art);
|
|
329
|
+
byArt.set(found.art, kept);
|
|
330
|
+
}
|
|
331
|
+
return kept;
|
|
332
|
+
},
|
|
333
|
+
triangles: (drawn) => {
|
|
334
|
+
const key = `${drawn.slot}\u0000${drawn.attachment}`;
|
|
335
|
+
let lists = triangles.get(key);
|
|
336
|
+
if (lists === undefined) {
|
|
337
|
+
lists = [];
|
|
338
|
+
triangles.set(key, lists);
|
|
339
|
+
}
|
|
340
|
+
const wanted = drawn.triangles;
|
|
341
|
+
const equal = (list: readonly number[]): boolean => {
|
|
342
|
+
if (list.length !== wanted.length) return false;
|
|
343
|
+
for (let i = 0; i < list.length; i++) if (list[i] !== wanted[i]) return false;
|
|
344
|
+
return true;
|
|
345
|
+
};
|
|
346
|
+
let kept = lists.find(equal);
|
|
347
|
+
if (kept === undefined) {
|
|
348
|
+
kept = [...wanted];
|
|
349
|
+
lists.push(kept);
|
|
350
|
+
}
|
|
351
|
+
return kept;
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The raw walk the core poser steps an animation with (issue #1180):
|
|
358
|
+
* `poseRawAnimationEach`, which hands each pose to `visit` as it is posed, so
|
|
359
|
+
* one pose is held at a time rather than the animation's whole series. A plant
|
|
360
|
+
* passes another (`RC44`).
|
|
361
|
+
*/
|
|
362
|
+
export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void) => void;
|
|
363
|
+
|
|
364
|
+
/** The walk `corePoser` steps with unless a plant passes another: one pose posed, drawn and released before the next. */
|
|
365
|
+
export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit) => poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit);
|
|
366
|
+
|
|
367
|
+
/** What a suite passes `corePoser` in place of the poser's own parts — `PieceArrays` (`RC43`) and the raw walk (`RC44`). */
|
|
368
|
+
export interface CorePoserPlants {
|
|
369
|
+
arrays?: () => PieceArrays;
|
|
370
|
+
walk?: CoreRawWalk;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The document posed with no skin set — spine-core's initial state, a fresh
|
|
375
|
+
* skeleton whose `setSkin` was never called: `underNoSkin` (issue #1051), no
|
|
376
|
+
* skin's `bones` or constraint lists applied, the default skin's included,
|
|
377
|
+
* and every slot resolved through the default skin alone — or through
|
|
378
|
+
* nothing, where the document declares skins and none of them `default`
|
|
379
|
+
* (`./core/skins.ts`, *No skin set*, for the measurement).
|
|
380
|
+
*
|
|
381
|
+
* Exported for the model side of the validator (issue #1025), which poses a
|
|
382
|
+
* slot the way `validate()` does — a fresh skeleton, no skin set — and must
|
|
383
|
+
* resolve that state by this rule rather than by a copy of it.
|
|
384
|
+
*/
|
|
385
|
+
export function noSkinView(doc: CompiledDocument): CompiledDocument {
|
|
386
|
+
return underNoSkin(doc);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** A slot row's number, which the raw entry writes as a double — `null` only for a value that is not finite. */
|
|
390
|
+
function channel(value: number | null, slot: string): number {
|
|
391
|
+
if (value === null) throw new CoreInputError(`slot "${slot}": the raw pose computed a colour channel that is not finite`);
|
|
392
|
+
return value;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The world transforms of a raw pose's bones, by name — what the rest table poses through. */
|
|
396
|
+
function worldOf(pose: RawPose): Map<string, CoreWorld> {
|
|
397
|
+
return new Map(pose.bones.map((b) => [b.name, { a: b.a, b: b.b, c: b.c, d: b.d, worldX: b.worldX, worldY: b.worldY }]));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** `regionKey` in `src/render.ts`: the trimmed name and the sequence index — the key a substitution matches on. */
|
|
401
|
+
function regionKey(region: TextureRegion): string {
|
|
402
|
+
return `${region.name.trim()}#${region.index}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** `artUvsOf` in `src/render.ts`, over rigc's atlas reader: a mesh's own UVs, a region's kept rectangle in the drawing's space. */
|
|
406
|
+
function artUvsOf(drawn: RawDrawn, region: TextureRegion): PieceTexture | undefined {
|
|
407
|
+
if (drawn.kind === 'mesh') return { region: regionKey(region), artUvs: [...drawn.uvs] };
|
|
408
|
+
if (region.degrees !== 0) return undefined;
|
|
409
|
+
const ow = region.originalWidth;
|
|
410
|
+
const oh = region.originalHeight;
|
|
411
|
+
if (!(ow > 0) || !(oh > 0)) return undefined;
|
|
412
|
+
const s0 = region.offsetX / ow;
|
|
413
|
+
const s1 = (region.offsetX + region.width) / ow;
|
|
414
|
+
const tBottom = 1 - region.offsetY / oh;
|
|
415
|
+
const tTop = 1 - (region.offsetY + region.height) / oh;
|
|
416
|
+
return { region: regionKey(region), artUvs: [s0, tBottom, s0, tTop, s1, tTop, s1, tBottom] };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The clip polygon over each slot of the draw order, where a clip covers it —
|
|
421
|
+
* the core's walk (`poseClipped` in `./core/clipping.ts`) restated to pair a
|
|
422
|
+
* slot with its polygon; `clippedPieces` holds its answer to the core's own
|
|
423
|
+
* rows.
|
|
424
|
+
*/
|
|
425
|
+
function clipCover(pose: RawPose, active: ReadonlySet<string>): Map<string, ClipShape> {
|
|
426
|
+
const polygons = new Map(pose.clips.map((c) => [c[0], { end: c[2], polygon: c[3] }]));
|
|
427
|
+
const kinds = new Map(pose.shown.map((s) => [s.slot, s]));
|
|
428
|
+
const cover = new Map<string, ClipShape>();
|
|
429
|
+
let current: { end: string | null; shape: ClipShape } | null = null;
|
|
430
|
+
for (const slot of pose.drawOrder) {
|
|
431
|
+
const shown = kinds.get(slot);
|
|
432
|
+
const g = shown?.geometry;
|
|
433
|
+
const clip = g?.kind === 'clipping' ? polygons.get(slot) : undefined;
|
|
434
|
+
if (shown !== undefined && g?.kind === 'clipping' && clip !== undefined) {
|
|
435
|
+
if (current !== null && current.end === slot) current = null;
|
|
436
|
+
if (active.has(shown.bone) && current === null) current = { end: clip.end, shape: { polygon: clip.polygon, inverse: g.inverse, convex: g.convex } };
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (current !== null) cover.set(slot, current.shape);
|
|
440
|
+
if (current !== null && current.end === slot) current = null;
|
|
441
|
+
}
|
|
442
|
+
return cover;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** One posed moment of the core, read through the seam's `Posed`. */
|
|
446
|
+
function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper): Posed {
|
|
447
|
+
const slotRows = new Map<string, CoreSlotRow>(pose.slots.map((row) => [row[0], row]));
|
|
448
|
+
let regions: Map<string, DrawnRegion> | null = null;
|
|
449
|
+
const regionsOf = (): Map<string, DrawnRegion> => {
|
|
450
|
+
if (regions !== null) return regions;
|
|
451
|
+
const { drawn, why } = drawnRegions(input.doc, pose.shown, pose.drawOrder, input.source);
|
|
452
|
+
if (drawn === null) throw new CoreInputError(`the page UVs are not posed: ${why}`);
|
|
453
|
+
regions = new Map(drawn.map((d) => [d.slot, d]));
|
|
454
|
+
return regions;
|
|
455
|
+
};
|
|
456
|
+
const tintOf = (d: RawDrawn): [number, number, number, number] => {
|
|
457
|
+
const row = slotRows.get(d.slot);
|
|
458
|
+
if (row === undefined) throw new CoreInputError(`slot "${d.slot}" drew and has no slot row`);
|
|
459
|
+
return [
|
|
460
|
+
channel(row[2], d.slot) * d.colour[0],
|
|
461
|
+
channel(row[3], d.slot) * d.colour[1],
|
|
462
|
+
channel(row[4], d.slot) * d.colour[2],
|
|
463
|
+
channel(row[5], d.slot) * d.colour[3],
|
|
464
|
+
];
|
|
465
|
+
};
|
|
466
|
+
const darkOf = (d: RawDrawn): [number, number, number] | undefined => {
|
|
467
|
+
const dark = slotRows.get(d.slot)?.[6] ?? null;
|
|
468
|
+
return dark === null ? undefined : [channel(dark[0], d.slot), channel(dark[1], d.slot), channel(dark[2], d.slot)];
|
|
469
|
+
};
|
|
470
|
+
return {
|
|
471
|
+
pieces: (draw: DrawOptions): Piece[] => pieces(input, pose, draw, regionsOf(), tintOf, darkOf, through),
|
|
472
|
+
bones: (): BoneSnapshot[] => {
|
|
473
|
+
const unposed = unposedBones(pose.bones);
|
|
474
|
+
return pose.bones.map((b) => !unposed.has(b.name) ? ({
|
|
475
|
+
name: b.name,
|
|
476
|
+
worldX: b.worldX,
|
|
477
|
+
worldY: b.worldY,
|
|
478
|
+
a: b.a,
|
|
479
|
+
b: b.b,
|
|
480
|
+
c: b.c,
|
|
481
|
+
d: b.d,
|
|
482
|
+
rotationX: b.rotationX,
|
|
483
|
+
rotationY: b.rotationY,
|
|
484
|
+
scaleX: b.scaleX,
|
|
485
|
+
scaleY: b.scaleY,
|
|
486
|
+
}) : inactiveBoneSnapshot(b.name));
|
|
487
|
+
},
|
|
488
|
+
attachments: (): AttachmentPose[] =>
|
|
489
|
+
pose.drawn.map((d) => ({ slot: d.slot, attachment: d.attachment, vertices: [...d.vertices], color: tintOf(d) })),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The drawables of one core pose, in draw order — `drawPieces` in `src/render.ts`, read off the raw pose. */
|
|
494
|
+
function pieces(
|
|
495
|
+
input: CoreInput,
|
|
496
|
+
pose: RawPose,
|
|
497
|
+
draw: DrawOptions,
|
|
498
|
+
regions: Map<string, DrawnRegion>,
|
|
499
|
+
tintOf: (d: RawDrawn) => [number, number, number, number],
|
|
500
|
+
darkOf: (d: RawDrawn) => [number, number, number] | undefined,
|
|
501
|
+
through: ShapeClipper,
|
|
502
|
+
): Piece[] {
|
|
503
|
+
const named = draw.subset === undefined ? undefined : new Set(draw.subset.names);
|
|
504
|
+
const cover = draw.unclipped ? new Map<string, ClipShape>() : clipCover(pose, new Set(pose.bones.filter((b) => b.active).map((b) => b.name)));
|
|
505
|
+
const rows = new Map(pose.clipped.map((row) => [row[0], row]));
|
|
506
|
+
if (!draw.unclipped) {
|
|
507
|
+
const walked = [...cover.keys()].filter((slot) => pose.drawn.some((d) => d.slot === slot)).join();
|
|
508
|
+
const cored = pose.clipped.map((row) => row[0]).join();
|
|
509
|
+
if (walked !== cored) throw new Error(`the clip cover [${walked}] is not the core's clipped roster [${cored}] — the two walks disagree`);
|
|
510
|
+
}
|
|
511
|
+
const out: Piece[] = [];
|
|
512
|
+
for (const d of pose.drawn) {
|
|
513
|
+
const drawn = draw.subset === undefined || named === undefined || named.has(d.slot) === (draw.subset.mode === 'slots');
|
|
514
|
+
if (!drawn) continue;
|
|
515
|
+
const found = regions.get(d.slot);
|
|
516
|
+
if (found === undefined) throw new CoreInputError(`slot "${d.slot}" drew "${d.attachment}", and no atlas region was resolved for it`);
|
|
517
|
+
const uvs = input.arrays.uvs(found);
|
|
518
|
+
const atlasRegion = draw.texture ? input.region(found.region) : null;
|
|
519
|
+
if (draw.texture && atlasRegion === null) throw new CoreInputError(`slot "${d.slot}": atlas region "${found.region}" is not in the atlas`);
|
|
520
|
+
const texture = atlasRegion === null ? undefined : artUvsOf(d, atlasRegion);
|
|
521
|
+
const common = { tint: tintOf(d), dark: darkOf(d), slot: d.slot, page: found.found.page.name };
|
|
522
|
+
const world = [...d.vertices];
|
|
523
|
+
const piece: Piece =
|
|
524
|
+
d.kind === 'mesh'
|
|
525
|
+
? { kind: 'mesh', ...common, texture, world, uvs, triangles: input.arrays.triangles(d) }
|
|
526
|
+
: { kind: 'region', ...common, texture, world, uvs };
|
|
527
|
+
const shape = cover.get(d.slot);
|
|
528
|
+
out.push(shape === undefined ? piece : clippedPiece(piece, d, shape, uvs, rows.get(d.slot), through));
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* `piece` cut by the clip over it, over the page UVs (the header's *The clip*),
|
|
535
|
+
* or `piece` itself when the clipper cut nothing — `clippedPiece` in
|
|
536
|
+
* `src/render.ts`. The cut's vertices, triangles and verdict are held to the
|
|
537
|
+
* core's own row for the slot.
|
|
538
|
+
*/
|
|
539
|
+
function clippedPiece(piece: Piece, d: RawDrawn, shape: ClipShape, uvs: number[], row: RawPose['clipped'][number] | undefined, through: ShapeClipper): Piece {
|
|
540
|
+
const cut = through(shape, d.vertices, d.triangles, uvs);
|
|
541
|
+
if (cut === null) throw new Error(`slot "${d.slot}": the clip over it is one the core does not draw, and the raw pose carried it — a defect in src/render_core.ts`);
|
|
542
|
+
if (row === undefined || (row[2] === 1) !== cut.clipped || row[3].join() !== cut.vertices.join() || row[5].join() !== cut.triangles.join()) {
|
|
543
|
+
throw new Error(`slot "${d.slot}": the clip over the page UVs cut other geometry than the core's clipped row — a defect in src/render_core.ts`);
|
|
544
|
+
}
|
|
545
|
+
if (!cut.clipped) return piece;
|
|
546
|
+
let texture = piece.texture;
|
|
547
|
+
const source = clipSourceOf(d.vertices, uvs, d.triangles, cut.sources, texture?.artUvs);
|
|
548
|
+
if (texture !== undefined) {
|
|
549
|
+
const art = through(shape, d.vertices, d.triangles, texture.artUvs);
|
|
550
|
+
if (art === null || art.uvs.length !== cut.uvs.length) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`slot "${piece.slot}": the clip cut ${cut.uvs.length / 2} vertices for the page UVs and ` +
|
|
553
|
+
`${(art?.uvs.length ?? 0) / 2} for the original-art UVs over the same geometry`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
texture = { region: texture.region, artUvs: art.uvs, sourceArtUvs: source.artUvs };
|
|
557
|
+
}
|
|
558
|
+
const { tint, dark, slot, page } = piece;
|
|
559
|
+
return { kind: 'mesh', tint, dark, slot, page, texture, world: cut.vertices, uvs: cut.uvs, triangles: cut.triangles, source: { world: source.world, uvs: source.uvs } };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The rest table (`restOf` in `src/render.ts`): every (slot, attachment) the
|
|
564
|
+
* frames show, in order of first appearance, posed on the setup bones with no
|
|
565
|
+
* deform — the attachment looked up by that name as a placeholder in the
|
|
566
|
+
* posed skin, then the default skin, as `Skeleton.getAttachment` does.
|
|
567
|
+
*/
|
|
568
|
+
function restOf(view: CompiledDocument, setup: RawPose, shown: readonly AttachmentPose[][]): AttachmentRest[] {
|
|
569
|
+
const world = worldOf(setup);
|
|
570
|
+
const bones = new Map(view.slots.map((s) => [s.name, s.bone]));
|
|
571
|
+
const sourceOf = sourceOfDoc(view);
|
|
572
|
+
const seen = new Map<string, Set<string>>();
|
|
573
|
+
const out: AttachmentRest[] = [];
|
|
574
|
+
for (const entries of shown) {
|
|
575
|
+
for (const entry of entries) {
|
|
576
|
+
const names = seen.get(entry.slot) ?? new Set<string>();
|
|
577
|
+
if (names.has(entry.attachment)) continue;
|
|
578
|
+
names.add(entry.attachment);
|
|
579
|
+
seen.set(entry.slot, names);
|
|
580
|
+
const skin = lookupSkins(view).find((k) => k.attachments[entry.slot]?.[entry.attachment] !== undefined);
|
|
581
|
+
const record = skin?.attachments[entry.slot]?.[entry.attachment];
|
|
582
|
+
const g = record?.geometry;
|
|
583
|
+
const boneName = bones.get(entry.slot);
|
|
584
|
+
const bone = boneName === undefined ? undefined : world.get(boneName);
|
|
585
|
+
if (skin === undefined || g === undefined || bone === undefined || (g.kind !== 'region' && g.kind !== 'mesh' && g.kind !== 'linkedmesh')) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`slot ${JSON.stringify(entry.slot)} showed attachment ${JSON.stringify(entry.attachment)} in a frame, and ` +
|
|
588
|
+
'the setup skeleton resolves no region or mesh of that name there',
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
if (g.kind === 'region') {
|
|
592
|
+
const rect = g.region.atlas;
|
|
593
|
+
if (rect === null) throw new CoreInputError(`slot "${entry.slot}" region "${entry.attachment}": the model states the build had no atlas rectangle for it`);
|
|
594
|
+
out.push({ slot: entry.slot, attachment: entry.attachment, kind: 'region', vertices: regionCorners({ ...g.region, atlas: rect }, bone), triangles: [...QUAD_TRIANGLES] });
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const mesh = g.kind === 'mesh' ? g : view.skins.find((k) => k.name === g.skin)?.attachments[g.slot]?.[g.source]?.geometry;
|
|
598
|
+
const vertices = g.kind === 'mesh' ? g.vertices : sourceOf(g.skin, g.slot, g.source);
|
|
599
|
+
if (mesh === undefined || mesh.kind !== 'mesh' || typeof vertices === 'string') throw new CoreInputError(`slot "${entry.slot}": the linked mesh "${entry.attachment}" resolves no source mesh`);
|
|
600
|
+
if (mesh.hull === undefined) throw new CoreInputError(`slot "${entry.slot}" mesh "${entry.attachment}": the model states no hull`);
|
|
601
|
+
out.push({
|
|
602
|
+
slot: entry.slot,
|
|
603
|
+
attachment: entry.attachment,
|
|
604
|
+
kind: 'mesh',
|
|
605
|
+
vertices: worldVertices(vertices, bone, world),
|
|
606
|
+
triangles: [...mesh.triangles],
|
|
607
|
+
hull: mesh.hull,
|
|
608
|
+
uvs: [...mesh.uvs],
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* The first place two `pages` sections differ, by path, or `null` when they
|
|
617
|
+
* are the same — the page count, a page's name or size, its `pma` or `scale`
|
|
618
|
+
* where `stated` carries them (a `rigc-compiled/3` document's, issue #1026), a
|
|
619
|
+
* region count, or a region's name or one of its numbers.
|
|
620
|
+
*/
|
|
621
|
+
export function firstPageDifference(stated: readonly ModelPage[], found: readonly ModelPage[]): string | null {
|
|
622
|
+
if (stated.length !== found.length) return `the document states ${stated.length} page(s), the atlas has ${found.length}`;
|
|
623
|
+
for (let i = 0; i < stated.length; i++) {
|
|
624
|
+
const a = stated[i];
|
|
625
|
+
const b = found[i];
|
|
626
|
+
for (const key of ['name', 'width', 'height'] as const) {
|
|
627
|
+
if (a[key] !== b[key]) return `pages[${i}].${key} is ${JSON.stringify(a[key])} in the document and ${JSON.stringify(b[key])} in the atlas`;
|
|
628
|
+
}
|
|
629
|
+
// A rigc-compiled/3 page states its `pma` and `scale` too (issue #1026), and an atlas edited in either after the build is not the one it was written beside; a /2 page states neither, and is held to its placement alone.
|
|
630
|
+
for (const key of ['pma', 'scale'] as const) {
|
|
631
|
+
if (a[key] !== undefined && a[key] !== b[key]) return `pages[${i}] "${a.name}": ${key} is ${JSON.stringify(a[key])} in the document and ${JSON.stringify(b[key])} in the atlas`;
|
|
632
|
+
}
|
|
633
|
+
if (a.regions.length !== b.regions.length) return `pages[${i}] "${a.name}" holds ${a.regions.length} region(s) in the document and ${b.regions.length} in the atlas`;
|
|
634
|
+
for (let j = 0; j < a.regions.length; j++) {
|
|
635
|
+
const r = a.regions[j];
|
|
636
|
+
const q = b.regions[j];
|
|
637
|
+
for (const key of Object.keys(r) as Array<keyof typeof r>) {
|
|
638
|
+
if (r[key] !== q[key]) return `pages[${i}] "${a.name}" region ${JSON.stringify(r.name)}: ${key} is ${JSON.stringify(r[key])} in the document and ${JSON.stringify(q[key])} in the atlas`;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Where each region a core pose draws sits on its page, read from the
|
|
647
|
+
* document's `pages` section (issue #1016) — or, for a `rigc-compiled/1`
|
|
648
|
+
* document, which has none, from `atlasText` as before. With both, the atlas
|
|
649
|
+
* must be the one the document was written beside: a page or region that
|
|
650
|
+
* differs is refused naming the first difference, so the core never draws the
|
|
651
|
+
* build's placement over another atlas's pages while spine-core, reading that
|
|
652
|
+
* atlas, would draw another picture.
|
|
653
|
+
*/
|
|
654
|
+
function placementOf(doc: CompiledDocument, atlasText: string, where: string): { lookup: UvSource['lookup']; region: CoreInput['region'] } {
|
|
655
|
+
if (doc.pages !== null) {
|
|
656
|
+
if (atlasText !== '') {
|
|
657
|
+
const differs = firstPageDifference(doc.pages, pagesOfAtlas(atlasText));
|
|
658
|
+
if (differs !== null) {
|
|
659
|
+
throw new CoreInputError(
|
|
660
|
+
`the atlas beside ${where} is not the one it was written beside: ${differs} — ` +
|
|
661
|
+
'the core would draw the build\'s placement over pages the atlas has rearranged',
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const lookup = documentPageLookup(doc.pages);
|
|
666
|
+
return { lookup, region: (name) => lookup(name)?.region ?? null };
|
|
667
|
+
}
|
|
668
|
+
if (atlasText === '') {
|
|
669
|
+
throw new CoreInputError(
|
|
670
|
+
`${where} is a ${doc.spec} document, which does not state where each region sits on its page (the pages section, issue #1016), ` +
|
|
671
|
+
'and no atlas was given to read it from — rebuild it to carry them, or pose it beside the atlas it was built with',
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
const lookup = atlasRegionLookup(parseAtlasText(atlasText));
|
|
675
|
+
return { lookup, region: (name) => lookup(name)?.region ?? null };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* `Poser` over rigc's own core: `modelText` a `rigc-compiled/3` or `/2` document
|
|
680
|
+
* (`skeleton.model.json`), which states where each region sits on its page
|
|
681
|
+
* (`pages`, issue #1016), so `atlasText` may be `''`; given, it is held to
|
|
682
|
+
* the document's `pages` (`placementOf`). A `rigc-compiled/1` document states
|
|
683
|
+
* no placement and is drawn through `atlasText`, the atlas `build` wrote
|
|
684
|
+
* beside it, as before; with none given it is refused by name. With `skeleton`, the Spine file beside the document
|
|
685
|
+
* is held to the digest the document records (`spine.sha256`, issue #968) and
|
|
686
|
+
* refused, naming both digests, when it is not that build's. Refused by
|
|
687
|
+
* `CoreInputError`, naming why, where the document or the atlas cannot be read; a pose the core leaves a block of out
|
|
688
|
+
* is refused the same way when it is asked for (the header).
|
|
689
|
+
*/
|
|
690
|
+
export function corePoser(
|
|
691
|
+
modelText: string,
|
|
692
|
+
atlasText: string,
|
|
693
|
+
where = 'skeleton.model.json',
|
|
694
|
+
skeleton?: { path: string; bytes: Uint8Array },
|
|
695
|
+
through: ShapeClipper = clipThrough,
|
|
696
|
+
plants: CorePoserPlants = {},
|
|
697
|
+
): Poser {
|
|
698
|
+
const doc = readModel(modelText, where);
|
|
699
|
+
// The document poses the rig it was built with; the Spine file beside it must be that build's (issue #968).
|
|
700
|
+
if (skeleton !== undefined) {
|
|
701
|
+
const found = spineFileSha256(skeleton.bytes);
|
|
702
|
+
if (found !== doc.spine.sha256) {
|
|
703
|
+
throw new CoreInputError(
|
|
704
|
+
`${skeleton.path} is not the skeleton.json ${where} was written beside: its sha256 is ${found}, the document records ${doc.spine.sha256} — ` +
|
|
705
|
+
'the Spine file was edited or replaced after the build, and the core would draw the build\'s rig instead of it',
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
let parsed: unknown;
|
|
710
|
+
try {
|
|
711
|
+
parsed = JSON.parse(modelText);
|
|
712
|
+
} catch (err) {
|
|
713
|
+
throw new CoreInputError(`${where}: not JSON — ${(err as Error).message}`);
|
|
714
|
+
}
|
|
715
|
+
const sequences = readUvSequences(parsed);
|
|
716
|
+
const { lookup, region } = placementOf(doc, atlasText, where);
|
|
717
|
+
const views = new Map<string, CompiledDocument>();
|
|
718
|
+
const arrays = (plants.arrays ?? keptPieceArrays)();
|
|
719
|
+
const walk = plants.walk ?? CORE_RAW_WALK;
|
|
720
|
+
const inputOf = (skin: string | undefined): CoreInput => {
|
|
721
|
+
const key = skin === undefined ? '' : `=${skin}`;
|
|
722
|
+
let view = views.get(key);
|
|
723
|
+
if (view === undefined) {
|
|
724
|
+
view = skin === undefined ? noSkinView(doc) : underSkin(doc, skin);
|
|
725
|
+
views.set(key, view);
|
|
726
|
+
}
|
|
727
|
+
return { doc: view, source: { lookup, sequences }, region, arrays };
|
|
728
|
+
};
|
|
729
|
+
const roster: SubsetRoster = {
|
|
730
|
+
declared: doc.slots.map((s) => s.name),
|
|
731
|
+
carriers: (slot) => doc.skins.filter((k) => Object.keys(k.attachments[slot] ?? {}).length > 0).map((k) => k.name),
|
|
732
|
+
defaultSkin: doc.skins.some((k) => k.name === CORE_DEFAULT_SKIN) ? CORE_DEFAULT_SKIN : null,
|
|
733
|
+
};
|
|
734
|
+
return {
|
|
735
|
+
animations: doc.animations.map((a) => ({ name: a.name, duration: a.timelines.duration })),
|
|
736
|
+
bones: doc.bones.map((b) => ({ name: b.name, parent: b.parent ?? null })),
|
|
737
|
+
slots: doc.slots.map((s) => ({ name: s.name, bone: s.bone })),
|
|
738
|
+
subset: (opts, skin) => subsetOver(roster, opts, skin),
|
|
739
|
+
setup: (skin) => {
|
|
740
|
+
const input = inputOf(skin);
|
|
741
|
+
return corePosed(input, poseRawSetup(input.doc, { through }), through);
|
|
742
|
+
},
|
|
743
|
+
animation: (name, skin, fps, count, visit) => {
|
|
744
|
+
const input = inputOf(skin);
|
|
745
|
+
const step = 1 / fps;
|
|
746
|
+
// Each pose is drawn as it is posed and released before the next (issue #1180), so the walk holds one pose and not
|
|
747
|
+
// the animation's series. What is thrown is what was thrown when the series was posed first and drawn after: a
|
|
748
|
+
// walk that refuses a later pose still refuses the call, and a draw that throws is re-thrown once the walk has
|
|
749
|
+
// finished without refusing — the first such throw, by frame — and no frame after it is drawn.
|
|
750
|
+
const drawFailed: unknown[] = [];
|
|
751
|
+
walk(input.doc, name, new Array<number>(count).fill(step), { through }, (pose, i) => {
|
|
752
|
+
if (drawFailed.length > 0) return;
|
|
753
|
+
try {
|
|
754
|
+
visit(i, corePosed(input, pose, through));
|
|
755
|
+
} catch (thrown) {
|
|
756
|
+
drawFailed.push(thrown);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
if (drawFailed.length > 0) throw drawFailed[0];
|
|
760
|
+
},
|
|
761
|
+
rest: (skin, shown) => {
|
|
762
|
+
const input = inputOf(skin);
|
|
763
|
+
return restOf(input.doc, poseRawSetup(input.doc), shown);
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The skin roster behind the core poser (`SkinRoster` in `src/render.ts`,
|
|
770
|
+
* issue #1014): the bones each skin leaves unposed, read off the model
|
|
771
|
+
* document — `unposedBones` over `activeBones` of the skin's view, the
|
|
772
|
+
* predicate the raw pose flags its bones with, and under no skin the view the
|
|
773
|
+
* core poses with no skin set (`noSkinView`). `skins` is the skin list in the Spine file's order:
|
|
774
|
+
* a `rigc-compiled/3` document's `editorOrder` (issue #1026), else the file's
|
|
775
|
+
* own list, which a `/2` or `/1` document does not hold (`resolveSkinView`'s
|
|
776
|
+
* note in `./core/index.ts`).
|
|
777
|
+
*
|
|
778
|
+
* Measured against the runtime's reading (`skinRosterOf`, a fresh skeleton's
|
|
779
|
+
* `active` under each skin and under none) on every rigc build the tree
|
|
780
|
+
* carries: the same bones, under every skin. Since issue #1020 it is handed the
|
|
781
|
+
* document `coreDocumentFacts` read for the run's other facts, so the roster
|
|
782
|
+
* costs no reading of its own; a skin's view is still built only when a
|
|
783
|
+
* question is asked of it.
|
|
784
|
+
*/
|
|
785
|
+
function coreSkinRoster(doc: CompiledDocument, skins: readonly string[]): SkinRoster {
|
|
786
|
+
return {
|
|
787
|
+
skins,
|
|
788
|
+
unposedUnder: (skin) => {
|
|
789
|
+
const view = skin === undefined ? noSkinView(doc) : underSkin(doc, skin);
|
|
790
|
+
const active = activeBones(view);
|
|
791
|
+
return unposedBones(view.bones.map((b) => ({ name: b.name, parent: b.parent ?? null, active: active.has(b.name) })));
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* What `render` and `check` read off a rigc build's model document besides the
|
|
798
|
+
* pose (issue #1020) — so a build the core poses draws with its
|
|
799
|
+
* `skeleton.atlas` gone, and reads off `skeleton.json` only what the document
|
|
800
|
+
* does not state.
|
|
801
|
+
*/
|
|
802
|
+
export interface CoreDocumentFacts {
|
|
803
|
+
/** The document's spec: `rigc-compiled/3` and `/2` state where each region sits on its page, `rigc-compiled/1` does not; `/3` alone states the orders, the stage and the pages' `scale:` lines (issue #1026). */
|
|
804
|
+
spec: string;
|
|
805
|
+
/**
|
|
806
|
+
* Every page the `pages` section states, by name, in file order — the
|
|
807
|
+
* images the draw samples, read by these names; `null` for a
|
|
808
|
+
* `rigc-compiled/1` document, whose pages only its atlas names.
|
|
809
|
+
*/
|
|
810
|
+
pageNames: readonly string[] | null;
|
|
811
|
+
/** The skin roster behind the core poser (`coreSkinRoster`), over this one reading. */
|
|
812
|
+
roster: SkinRoster;
|
|
813
|
+
/**
|
|
814
|
+
* What a `rigc-compiled/3` document states that `skeleton.json` and the
|
|
815
|
+
* atlas were the only place of before issue #1026 — the order the file lists
|
|
816
|
+
* animations and skins in, whether a stage is declared, and the `scale:`
|
|
817
|
+
* lines its pages state, in page order — or `null` for a `/2` or `/1`
|
|
818
|
+
* document, whose reader takes them off the files beside it and says so.
|
|
819
|
+
*/
|
|
820
|
+
stated: { animations: readonly string[]; skins: readonly string[]; declaresStage: boolean; scales: readonly number[] } | null;
|
|
821
|
+
/**
|
|
822
|
+
* The slot subset's roster (`subsetOver`): the slots in the document's draw
|
|
823
|
+
* order, its default skin, and the skins the document files a slot's
|
|
824
|
+
* attachments under. A refusal lists those skins, and the order it lists
|
|
825
|
+
* them in is the Spine file's (`./core/index.ts`, *Several skins filling one
|
|
826
|
+
* placeholder*) — so they are put in the skin order a `rigc-compiled/3`
|
|
827
|
+
* document states (`editorOrder`, issue #1026), or, for a `/2` or `/1`
|
|
828
|
+
* document, which does not hold it, the Spine file's own list.
|
|
829
|
+
*/
|
|
830
|
+
subset: SubsetRoster;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* The document's facts for `render` and `check` (`CoreDocumentFacts`), read
|
|
835
|
+
* once. `fileSkins` is the Spine file's skin list, in its order — what a
|
|
836
|
+
* `rigc-compiled/2` or `/1` document does not state; a `/3` document's own
|
|
837
|
+
* `editorOrder` is read instead (issue #1026). Refused by `CoreInputError`
|
|
838
|
+
* where `readModel` refuses the document.
|
|
839
|
+
*/
|
|
840
|
+
export function coreDocumentFacts(modelText: string, where: string, fileSkins: readonly string[]): CoreDocumentFacts {
|
|
841
|
+
const doc = readModel(modelText, where);
|
|
842
|
+
const stated = doc.stated;
|
|
843
|
+
const skins = stated === null ? fileSkins : stated.editorOrder.skins.map((k) => k.name);
|
|
844
|
+
const rank = (name: string): number => {
|
|
845
|
+
const at = skins.indexOf(name);
|
|
846
|
+
return at < 0 ? skins.length : at;
|
|
847
|
+
};
|
|
848
|
+
return {
|
|
849
|
+
spec: doc.spec,
|
|
850
|
+
pageNames: doc.pages === null ? null : doc.pages.map((page) => page.name),
|
|
851
|
+
roster: coreSkinRoster(doc, skins),
|
|
852
|
+
stated:
|
|
853
|
+
stated === null
|
|
854
|
+
? null
|
|
855
|
+
: {
|
|
856
|
+
animations: stated.editorOrder.animations,
|
|
857
|
+
skins,
|
|
858
|
+
declaresStage: stated.stage !== null,
|
|
859
|
+
scales: (doc.pages ?? []).flatMap((page) => (typeof page.scale === 'number' ? [page.scale] : [])),
|
|
860
|
+
},
|
|
861
|
+
subset: {
|
|
862
|
+
declared: doc.slots.map((s) => s.name),
|
|
863
|
+
carriers: (slot) =>
|
|
864
|
+
doc.skins
|
|
865
|
+
.filter((k) => Object.keys(k.attachments[slot] ?? {}).length > 0)
|
|
866
|
+
.map((k) => k.name)
|
|
867
|
+
.sort((a, b) => rank(a) - rank(b)),
|
|
868
|
+
defaultSkin: doc.skins.some((k) => k.name === CORE_DEFAULT_SKIN) ? CORE_DEFAULT_SKIN : null,
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
}
|