@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 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.84.0",
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": {
@@ -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
- state.update(step);
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
 
@@ -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;
@@ -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
+ }