spine-rigc 0.5.0 → 0.6.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/NOTICE.md +21 -0
- package/README.md +323 -224
- package/bin/rigc.cjs +40 -0
- package/cli.ts +558 -49
- package/docs/AUTHORING.md +60 -23
- package/docs/PROMPTING.md +106 -0
- package/docs/SPEC_COVERAGE.md +16 -14
- package/package.json +4 -2
- package/src/compile.ts +189 -142
- package/src/emit.ts +88 -0
- package/src/json-position.ts +253 -0
- package/src/png.ts +72 -7
- package/src/preview.ts +243 -0
- package/src/render.ts +58 -0
- package/src/types.ts +8 -0
- package/src/validate.ts +58 -13
- package/tools/plate.ts +164 -21
package/src/compile.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { basename, dirname, relative, resolve } from 'node:path';
|
|
|
21
21
|
import { existsSync, readFileSync } from 'node:fs';
|
|
22
22
|
import { readPngInfo } from './png.ts';
|
|
23
23
|
import { CompileError, NotImplementedError } from './errors.ts';
|
|
24
|
+
import { parseJsonWithPosition } from './json-position.ts';
|
|
24
25
|
import {
|
|
25
26
|
parseRigSpec,
|
|
26
27
|
type RigAttachment,
|
|
@@ -255,8 +256,17 @@ export function bezierForChannel(
|
|
|
255
256
|
// ---------------------------------------------------------------------------
|
|
256
257
|
|
|
257
258
|
function readJson<T>(path: string): T {
|
|
259
|
+
let text: string;
|
|
258
260
|
try {
|
|
259
|
-
|
|
261
|
+
text = readFileSync(path, 'utf8');
|
|
262
|
+
} catch (err) {
|
|
263
|
+
throw new CompileError(`cannot read ${path}: ${(err as Error).message}`);
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
// A parse failure gets a line/column appended to the runtime's own
|
|
267
|
+
// message — see `parseJsonWithPosition` for why `JSON.parse` alone
|
|
268
|
+
// cannot say where.
|
|
269
|
+
return parseJsonWithPosition(text) as T;
|
|
260
270
|
} catch (err) {
|
|
261
271
|
throw new CompileError(`cannot read ${path}: ${(err as Error).message}`);
|
|
262
272
|
}
|
|
@@ -319,6 +329,36 @@ export interface CompileOptions {
|
|
|
319
329
|
imagesDir?: string;
|
|
320
330
|
}
|
|
321
331
|
|
|
332
|
+
/**
|
|
333
|
+
* One part = one page. No packer, so no PMA trap, no rotation, no strip
|
|
334
|
+
* offsets. Region covers the page exactly => u2=v2=1.
|
|
335
|
+
*
|
|
336
|
+
* Two text-shape traps are load-bearing here:
|
|
337
|
+
* * a region name is the RAW line, not a trimmed one -> no indentation;
|
|
338
|
+
* * a blank line closes the page block -> none between header and regions.
|
|
339
|
+
*
|
|
340
|
+
* Exported (rather than inlined into `compile`) so `--copy-images` can call it a
|
|
341
|
+
* second time with `page` rewritten to the copies' filenames, after the copy
|
|
342
|
+
* itself has happened — see [`src/emit.ts`](emit.ts). Everything else about an
|
|
343
|
+
* image (`region`, `width`, `height`) is unchanged by that rewrite; only where the
|
|
344
|
+
* bytes live moved.
|
|
345
|
+
*/
|
|
346
|
+
export function buildAtlasText(images: CompiledImage[]): string {
|
|
347
|
+
const atlasLines: string[] = [];
|
|
348
|
+
images.forEach((img, i) => {
|
|
349
|
+
if (i > 0) atlasLines.push(''); // exactly one blank line BETWEEN pages
|
|
350
|
+
atlasLines.push(img.page);
|
|
351
|
+
atlasLines.push(`size: ${img.width}, ${img.height}`);
|
|
352
|
+
atlasLines.push('filter: Linear, Linear');
|
|
353
|
+
atlasLines.push('pma: false');
|
|
354
|
+
atlasLines.push(img.region);
|
|
355
|
+
atlasLines.push(`bounds: 0, 0, ${img.width}, ${img.height}`);
|
|
356
|
+
atlasLines.push(`offsets: 0, 0, ${img.width}, ${img.height}`);
|
|
357
|
+
atlasLines.push('rotate: 0');
|
|
358
|
+
});
|
|
359
|
+
return `${atlasLines.join('\n')}\n`;
|
|
360
|
+
}
|
|
361
|
+
|
|
322
362
|
export function compile(opts: CompileOptions): CompileResult {
|
|
323
363
|
const rigPath = resolve(opts.rigPath);
|
|
324
364
|
const motionPath = resolve(opts.motionPath);
|
|
@@ -330,17 +370,38 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
330
370
|
const motion = readJson<MotionSpec>(motionPath);
|
|
331
371
|
const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath);
|
|
332
372
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
373
|
+
// The rig spec names its own path in every message `parseRigSpec` throws (its
|
|
374
|
+
// `where` argument, above). The motion spec gets no such treatment below —
|
|
375
|
+
// every "animation … bone … property" refusal is built from data alone — so a
|
|
376
|
+
// reader with two input files and one error has no way to tell which one is at
|
|
377
|
+
// fault. This wraps the motion-only regions (the version/archetype check right
|
|
378
|
+
// below, the physics table and the animation loop further down) and prefixes
|
|
379
|
+
// the motion path onto any `CompileError` that escapes them, unless the
|
|
380
|
+
// message already names it.
|
|
381
|
+
const withMotionSource = <T>(fn: () => T): T => {
|
|
382
|
+
try {
|
|
383
|
+
return fn();
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err instanceof CompileError && !err.message.includes(motionPath)) {
|
|
386
|
+
throw new CompileError(`${motionPath}: ${err.message}`);
|
|
387
|
+
}
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
withMotionSource(() => {
|
|
393
|
+
if (motion.spec !== 'rigc-motion/1') {
|
|
394
|
+
throw new CompileError(`unknown motion spec version: ${String(motion.spec)}`);
|
|
395
|
+
}
|
|
396
|
+
// The motion spec was authored against one formation. Pairing it with another
|
|
397
|
+
// rig would aim its keys at bones whose names happen to match and whose meaning
|
|
398
|
+
// does not — the class of wrongness that loads, plays and lies.
|
|
399
|
+
if (motion.archetype !== rig.name) {
|
|
400
|
+
throw new CompileError(
|
|
401
|
+
`motion spec names archetype "${motion.archetype}" but the rig spec at ${rigPath} is called "${rig.name}"`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
344
405
|
|
|
345
406
|
// The stage. The rig may state it outright (a foreign skeleton has no crop);
|
|
346
407
|
// otherwise the manifest's crop is it. With neither there is nothing to
|
|
@@ -546,25 +607,7 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
546
607
|
}
|
|
547
608
|
|
|
548
609
|
// -- 2. atlas --------------------------------------------------------------
|
|
549
|
-
|
|
550
|
-
// offsets. Region covers the page exactly => u2=v2=1.
|
|
551
|
-
//
|
|
552
|
-
// Two text-shape traps are load-bearing here:
|
|
553
|
-
// * a region name is the RAW line, not a trimmed one -> no indentation;
|
|
554
|
-
// * a blank line closes the page block -> none between header and regions.
|
|
555
|
-
const atlasLines: string[] = [];
|
|
556
|
-
images.forEach((img, i) => {
|
|
557
|
-
if (i > 0) atlasLines.push(''); // exactly one blank line BETWEEN pages
|
|
558
|
-
atlasLines.push(img.page);
|
|
559
|
-
atlasLines.push(`size: ${img.width}, ${img.height}`);
|
|
560
|
-
atlasLines.push('filter: Linear, Linear');
|
|
561
|
-
atlasLines.push('pma: false');
|
|
562
|
-
atlasLines.push(img.region);
|
|
563
|
-
atlasLines.push(`bounds: 0, 0, ${img.width}, ${img.height}`);
|
|
564
|
-
atlasLines.push(`offsets: 0, 0, ${img.width}, ${img.height}`);
|
|
565
|
-
atlasLines.push('rotate: 0');
|
|
566
|
-
});
|
|
567
|
-
const atlasText = `${atlasLines.join('\n')}\n`;
|
|
610
|
+
const atlasText = buildAtlasText(images);
|
|
568
611
|
|
|
569
612
|
// -- 3. bones --------------------------------------------------------------
|
|
570
613
|
//
|
|
@@ -728,128 +771,132 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
728
771
|
constraints.push(buildRigConstraint(spec as RigConstraintInput, boneNames));
|
|
729
772
|
constraintNames.add(spec.name);
|
|
730
773
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
774
|
+
withMotionSource(() => {
|
|
775
|
+
for (const [name, spec] of Object.entries(motion.physics ?? {})) {
|
|
776
|
+
if (constraintNames.has(name)) {
|
|
777
|
+
throw new CompileError(`constraint "${name}" is declared in both the rig spec and the motion spec's physics table`);
|
|
778
|
+
}
|
|
779
|
+
constraintNames.add(name);
|
|
780
|
+
if (!boneNames.has(spec.bone)) {
|
|
781
|
+
throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
|
|
782
|
+
}
|
|
783
|
+
const entry: SpineConstraint = {
|
|
784
|
+
name,
|
|
785
|
+
type: 'physics',
|
|
786
|
+
bone: spec.bone,
|
|
787
|
+
};
|
|
788
|
+
const components: string[] = [];
|
|
789
|
+
for (const comp of PHYSICS_COMPONENTS) {
|
|
790
|
+
const v = spec[comp];
|
|
791
|
+
if (v === undefined || v === 0) continue;
|
|
792
|
+
entry[comp] = r6(v);
|
|
793
|
+
components.push(comp);
|
|
794
|
+
}
|
|
795
|
+
if (!components.length) {
|
|
796
|
+
// The parser is happy with this and the constraint does nothing at all.
|
|
797
|
+
// A23 catches it too; refusing here means it never reaches the gate.
|
|
798
|
+
throw new CompileError(
|
|
799
|
+
`physics constraint "${name}" drives no component — set at least one of ${PHYSICS_COMPONENTS.join('/')}`,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
for (const [param, dflt] of PHYSICS_PARAMS) {
|
|
803
|
+
const v = spec[param as keyof typeof spec] as number | undefined;
|
|
804
|
+
if (v === undefined || v === dflt) continue;
|
|
805
|
+
entry[param] = r6(v);
|
|
806
|
+
}
|
|
807
|
+
constraints.push(entry);
|
|
808
|
+
physicsReport.push({
|
|
809
|
+
name,
|
|
810
|
+
bone: spec.bone,
|
|
811
|
+
components,
|
|
812
|
+
mix: spec.mix ?? 1,
|
|
813
|
+
drivesMesh: meshBones.has(spec.bone),
|
|
814
|
+
});
|
|
757
815
|
}
|
|
758
|
-
|
|
759
|
-
const v = spec[param as keyof typeof spec] as number | undefined;
|
|
760
|
-
if (v === undefined || v === dflt) continue;
|
|
761
|
-
entry[param] = r6(v);
|
|
762
|
-
}
|
|
763
|
-
constraints.push(entry);
|
|
764
|
-
physicsReport.push({
|
|
765
|
-
name,
|
|
766
|
-
bone: spec.bone,
|
|
767
|
-
components,
|
|
768
|
-
mix: spec.mix ?? 1,
|
|
769
|
-
drivesMesh: meshBones.has(spec.bone),
|
|
770
|
-
});
|
|
771
|
-
}
|
|
816
|
+
});
|
|
772
817
|
|
|
773
818
|
// -- 5. animations ---------------------------------------------------------
|
|
774
819
|
const animations: SpineSkeletonJson['animations'] = {};
|
|
775
820
|
const declaredDurations: Record<string, number> = {};
|
|
776
821
|
const slotNames = new Set(slots.map((s) => s.name));
|
|
777
822
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (
|
|
793
|
-
|
|
823
|
+
withMotionSource(() => {
|
|
824
|
+
for (const [animName, anim] of Object.entries(motion.animations)) {
|
|
825
|
+
declaredDurations[animName] = anim.duration;
|
|
826
|
+
const slotTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
827
|
+
const boneTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
828
|
+
const physicsTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
829
|
+
const claimed = new Set<string>();
|
|
830
|
+
let compiledDuration = 0;
|
|
831
|
+
|
|
832
|
+
for (const track of anim.tracks) {
|
|
833
|
+
const isPhysicsTrack = track.property in PHYSICS_TRACKS;
|
|
834
|
+
const isBoneTrack = !isPhysicsTrack && track.property in BONE_TRACKS;
|
|
835
|
+
const targets = resolveTargets(track, motion, animName);
|
|
836
|
+
targets.forEach((target, index) => {
|
|
837
|
+
if (isPhysicsTrack) {
|
|
838
|
+
if (!constraintNames.has(target)) {
|
|
839
|
+
throw new CompileError(`animation "${animName}" keys unknown physics constraint "${target}"`);
|
|
840
|
+
}
|
|
841
|
+
} else if (isBoneTrack) {
|
|
842
|
+
if (!boneNames.has(target)) {
|
|
843
|
+
throw new CompileError(`animation "${animName}" keys unknown bone "${target}"`);
|
|
844
|
+
}
|
|
845
|
+
} else if (!slotNames.has(target)) {
|
|
846
|
+
throw new CompileError(`animation "${animName}" targets unknown slot "${target}"`);
|
|
794
847
|
}
|
|
795
|
-
|
|
796
|
-
if (
|
|
797
|
-
throw new CompileError(
|
|
848
|
+
const claim = `${target}.${track.property}`;
|
|
849
|
+
if (claimed.has(claim)) {
|
|
850
|
+
throw new CompileError(
|
|
851
|
+
`animation "${animName}" has two tracks on ${claim}; merge them into one track`,
|
|
852
|
+
);
|
|
798
853
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
: isBoneTrack
|
|
814
|
-
? compileValueTrack(track, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
|
|
815
|
-
: compileTrack(track, motion, animName, anim.duration, target, shift, tableFor('default'));
|
|
816
|
-
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
817
|
-
if (isPhysicsTrack) (physicsTimelines[target] ??= {})[track.property] = keys;
|
|
818
|
-
else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
|
|
819
|
-
else (slotTimelines[target] ??= {})[track.property] = keys;
|
|
820
|
-
});
|
|
821
|
-
}
|
|
854
|
+
claimed.add(claim);
|
|
855
|
+
|
|
856
|
+
const shift = (track.lag ?? 0) + (track.stagger ?? 0) * index;
|
|
857
|
+
const keys = isPhysicsTrack
|
|
858
|
+
? compileValueTrack(track, motion, animName, anim.duration, target, shift, PHYSICS_TRACKS, 'physics constraint')
|
|
859
|
+
: isBoneTrack
|
|
860
|
+
? compileValueTrack(track, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
|
|
861
|
+
: compileTrack(track, motion, animName, anim.duration, target, shift, tableFor('default'));
|
|
862
|
+
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
863
|
+
if (isPhysicsTrack) (physicsTimelines[target] ??= {})[track.property] = keys;
|
|
864
|
+
else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
|
|
865
|
+
else (slotTimelines[target] ??= {})[track.property] = keys;
|
|
866
|
+
});
|
|
867
|
+
}
|
|
822
868
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
869
|
+
const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
|
|
870
|
+
if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
871
|
+
|
|
872
|
+
const eventKeys = anim.events ? compileEvents(anim.events, animName, anim.duration, rig.events ?? {}) : null;
|
|
873
|
+
// An event timeline counts towards the animation's length the same as any
|
|
874
|
+
// other: `readAnimation` takes the duration from the longest timeline it
|
|
875
|
+
// built, and `EventTimeline.getDuration()` is its last frame like the rest.
|
|
876
|
+
if (eventKeys) for (const key of eventKeys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
877
|
+
|
|
878
|
+
// Rule 4: the declared duration is verified, because skeleton JSON does not
|
|
879
|
+
// carry one — the loader takes the max key time.
|
|
880
|
+
//
|
|
881
|
+
// This arm is about the DECLARED DURATION being wrong, so it compares one
|
|
882
|
+
// number per animation and tolerates a frame of it. The other arm —
|
|
883
|
+
// `checkKeyTime`, above, per key — is about a key landing past the end, and
|
|
884
|
+
// a frame is 16,667 times too coarse to see one. Both are needed: this one
|
|
885
|
+
// catches an animation that stops a second early, and only that one catches
|
|
886
|
+
// a key on a track whose neighbour already sits on the declared duration.
|
|
887
|
+
if (Math.abs(compiledDuration - anim.duration) > FRAME) {
|
|
888
|
+
throw new CompileError(
|
|
889
|
+
`animation "${animName}" declares duration ${anim.duration}s but its last key is at ${compiledDuration}s`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
animations[animName] = {};
|
|
893
|
+
if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
|
|
894
|
+
if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
|
|
895
|
+
if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
|
|
896
|
+
if (drawOrder) animations[animName].drawOrder = drawOrder;
|
|
897
|
+
if (eventKeys) animations[animName].events = eventKeys;
|
|
845
898
|
}
|
|
846
|
-
|
|
847
|
-
if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
|
|
848
|
-
if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
|
|
849
|
-
if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
|
|
850
|
-
if (drawOrder) animations[animName].drawOrder = drawOrder;
|
|
851
|
-
if (eventKeys) animations[animName].events = eventKeys;
|
|
852
|
-
}
|
|
899
|
+
});
|
|
853
900
|
|
|
854
901
|
// -- 6. assemble -----------------------------------------------------------
|
|
855
902
|
const header: SpineSkeletonJson['skeleton'] = {
|
package/src/emit.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing the compiled atlas to disk with its pages copied alongside it — the
|
|
3
|
+
* step behind `build --copy-images` (issue #217).
|
|
4
|
+
*
|
|
5
|
+
* `compile()` never touches the filesystem for output; it only reads inputs and
|
|
6
|
+
* assembles text (see its own header). A page's name in that text is
|
|
7
|
+
* `relative(outDir, absPath)` — the PNG's path *as seen from the atlas file* — so
|
|
8
|
+
* it points at wherever the source art already lives, which is very often
|
|
9
|
+
* outside `outDir` (`../parts/torso.png`). That is the right default for a build
|
|
10
|
+
* that sits beside the project that owns the art, and it is exactly what makes
|
|
11
|
+
* `--out` non-self-contained the moment the directory is zipped, committed or
|
|
12
|
+
* handed to someone who does not have that project.
|
|
13
|
+
*
|
|
14
|
+
* `copyAtlasImages` is the opt-in other half: copy every referenced page PNG
|
|
15
|
+
* into the output directory and hand back the atlas text with page names
|
|
16
|
+
* rewritten to match. Nothing here runs unless `cli.ts` calls it, so the default
|
|
17
|
+
* emit (`buildAtlasText` in [`src/compile.ts`](compile.ts)) is untouched.
|
|
18
|
+
*/
|
|
19
|
+
import { copyFileSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { basename, extname, join } from 'node:path';
|
|
21
|
+
import { buildAtlasText } from './compile.ts';
|
|
22
|
+
import type { CompiledImage } from './types.ts';
|
|
23
|
+
|
|
24
|
+
/** One page's source and where `--copy-images` put it. */
|
|
25
|
+
export interface CopiedPage {
|
|
26
|
+
region: string;
|
|
27
|
+
/** Absolute path the page was read from. */
|
|
28
|
+
from: string;
|
|
29
|
+
/** Filename inside the output directory — also the atlas's new page name. */
|
|
30
|
+
to: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CopyImagesResult {
|
|
34
|
+
/** The atlas text, with every page name rewritten to its `to` filename. */
|
|
35
|
+
atlasText: string;
|
|
36
|
+
pages: CopiedPage[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Copy every image `compile()` referenced into `outDir` and return the atlas
|
|
41
|
+
* text rewritten to match.
|
|
42
|
+
*
|
|
43
|
+
* ## Naming and collisions
|
|
44
|
+
*
|
|
45
|
+
* A page's filename in `outDir` is its source basename, unchanged. That is safe
|
|
46
|
+
* because rigc already enforces a stronger invariant upstream: a region name IS
|
|
47
|
+
* the PNG basename (`compile.ts`'s `addImage`), and two images sharing a region
|
|
48
|
+
* are refused at compile time — so within one compile, no two entries of
|
|
49
|
+
* `images` can legitimately carry the same basename already.
|
|
50
|
+
*
|
|
51
|
+
* The check below exists anyway. "The caller already prevents this" stops being
|
|
52
|
+
* true the moment the caller changes, and a case-insensitive filesystem (the
|
|
53
|
+
* macOS default) can still collide two basenames the region check saw as
|
|
54
|
+
* distinct (`Torso.png` vs `torso.png` are different regions, one destination
|
|
55
|
+
* file). On a genuine collision, the second and later claimants of a name get
|
|
56
|
+
* `-2`, `-3`, … inserted before the extension, assigned in the order `images`
|
|
57
|
+
* lists them — the same order the atlas itself is written in, so the mapping is
|
|
58
|
+
* identical on every run of the same compile. The rewritten atlas is the record
|
|
59
|
+
* of it: reading a page name back out says exactly which file it is, with no
|
|
60
|
+
* side table to fall out of sync.
|
|
61
|
+
*/
|
|
62
|
+
export function copyAtlasImages(images: CompiledImage[], outDir: string): CopyImagesResult {
|
|
63
|
+
mkdirSync(outDir, { recursive: true });
|
|
64
|
+
|
|
65
|
+
const claimedBy = new Map<string, string>(); // destination filename -> absPath holding it
|
|
66
|
+
const pages: CopiedPage[] = [];
|
|
67
|
+
|
|
68
|
+
for (const img of images) {
|
|
69
|
+
const original = basename(img.absPath);
|
|
70
|
+
let name = original;
|
|
71
|
+
if (claimedBy.has(name) && claimedBy.get(name) !== img.absPath) {
|
|
72
|
+
const ext = extname(original);
|
|
73
|
+
const stem = original.slice(0, original.length - ext.length);
|
|
74
|
+
let n = 2;
|
|
75
|
+
do {
|
|
76
|
+
name = `${stem}-${n}${ext}`;
|
|
77
|
+
n++;
|
|
78
|
+
} while (claimedBy.has(name) && claimedBy.get(name) !== img.absPath);
|
|
79
|
+
}
|
|
80
|
+
claimedBy.set(name, img.absPath);
|
|
81
|
+
pages.push({ region: img.region, from: img.absPath, to: name });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const page of pages) copyFileSync(page.from, join(outDir, page.to));
|
|
85
|
+
|
|
86
|
+
const rewritten: CompiledImage[] = images.map((img, i) => ({ ...img, page: pages[i].to }));
|
|
87
|
+
return { atlasText: buildAtlasText(rewritten), pages };
|
|
88
|
+
}
|