@rig-c/core 2.84.0 → 2.85.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/docs/AUTHORING.md +51 -0
- package/package.json +1 -1
- package/src/cli/core_commands.ts +37 -1
- package/src/cli/shared.ts +12 -0
- package/src/render.ts +9 -5
- package/src/render_core.ts +5 -3
- package/src/render_shared.ts +8 -1
- package/src/texture_scale.ts +153 -0
package/docs/AUTHORING.md
CHANGED
|
@@ -12792,6 +12792,57 @@ for `declared` and the run says so on a line of its own.
|
|
|
12792
12792
|
- **Not a second poser.** Everything it draws is a pose `render` would have drawn at
|
|
12793
12793
|
the same frame; where it disagrees with `render --geometry`, it is wrong (`IN04`).
|
|
12794
12794
|
|
|
12795
|
+
### Texture scale — warning-only region reading
|
|
12796
|
+
|
|
12797
|
+
```bash
|
|
12798
|
+
rigc texture-scale --candidate build/ --json texture-scale.json
|
|
12799
|
+
```
|
|
12800
|
+
|
|
12801
|
+
`REGION_TEXTURE_MINIFIED` names each drawn region attachment whose **smallest
|
|
12802
|
+
sampled draw scale is below 0.5 world units per stored texture pixel**, with its
|
|
12803
|
+
slot, attachment, atlas region/page/index, stored texture width and height, and
|
|
12804
|
+
the animation and time of that sample (`animation: null`, `time: 0` for setup).
|
|
12805
|
+
The strict threshold applies to the measured value: a nominal authored 0.5 can
|
|
12806
|
+
read just below 0.5 after runtime transforms and floating-point storage.
|
|
12807
|
+
It is a warning, never an assertion or a build refusal. This separate reading
|
|
12808
|
+
changes no build, atlas or rendered frame. Scale 1.0 and exactly 0.5 stay silent.
|
|
12809
|
+
|
|
12810
|
+
The scale is the smallest singular value of the texture-pixel → world 2×2
|
|
12811
|
+
map read from posed region corners and their page UVs. It includes attachment
|
|
12812
|
+
size/scale, parent transforms, constraints, rotation, reflection, anisotropy,
|
|
12813
|
+
shear, trimming and atlas rotation. Raw bone scale alone cannot give this value.
|
|
12814
|
+
The consumer's camera, device pixels and viewport scaling are outside it.
|
|
12815
|
+
|
|
12816
|
+
Setup is always read, then every animation under `--skin <name>` (without it,
|
|
12817
|
+
no skin is set). `--fps <n>` defaults to 60. The sorted sample schedule combines
|
|
12818
|
+
that grid, each timeline's float32 key times, key-interval midpoints and the
|
|
12819
|
+
animation endpoint. Physics is stepped along this schedule, using the render
|
|
12820
|
+
clock. The JSON (`rigc-texture-scale/1`) records the exact schedule, threshold,
|
|
12821
|
+
rate, skin, count of distinct drawn region attachments and `warnings`.
|
|
12822
|
+
`--json` must be outside the candidate directory (symlinks are resolved). An
|
|
12823
|
+
existing file is replaced only when it is already a `rigc-texture-scale/1`
|
|
12824
|
+
report; a source artifact is refused before posing and left byte-identical.
|
|
12825
|
+
Text and JSON report the **minimum among these samples**, not a continuous
|
|
12826
|
+
minimum: interpolated transforms, shear and constraints can reach narrower
|
|
12827
|
+
extrema between them. Increase the rate to investigate; silence is not proof
|
|
12828
|
+
that no unsampled extreme exists. A tie keeps the first observation, setup first.
|
|
12829
|
+
|
|
12830
|
+
Only selected, active region attachments with positive combined alpha,
|
|
12831
|
+
nonempty clipped geometry and a source rectangle containing alpha are read.
|
|
12832
|
+
Meshes, unselected attachments, alpha-zero regions and wholly clipped regions
|
|
12833
|
+
are excluded. This is a geometric draw reading; it does not test occlusion by
|
|
12834
|
+
later slots or whether the visible clipped portion samples only transparent
|
|
12835
|
+
texels. The threshold identifies potential minification without mipmaps; it
|
|
12836
|
+
does not assert visible dither for every source texture.
|
|
12837
|
+
|
|
12838
|
+
**Remedies:** area-downsample the source texture and preserve the region's world
|
|
12839
|
+
width and height (and its placement) when rebuilding, or enable mipmaps in the
|
|
12840
|
+
consumer. For example, a 730×400 texture drawn at 0.167 can be area-downsampled
|
|
12841
|
+
to 122×67 while retaining world dimensions 121.91×66.8; the new draw scale is
|
|
12842
|
+
about 1.0. This removes high-frequency aliasing in the planted fixture, not a
|
|
12843
|
+
guarantee about arbitrary artwork or every consumer filter. rigc never resizes
|
|
12844
|
+
art automatically and this reading changes no atlas mipmap settings.
|
|
12845
|
+
|
|
12795
12846
|
## 14. Read a named mesh at setup
|
|
12796
12847
|
|
|
12797
12848
|
`rig-c/core` promises `readSetupMesh(view, target)` and the types
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.85.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { textureScaleReading, textureScaleLines, TEXTURE_SCALE_FPS } from '../texture_scale.ts';
|
|
1
2
|
import { extremesOf, extremesText, extremesPlate, extremesLine, extremesRefusal } from '../extremes.ts';
|
|
2
3
|
/**
|
|
3
4
|
* The bodies of the commands that reach nothing of spine-core — `explain`,
|
|
@@ -53,7 +54,7 @@ import { writeWhole } from './write_whole.ts';
|
|
|
53
54
|
import { GUIDE_FILE, GUIDE_RULE_TABLES_ELSEWHERE, guideAnswer, refusalLine } from '../guide.ts';
|
|
54
55
|
import { ATLAS_ABSENT, COMMANDS, type CommandRun, DelegateError, modelTextBeside, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, GuideError, printAlphaClips, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPageEdges, readPoserFlag, readSheetFormat, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
|
|
55
56
|
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
56
|
-
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
57
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
57
58
|
import { spawnSync } from 'node:child_process';
|
|
58
59
|
|
|
59
60
|
|
|
@@ -1254,6 +1255,40 @@ export function physicsLine(stepped: readonly string[], declared: readonly strin
|
|
|
1254
1255
|
return `${head} — ${idle.length} of the ${declared.length} declared took none: ${idle.map((n) => JSON.stringify(n)).join(', ')} (${why})`;
|
|
1255
1256
|
}
|
|
1256
1257
|
|
|
1258
|
+
/** Warning-only reading, independent of build and of raster output. */
|
|
1259
|
+
export function cmdTextureScale(flags: Record<string, string>): void {
|
|
1260
|
+
const accepted = ['candidate', 'atlas', 'skin', 'fps', 'poser', 'json', 'help'];
|
|
1261
|
+
const stray = Object.keys(flags).filter(k => !accepted.includes(k));
|
|
1262
|
+
if (stray.length > 0) throw new UsageError(`texture-scale takes no --${stray.join(', --')}; use texture-scale --help`);
|
|
1263
|
+
if (flags.candidate === undefined) throw new UsageError('texture-scale needs --candidate <dir | skeleton.json>');
|
|
1264
|
+
const fps = readPositiveNumber(flags, 'fps', TEXTURE_SCALE_FPS, 1);
|
|
1265
|
+
const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
|
|
1266
|
+
if (flags.json !== undefined) {
|
|
1267
|
+
const target = realpathAhead(flags.json);
|
|
1268
|
+
const within = relative(realpathSync(dirname(skeletonPath)), target);
|
|
1269
|
+
if (within === '' || (within !== '..' && !within.startsWith(`..${sep}`) && !isAbsolute(within))) {
|
|
1270
|
+
throw new UsageError('--json must name a report outside the candidate directory; texture-scale never writes into a build');
|
|
1271
|
+
}
|
|
1272
|
+
if (existsSync(target)) {
|
|
1273
|
+
let isReport = false;
|
|
1274
|
+
if (statSync(target).isFile()) {
|
|
1275
|
+
try { isReport = (JSON.parse(readFileSync(target, 'utf8')) as { spec?: unknown }).spec === 'rigc-texture-scale/1'; } catch { /* An existing non-report is never replaced. */ }
|
|
1276
|
+
}
|
|
1277
|
+
if (!isReport) throw new UsageError('--json target exists and is not a rigc-texture-scale/1 report; name a new file outside the candidate directory');
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
const skeletonText = readSkeletonText(skeletonPath);
|
|
1281
|
+
const candidate = loadCandidate({ skeletonText, atlasText, atlasDir: dirname(atlasPath), label: skeletonPath },
|
|
1282
|
+
{ skeleton: skeletonPath, atlas: atlasPath }, posersAsked(flags));
|
|
1283
|
+
readPoserFlag(flags);
|
|
1284
|
+
const skin = readSkinFlag(flags, [...candidate.facts.skins]);
|
|
1285
|
+
refuseUnchosen(candidate.choice);
|
|
1286
|
+
const read = throughPoser(candidate.choice, poser => textureScaleReading(poser, candidate.pages, skeletonText, fps, skin));
|
|
1287
|
+
console.log(`poser: ${read.note}`);
|
|
1288
|
+
for (const line of textureScaleLines(read.value.report)) console.log(line);
|
|
1289
|
+
if (flags.json !== undefined) writeJson(flags.json, read.value.report);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1257
1292
|
/** Where `inspect` writes when `--out` is not given, and the name it takes inside a directory `--out` names. */
|
|
1258
1293
|
export const DEFAULT_INSPECT_OUT = 'inspect.png';
|
|
1259
1294
|
|
|
@@ -2679,6 +2714,7 @@ export const CORE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
|
|
|
2679
2714
|
check: ({ flags }) => cmdCheck(flags),
|
|
2680
2715
|
render: ({ flags }) => cmdRender(flags),
|
|
2681
2716
|
inspect: ({ flags }) => cmdInspect(flags),
|
|
2717
|
+
'texture-scale': ({ flags }) => cmdTextureScale(flags),
|
|
2682
2718
|
pose: ({ flags }) => cmdPose(flags),
|
|
2683
2719
|
chainfit: ({ flags }) => cmdChainFit(flags),
|
|
2684
2720
|
guide: ({ flags, positional }) => cmdGuide(flags, positional),
|
package/src/cli/shared.ts
CHANGED
|
@@ -1800,6 +1800,18 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1800
1800
|
},
|
|
1801
1801
|
},
|
|
1802
1802
|
},
|
|
1803
|
+
{
|
|
1804
|
+
name: 'texture-scale',
|
|
1805
|
+
runtime: false,
|
|
1806
|
+
spineFormat: false,
|
|
1807
|
+
usage: ['rigc texture-scale --candidate <dir | skeleton.json> [--skin <name>] [--fps 60] [--poser core|spine] [--json <out>]'],
|
|
1808
|
+
flags: ['candidate', 'atlas', 'skin', 'fps', 'poser', 'json'],
|
|
1809
|
+
overrides: {
|
|
1810
|
+
fps: { meaning: 'sample setup and every animation at this rate (default 60), also including float32 key times, key-interval midpoints and endpoints; reports sampled minima, never continuous extrema' },
|
|
1811
|
+
json: { meaning: 'write the report outside the candidate directory; an existing file is replaced only if it is a rigc-texture-scale/1 report' },
|
|
1812
|
+
},
|
|
1813
|
+
notes: ['REGION_TEXTURE_MINIFIED is a warning only: smallest singular value of texture-pixel to world map < 0.5. Names the region, texture size and sampled minimum with animation/time. Skips unselected, transparent and fully clipped regions; no occlusion or consumer camera test. Never changes a build. See docs/AUTHORING.md, Texture scale.'],
|
|
1814
|
+
},
|
|
1803
1815
|
{
|
|
1804
1816
|
name: 'inspect',
|
|
1805
1817
|
runtime: false,
|
package/src/render.ts
CHANGED
|
@@ -503,7 +503,7 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
503
503
|
slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
|
|
504
504
|
subset: (opts, skin) => slotSubsetOf(data, opts, skin),
|
|
505
505
|
setup: (skin) => spinePosed(setupPosed(data, skin)),
|
|
506
|
-
animation: (name, skin, fps, count, visit, environment) => {
|
|
506
|
+
animation: (name, skin, fps, count, visit, environment, _channels, times) => {
|
|
507
507
|
const skeleton = skeletonUnderSkin(data, skin);
|
|
508
508
|
const state = new AnimationState(new AnimationStateData(data));
|
|
509
509
|
// Not looping: the last frame sits at the animation's duration, and a looping
|
|
@@ -516,10 +516,11 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
516
516
|
const posed = spinePosed(skeleton);
|
|
517
517
|
for (let i = 0; i <= count; i++) {
|
|
518
518
|
if (i > 0) {
|
|
519
|
-
|
|
519
|
+
const delta = times === undefined ? step : times[i] - times[i - 1];
|
|
520
|
+
state.update(delta);
|
|
520
521
|
state.apply(skeleton);
|
|
521
522
|
setSkeletonEnvironment(skeleton, environment?.(i));
|
|
522
|
-
skeleton.update(clock);
|
|
523
|
+
skeleton.update(times === undefined ? clock : physicsClockStep(delta));
|
|
523
524
|
skeleton.updateWorldTransform(Physics.update);
|
|
524
525
|
} else {
|
|
525
526
|
state.apply(skeleton);
|
|
@@ -723,7 +724,7 @@ function drawPieces(skeleton: Skeleton, draw: DrawOptions): Piece[] {
|
|
|
723
724
|
continue;
|
|
724
725
|
}
|
|
725
726
|
const drawn = subset === undefined || named === undefined || named.has(slot.data.name) === (subset.mode === 'slots');
|
|
726
|
-
const piece = drawn ? pieceOf(skeleton, slot, draw.texture, clipper) : null;
|
|
727
|
+
const piece = drawn ? pieceOf(skeleton, slot, draw.texture, clipper, draw.regionInfo) : null;
|
|
727
728
|
if (piece !== null) pieces.push(piece);
|
|
728
729
|
clipper?.clipEnd(slot);
|
|
729
730
|
}
|
|
@@ -743,6 +744,7 @@ function pieceOf(
|
|
|
743
744
|
slot: Slot,
|
|
744
745
|
withTexture: boolean,
|
|
745
746
|
clipper: SkeletonClipping | null,
|
|
747
|
+
withRegionInfo = false,
|
|
746
748
|
): Piece | null {
|
|
747
749
|
const pose = slot.appliedPose;
|
|
748
750
|
const attachment = pose.attachment;
|
|
@@ -765,7 +767,9 @@ function pieceOf(
|
|
|
765
767
|
const darkPose = pose.darkColor;
|
|
766
768
|
const dark: [number, number, number] | undefined =
|
|
767
769
|
darkPose === null ? undefined : [darkPose.r, darkPose.g, darkPose.b];
|
|
768
|
-
const common = { tint, dark, slot: slot.data.name, page: region.page.name
|
|
770
|
+
const common = { tint, dark, slot: slot.data.name, page: region.page.name,
|
|
771
|
+
...(withRegionInfo && !isMesh ? { regionInfo: { attachment: attachment.name, region: region.name, index: region.index, width: region.width, height: region.height } } : {}),
|
|
772
|
+
};
|
|
769
773
|
const texture = withTexture ? artUvsOf(attachment, region) : undefined;
|
|
770
774
|
const uvs = attachment.sequence.getUVs(index);
|
|
771
775
|
|
package/src/render_core.ts
CHANGED
|
@@ -557,7 +557,9 @@ function pieces(
|
|
|
557
557
|
const atlasRegion = draw.texture ? input.region(found.region) : null;
|
|
558
558
|
if (draw.texture && atlasRegion === null) throw new CoreInputError(`slot "${d.slot}": atlas region "${found.region}" is not in the atlas`);
|
|
559
559
|
const texture = atlasRegion === null ? undefined : artUvsOf(d, atlasRegion);
|
|
560
|
-
const common = { tint: tintOf(d), dark: darkOf(d), slot: d.slot, page: found.found.page.name
|
|
560
|
+
const common = { tint: tintOf(d), dark: darkOf(d), slot: d.slot, page: found.found.page.name,
|
|
561
|
+
...(draw.regionInfo && d.kind === 'region' ? { regionInfo: { attachment: d.attachment, region: found.region, index: input.region(found.region)?.index ?? -1, width: found.found.region.width, height: found.found.region.height } } : {}),
|
|
562
|
+
};
|
|
561
563
|
const world = [...d.vertices];
|
|
562
564
|
const piece: Piece =
|
|
563
565
|
d.kind === 'mesh'
|
|
@@ -782,7 +784,7 @@ export function corePoser(
|
|
|
782
784
|
const pose = poseRawSetup(input.doc, { through, ...observe });
|
|
783
785
|
return corePosed(input, pose, through, [], values);
|
|
784
786
|
},
|
|
785
|
-
animation: (name, skin, fps, count, visit, environment, channels) => {
|
|
787
|
+
animation: (name, skin, fps, count, visit, environment, channels, times) => {
|
|
786
788
|
let values: ChannelValue[] | undefined;
|
|
787
789
|
const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
|
|
788
790
|
const input = inputOf(skin);
|
|
@@ -802,7 +804,7 @@ export function corePoser(
|
|
|
802
804
|
if (ctx.time > 0 && ctx.states.get(r.name)?.lastTime === ctx.time) steppedNow.add(r.name);
|
|
803
805
|
return moved;
|
|
804
806
|
};
|
|
805
|
-
walk(input.doc, name, new Array<number>(count).fill(step), { through, physicsStep, ...observe }, (pose, i) => {
|
|
807
|
+
walk(input.doc, name, times === undefined ? new Array<number>(count).fill(step) : times.slice(1).map((t, i) => t - times[i]), { through, physicsStep, ...observe }, (pose, i) => {
|
|
806
808
|
const stepped = [...steppedNow].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
807
809
|
steppedNow.clear();
|
|
808
810
|
if (drawFailed.length > 0) return;
|
package/src/render_shared.ts
CHANGED
|
@@ -324,6 +324,8 @@ export interface PinnedViewport {
|
|
|
324
324
|
|
|
325
325
|
/** What every drawable has in common, whatever shape it is. */
|
|
326
326
|
export interface PieceCommon {
|
|
327
|
+
/** Opt-in atlas identity for texture-scale; absent on ordinary renders. */
|
|
328
|
+
regionInfo?: { attachment: string; region: string; index: number; width: number; height: number };
|
|
327
329
|
/**
|
|
328
330
|
* World-space vertex positions, `x, y` per vertex.
|
|
329
331
|
*
|
|
@@ -1101,6 +1103,8 @@ function coreFacts(skeletonText: string, atlasText: string | null, poser: Poser,
|
|
|
1101
1103
|
|
|
1102
1104
|
/** What a sampler hands a posed frame's draw walk — `PoseOptions` with the subset already resolved. */
|
|
1103
1105
|
export interface DrawOptions {
|
|
1106
|
+
/** Texture-scale only: atlas identity and stored texture size on whole regions. */
|
|
1107
|
+
regionInfo?: boolean;
|
|
1104
1108
|
/** The slots drawn, resolved against the posed skeleton (`slotSubsetOf`); `undefined` draws every slot. */
|
|
1105
1109
|
subset: SlotSubset | undefined;
|
|
1106
1110
|
/** Every attachment whole, no clip applied — the framing box's reading. */
|
|
@@ -1159,8 +1163,11 @@ export interface Poser {
|
|
|
1159
1163
|
* `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
|
|
1160
1164
|
* `environment(i)`, when given, is the change to the world the physics
|
|
1161
1165
|
* answers set before the step that poses frame `i` (issue #1416).
|
|
1166
|
+
* Optional `times` replaces the uniform grid with increasing absolute times
|
|
1167
|
+
* starting at 0 (count = times.length - 1), stepped by adjacent differences.
|
|
1168
|
+
* Used by texture-scale to include key times; ordinary renders are unchanged.
|
|
1162
1169
|
*/
|
|
1163
|
-
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean): void;
|
|
1170
|
+
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean, times?: readonly number[]): void;
|
|
1164
1171
|
/** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
|
|
1165
1172
|
rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
|
|
1166
1173
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Warning-only region minification, in world units per stored texture pixel. */
|
|
2
|
+
import type { Piece, Poser, Posed } from './render_shared.ts';
|
|
3
|
+
import type { Plate } from '../tools/plate.ts';
|
|
4
|
+
import { walkTimelines } from './timelines.ts';
|
|
5
|
+
import { hypot2 } from './numeric.ts';
|
|
6
|
+
|
|
7
|
+
export const TEXTURE_SCALE_WARNING = 'REGION_TEXTURE_MINIFIED';
|
|
8
|
+
export const TEXTURE_SCALE_THRESHOLD = 0.5;
|
|
9
|
+
export const TEXTURE_SCALE_FPS = 60;
|
|
10
|
+
export const TEXTURE_SCALE_LIMIT =
|
|
11
|
+
'Minimum among sampled poses only: grid + float32 key times + key-interval midpoints + endpoint; ' +
|
|
12
|
+
'interpolated scale/shear/constraints can have smaller extrema between samples. ' +
|
|
13
|
+
'World units per texture pixel, before consumer camera/viewport; no occlusion test.';
|
|
14
|
+
|
|
15
|
+
export interface RegionScale {
|
|
16
|
+
slot: string;
|
|
17
|
+
attachment: string;
|
|
18
|
+
region: string;
|
|
19
|
+
index: number;
|
|
20
|
+
page: string;
|
|
21
|
+
texture: { width: number; height: number };
|
|
22
|
+
smallestSampledScale: number;
|
|
23
|
+
animation: string | null;
|
|
24
|
+
time: number;
|
|
25
|
+
}
|
|
26
|
+
export interface TextureScaleReport {
|
|
27
|
+
spec: 'rigc-texture-scale/1';
|
|
28
|
+
threshold: number;
|
|
29
|
+
fps: number;
|
|
30
|
+
skin: string | null;
|
|
31
|
+
sampling: string;
|
|
32
|
+
samples: Array<{ animation: string | null; times: number[] }>;
|
|
33
|
+
drawnRegions: number;
|
|
34
|
+
warnings: Array<RegionScale & { name: typeof TEXTURE_SCALE_WARNING }>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Stable 2x2 sigma-min: |det| / sigma-max avoids subtracting nearly equal eigenvalues. */
|
|
38
|
+
export function smallestSingularValue(a: number, b: number, c: number, d: number): number {
|
|
39
|
+
const scale = Math.max(Math.abs(a), Math.abs(b), Math.abs(c), Math.abs(d));
|
|
40
|
+
if (scale === 0) return 0;
|
|
41
|
+
a /= scale; b /= scale; c /= scale; d /= scale;
|
|
42
|
+
const sum = a * a + b * b + c * c + d * d;
|
|
43
|
+
const disc = hypot2(a * a + c * c - b * b - d * d, 2 * (a * b + c * d));
|
|
44
|
+
return scale * Math.abs(a * d - b * c) / Math.sqrt((sum + disc) / 2);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Texel-space affine map from three corresponding quad corners; includes trim, rotation, reflection and shear. */
|
|
48
|
+
export function regionDrawScale(piece: Piece, page: Pick<Plate, 'width' | 'height'>): number {
|
|
49
|
+
const [x0, y0, x1, y1, x2, y2] = piece.world;
|
|
50
|
+
const u0 = piece.uvs[0] * page.width, v0 = piece.uvs[1] * page.height;
|
|
51
|
+
const u1 = piece.uvs[2] * page.width - u0, v1 = piece.uvs[3] * page.height - v0;
|
|
52
|
+
const u2 = piece.uvs[4] * page.width - u0, v2 = piece.uvs[5] * page.height - v0;
|
|
53
|
+
const det = u1 * v2 - u2 * v1;
|
|
54
|
+
if (det === 0) throw new Error(`slot ${JSON.stringify(piece.slot)}: region has a singular texture rectangle`);
|
|
55
|
+
return smallestSingularValue(
|
|
56
|
+
((x1 - x0) * v2 - (x2 - x0) * v1) / det,
|
|
57
|
+
((x2 - x0) * u1 - (x1 - x0) * u2) / det,
|
|
58
|
+
((y1 - y0) * v2 - (y2 - y0) * v1) / det,
|
|
59
|
+
((y2 - y0) * u1 - (y1 - y0) * u2) / det,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A schedule, not an extrema solver. Key times use the same float32 storage as the poser. */
|
|
64
|
+
export function textureScaleTimes(skeletonText: string, animation: string, duration: number, fps: number): number[] {
|
|
65
|
+
if (!Number.isFinite(fps) || fps < 1) throw new Error(`texture-scale fps ${fps}: expected a finite number >= 1`);
|
|
66
|
+
const root = JSON.parse(skeletonText) as { animations?: Record<string, unknown> };
|
|
67
|
+
const keys = new Set<number>([0, duration]);
|
|
68
|
+
walkTimelines({ animations: { [animation]: root.animations?.[animation] } }, (_p, _k, _n, rows) => {
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
const t = Math.fround((row as { time?: number }).time ?? 0);
|
|
71
|
+
if (Number.isFinite(t) && t >= 0 && t <= duration) keys.add(t);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
const sorted = [...keys].sort((a, b) => a - b);
|
|
75
|
+
const times = new Set(sorted);
|
|
76
|
+
for (let i = 1; i < sorted.length; i++) times.add((sorted[i - 1] + sorted[i]) / 2);
|
|
77
|
+
for (let i = 0; i / fps < duration; i++) times.add(i / fps);
|
|
78
|
+
return [...times].sort((a, b) => a - b);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Has a submitted triangle of positive area; fully clipped or collapsed geometry draws nothing. */
|
|
82
|
+
function hasArea(p: Piece): boolean {
|
|
83
|
+
const ts = p.kind === 'region' ? [0, 1, 2, 2, 3, 0] : p.triangles;
|
|
84
|
+
for (let i = 0; i < ts.length; i += 3) {
|
|
85
|
+
const a = ts[i] * 2, b = ts[i + 1] * 2, c = ts[i + 2] * 2;
|
|
86
|
+
if (Math.abs((p.world[b] - p.world[a]) * (p.world[c + 1] - p.world[a + 1]) -
|
|
87
|
+
(p.world[b + 1] - p.world[a + 1]) * (p.world[c] - p.world[a])) > 0) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Excludes wholly transparent source rectangles; no per-frame raster or screen-space occlusion claim. */
|
|
93
|
+
function hasAlpha(piece: Piece, page: Plate): boolean {
|
|
94
|
+
const us = [0, 2, 4, 6].map(i => piece.uvs[i] * page.width);
|
|
95
|
+
const vs = [1, 3, 5, 7].map(i => piece.uvs[i] * page.height);
|
|
96
|
+
const x0 = Math.max(0, Math.round(Math.min(...us))), x1 = Math.min(page.width, Math.round(Math.max(...us)));
|
|
97
|
+
const y0 = Math.max(0, Math.round(Math.min(...vs))), y1 = Math.min(page.height, Math.round(Math.max(...vs)));
|
|
98
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) if (page.data[(y * page.width + x) * 4 + 3] > 0) return true;
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Streaming read: setup first, then animations in declaration order; ties keep the first observation. Never emits a build. */
|
|
103
|
+
export function textureScaleReading(poser: Poser, pages: Map<string, Plate>, skeletonText: string, fps = TEXTURE_SCALE_FPS, skin?: string): { report: TextureScaleReport; regions: RegionScale[] } {
|
|
104
|
+
if (!Number.isFinite(fps) || fps < 1) throw new Error(`texture-scale fps ${fps}: expected a finite number >= 1`);
|
|
105
|
+
const readings = new Map<string, RegionScale>();
|
|
106
|
+
const slotBones = new Map(poser.slots.map(s => [s.name, s.bone]));
|
|
107
|
+
const alpha = new Map<string, boolean>();
|
|
108
|
+
const samples: TextureScaleReport['samples'] = [{ animation: null, times: [0] }];
|
|
109
|
+
const visit = (pose: Posed, animation: string | null, time: number): void => {
|
|
110
|
+
const unposed = new Set(pose.bones().filter(b => b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0).map(b => b.name));
|
|
111
|
+
const drawn = new Map(pose.pieces({ subset: undefined, unclipped: false, texture: false }).map(p => [p.slot, p]));
|
|
112
|
+
for (const whole of pose.pieces({ subset: undefined, unclipped: true, texture: false, regionInfo: true })) {
|
|
113
|
+
if (unposed.has(slotBones.get(whole.slot) ?? '')) continue;
|
|
114
|
+
if (whole.kind !== 'region' || whole.regionInfo === undefined || !(whole.tint[3] > 0)) continue;
|
|
115
|
+
const visible = drawn.get(whole.slot);
|
|
116
|
+
if (visible === undefined || !hasArea(visible)) continue;
|
|
117
|
+
const info = whole.regionInfo;
|
|
118
|
+
const page = pages.get(whole.page);
|
|
119
|
+
if (page === undefined) throw new Error(`region ${JSON.stringify(info.region)}: page ${JSON.stringify(whole.page)} is absent`);
|
|
120
|
+
const key = JSON.stringify([whole.slot, info.attachment, whole.page, info.region, info.index]);
|
|
121
|
+
const sourceAlpha = alpha.get(key) ?? hasAlpha(whole, page);
|
|
122
|
+
alpha.set(key, sourceAlpha);
|
|
123
|
+
if (!sourceAlpha) continue;
|
|
124
|
+
const scale = regionDrawScale(whole, page);
|
|
125
|
+
if (!Number.isFinite(scale)) throw new Error(`region ${JSON.stringify(info.region)} at ${animation ?? 'setup'} t=${time}: draw scale ${scale} is not finite`);
|
|
126
|
+
const old = readings.get(key);
|
|
127
|
+
if (old === undefined || scale < old.smallestSampledScale) readings.set(key, {
|
|
128
|
+
slot: whole.slot, attachment: info.attachment, region: info.region, index: info.index, page: whole.page,
|
|
129
|
+
texture: { width: info.width, height: info.height }, smallestSampledScale: scale, animation, time,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
visit(poser.setup(skin), null, 0);
|
|
134
|
+
for (const a of poser.animations) {
|
|
135
|
+
const times = textureScaleTimes(skeletonText, a.name, a.duration, fps);
|
|
136
|
+
samples.push({ animation: a.name, times });
|
|
137
|
+
poser.animation(a.name, skin, fps, times.length - 1, (i, posed) => visit(posed, a.name, times[i]), undefined, false, times);
|
|
138
|
+
}
|
|
139
|
+
const regions = [...readings.values()];
|
|
140
|
+
return { regions, report: {
|
|
141
|
+
spec: 'rigc-texture-scale/1', threshold: TEXTURE_SCALE_THRESHOLD, fps, skin: skin ?? null, sampling: TEXTURE_SCALE_LIMIT,
|
|
142
|
+
samples, drawnRegions: regions.length,
|
|
143
|
+
warnings: regions.filter(r => r.smallestSampledScale < TEXTURE_SCALE_THRESHOLD).map(r => ({ name: TEXTURE_SCALE_WARNING, ...r })),
|
|
144
|
+
} };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function textureScaleLines(report: TextureScaleReport): string[] {
|
|
148
|
+
return [
|
|
149
|
+
`texture-scale: ${report.warnings.length} warning(s) / ${report.drawnRegions} drawn region attachment(s), threshold < ${report.threshold}, ${report.fps} fps + keys/midpoints/endpoints`,
|
|
150
|
+
report.sampling,
|
|
151
|
+
...report.warnings.map(r => `WARN ${r.name}: region ${JSON.stringify(r.region)} slot ${JSON.stringify(r.slot)} attachment ${JSON.stringify(r.attachment)} texture ${r.texture.width}x${r.texture.height}, smallest sampled draw scale ${r.smallestSampledScale.toFixed(6)} at ${r.animation === null ? 'setup' : JSON.stringify(r.animation)} t=${r.time}s`),
|
|
152
|
+
];
|
|
153
|
+
}
|