@rig-c/core 2.83.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
@@ -9186,7 +9186,7 @@ inside it, and a second copy of the leg on the same bone):
9186
9186
  bun cli.ts render --candidate path/to/spine --animation swing --seam skirt:leg,leg_front:leg [--skin …] [--fps 12]
9187
9187
  # rigc render
9188
9188
  # One seam line per pair, including no gap/no ghost or SKIP, followed by
9189
- # exposure "<slot>" for each named member: frames, worst cell count and box.
9189
+ # exposure "<slot>" and joint contact: joined/separated/unavailable frames.
9190
9190
  ```
9191
9191
 
9192
9192
  The skirt swinging off the leg opens background between the leg's right edge and
@@ -9241,6 +9241,33 @@ table's vertices, and the atlas alpha:
9241
9241
  The rectangle controls derive eight real tear cells; a notched branch derives
9242
9242
  sixteen adjacent background cells excluded by this rule, including when both
9243
9243
  kinds occur in the same frame.
9244
+ - **jointContact** — a separate observation of **pair material connectivity**,
9245
+ on that same half-opacity texel grid. It requires a setup band and exactly
9246
+ one nonempty edge-connected material component for each named drawing, at
9247
+ both setup and frame. `joined` means the two posed components overlap or
9248
+ share a cell edge; `separated` means their union is disconnected. Sharing
9249
+ only a corner is not edge contact. Every sampled frame is present, including
9250
+ `unavailable` with a reason when a member is absent, the setup band is empty,
9251
+ material is empty or has multiple islands, or a named slot draws multiple
9252
+ fragments. No samples yields an empty list, not a successful observation.
9253
+
9254
+ **Raw gap measures swept support, not certified tearing.** An attached limb
9255
+ can rotate well away from its parent and leave an external crescent of
9256
+ background inside that support: `gap` can be positive while `jointContact`
9257
+ is `joined`. Removing enough proximal material can leave initial overlap but
9258
+ disconnect the same two drawings under the same attachment maps; that is
9259
+ operational material separation, reported as `separated`. This uses no pivot
9260
+ radius, anatomical assumption or new opacity threshold. The synthetic
9261
+ rectangular controls use an independent endpoint set oracle and actual gated
9262
+ builds rendered through both posers.
9263
+
9264
+ Contact does **not** infer anatomical joint intent. A joined pair can have a
9265
+ local notch or hole, including a genuine local tear while other material
9266
+ remains connected; contact cannot classify individual gap cells as harmless
9267
+ background or tearing. A separated pair need not be artistically wrong and
9268
+ can have zero raw gap outside the existing swept support. The grid cannot
9269
+ resolve sub-cell contact. Other skeleton drawings and draw order do not
9270
+ enter this pair-material observation. `unavailable` is never zero or PASS.
9244
9271
  - **ghost** — a cell one piece draws at the frame and the other does not, which
9245
9272
  the drawing piece, carrying it back, lands in the band, and which the other,
9246
9273
  carrying it back, finds neither drawing at the setup pose: the overlap swung
@@ -9336,6 +9363,7 @@ block carries the definitions above in words.
9336
9363
  | `pairs[].ghostA`, `pairs[].ghostB` | object | — | the same `{ frames, spans, worst }` shape for ghost cells drawn by `a` only and `b` only; an empty owner has `frames: []`, `spans: []`, `worst: null` |
9337
9364
  | `pairs[].gapOpen`, `pairs[].gapCovered` | object | — | the same `{ frames, spans, worst }` shape for gap cells with zero or positive rendered skeleton-composite alpha, respectively; an empty kind has `frames: []`, `spans: []`, `worst: null` |
9338
9365
  | `pairs[].exposureA`, `pairs[].exposureB` | object | — | `{ frames, spans, worst }` for the whole named slot’s positive-alpha cells on otherwise zero-alpha skeleton background; measured independently of the band, including SKIP pairs; not part of `opens` or `maxPx` |
9366
+ | `pairs[].jointContact.frames[]` | object | seconds for `time` | `{ index, time, status, reason }` for every sampled frame; `status` is `joined`, `separated` or `unavailable`; `reason` is `null` for measured contact and a named explanation for unavailable contact. Independent of raw gap/ghost, `opens` and `maxPx` |
9339
9367
  | `<kind>.frames[]` | hit[] | — | only the frames with a cell of that kind, in frame order |
9340
9368
  | `<kind>.spans[]` | array | — | `<kind>.frames` as runs of consecutive indices: `{ frames: [first, last], times: [first, last], maxPx }` — frame indices, seconds, px |
9341
9369
  | `<kind>.worst` | hit or `null` | — | the frame with the most cells, the earliest on a tie; `null` when none |
@@ -9362,6 +9390,18 @@ Clean stationary/shared-motion pair fields remain unchanged apart from exposure;
9362
9390
  the `reading` legend states the revised definitions. Historical measurements
9363
9391
  above pin their old reader and must not be treated as current measurements.
9364
9392
 
9393
+ The subsequent `jointContact` addition preserves **all** existing pair fields,
9394
+ raw gap/coverage/ghost/exposure numbers, `opens`, `maxPx` and their definitions.
9395
+ It appends one pair field, a legend entry and a contact clause to the printed
9396
+ line; `rigc-seam/1` is unchanged. Readers accepting additive keys can keep their
9397
+ existing policy. In particular, a consumer using raw `gapOpen` plus an owned
9398
+ ghost as its policy still uses those same numbers; adding contact does not
9399
+ change that verdict. Adopting material contact as a separate consumer policy
9400
+ requires an explicit choice. Readers comparing entire JSON objects or exact
9401
+ console lines must account for the added fields and clause. `stays closed` and
9402
+ `opens` in the existing clause continue to describe raw gap/ghost; use the
9403
+ separate contact clause for operational material separation.
9404
+
9365
9405
  **Refused, nothing written:** a slot the skeleton does not have (with the slots it
9366
9406
  does), an entry without exactly one `:`, an empty entry or name, a slot paired with
9367
9407
  itself (a seam is between two drawings), a pair named twice in either order (the
@@ -12577,7 +12617,7 @@ pixel (`IN09`).
12577
12617
  | What does this bone move? | `--show art,mesh,weights:<bone> --dim` | the bone's weight over every region and mesh it moves, as a heat: a pixel whose centre lies in a triangle is painted the ramp's colour at the blend of its corners' weights — blue near 0, yellow at 0.5, red at 1 (the stops are in the legend) — and a weight of 0 is not painted, so the art shows where the bone does not reach. A region and an unweighted mesh are moved whole by their slot's bone. The weights are read off `skeleton.json`, the file both posers skin from; a bone that weights nothing paints nothing and the run says so on a line of its own |
12578
12618
  | Does this part's edge follow that bone? | `--show art,weights:<bone>,mesh --overlay-slot <part> --zoom slot:<part>` | the part's own triangles and the bone's weight over them alone, over the whole painting. Where slots stack — a neck under the face, the hair, a collar — every mesh crossing the box is drawn and the heat is the union of every slot the bone moves, so the part's edge has no pixel of its own until the others are left out. The legend's `meshes[]` gives the part's weight for the bone at every vertex, so "does the top row follow the head" is read as numbers as well as colour |
12579
12619
  | Is this rig's density where its motion is? | `--show art,density --dim` | each region and mesh filled by its **ratio** — its triangles per world unit² over the figure's — on one ramp, log-scaled and centred on 1×: grey is as dense as the figure, pink to magenta denser (4×, 16×), light to dark green sparser (1/4×, 1/16×), clamped past the end stops (the stops are in the legend). A part's area is its posed mesh's own, the sum of its triangles' areas at the pose drawn, not the image's box; a region is two triangles over its quad. Where parts overlap the pixel is the part drawn last, as in the art, so a part under others is read with `--overlay-slot <part>`. The figure's totals are always over every slot the pose shows — the filter picks what is drawn and listed, never what the figure is — and the legend's `density` gives each slot's attachment, kind, vertices, triangles, hull, area, triangles per area, ratio and the bones it is weighted to, in draw order. A measurement, not a verdict: no bar is drawn, and `A13_MESH_BUDGET` stays the one gate on counts |
12580
- | Does a joint tear in this animation? | `render --seam <a>:<b> --animation <name>` (§8.8) | One line per pair: the frames where background opens inside the joint (gap) or the overlap band of one piece shows past its partner (ghost), with the worst frame's cell count and world box; `seam.json` carries every frame and its time, and the runs of frames. A pair whose drawings never overlap is a SKIP, said by name. A reading, not a verdict — the exit status never moves |
12620
+ | Does a pair lose material contact in this animation? | `render --seam <a>:<b> --animation <name>` (§8.8) | Raw swept-support gap and visible ghost counts, plus separate pair material contact: joined, separated or explicitly unavailable. Gap alone is not certified tearing; contact does not infer anatomical intent or rule out local notches. `seam.json` records the frames and times. A reading, not a verdict — the exit status never moves |
12581
12621
  | Which slot covers which in this animation? | `render --overlaps --animation <name>` (§8.9) | One `OVERLAP "<under>" under "<over>"` line per pair that moves: the frames it covers on, as runs, and the most pixels it covers and the frame of them; one line counting the layering the motion does not move. `overlaps.json` carries every frame's pairs with their pixel count and box, and `overlaps-f<NNNN>.png` draws each covered frame's contours, the covered part dashed. A reading, not a verdict — the draw-order key is the remedy |
12582
12622
  | Does anything fold in this animation? | `render --folds --animation <name>` (§8.3), then `inspect --show art,folds,mesh --animation <name> --at <the worst frame's time> --zoom folds` | `render --folds` prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count, which triangles, their world box at that frame and their setup-pose area. The view fills those triangles at the pose drawn — every pixel whose centre a folded triangle holds — in the colour the legend's `folds` states, and the legend's `folds[]` names, per mesh, the folded triangles by index with the same box and area, and the setup pose's degenerate and inverted ones, which are never counted. A fold is usually a sliver a few units across, invisible in a picture of the whole mesh: `--zoom folds` frames the picture to the folded triangles' box. A thin sliver still needs pixels — raise `--max` when the fill is a line. A reading, not a verdict |
12583
12623
  | How is this part triangulated? | `--show art,mesh,hull --zoom slot:<slot>` | each mesh's triangle edges, and over them its hull — vertex 0 to 1 to … to `hull − 1` and back. Regions are not drawn: a region is not a mesh. The legend lists every vertex in world units and in pixels |
@@ -12752,6 +12792,57 @@ for `declared` and the run says so on a line of its own.
12752
12792
  - **Not a second poser.** Everything it draws is a pose `render` would have drawn at
12753
12793
  the same frame; where it disagrees with `render --geometry`, it is wrong (`IN04`).
12754
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
+
12755
12846
  ## 14. Read a named mesh at setup
12756
12847
 
12757
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.83.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
@@ -1073,7 +1073,7 @@ const FLAG_MEANINGS: Record<string, string> = {
1073
1073
  'per pair, the frames with a gap — background inside the carried overlap hull and a local sweep of the same setup band cell through both drawings — ' +
1074
1074
  'and the frames with a visible ghost after draw order — the band of one drawing showing where, from its partner\'s point of view, nothing was drawn at the setup pose — each with ' +
1075
1075
  `its cell count, area and world box. Cells are one texel of the coarser drawing, drawn at ${DRAWN_ALPHA} of 255 or more. One line per pair; a pair whose ` +
1076
- 'drawings share no cell at the setup pose is a SKIP. exposureA/B count each named slot’s positive-alpha cells on otherwise empty skeleton background, even without a band or partner. A reading, not a gate: the exit status never moves. An unknown slot, a slot with itself, ' +
1076
+ 'drawings share no cell at the setup pose is a SKIP. Raw gap measures swept support, not certified tearing. jointContact separately reports pair material connectivity: joined, separated, or unavailable with a reason; it does not infer anatomical joint intent or rule out local notches. exposureA/B count each named slot’s positive-alpha cells on otherwise empty skeleton background, even without a band or partner. A reading, not a gate: the exit status never moves. An unknown slot, a slot with itself, ' +
1077
1077
  'a slot that draws on no frame, a malformed list and a pair named twice in either order are refused by name. Not with --slot/--hide',
1078
1078
  'angle-range':
1079
1079
  'also read, for each <bone>:<relative-to> pair, the range of the first bone\'s world direction against the second\'s over each set\'s ' +
@@ -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
  }
package/src/seam.ts CHANGED
@@ -63,6 +63,13 @@
63
63
  * Shared transforms read closed. The outer-hull reach remains bounded: tears
64
64
  * outside the transported setup overlap are not certified absent by a zero.
65
65
  *
66
+ * `jointContact` separately observes material connectivity on that same grid.
67
+ * Initially overlapping, single-component drawings are joined if their posed
68
+ * union is edge-connected, separated otherwise. Missing bands or non-single
69
+ * components are unavailable. Joined does not rule out local holes or notches;
70
+ * separated does not infer artistic intent. Raw gap can include an external
71
+ * rotational crescent of a joined pair and is deliberately unchanged.
72
+ *
66
73
  * Pure: no clock, no randomness, no runtime. The pieces, the attachments and the
67
74
  * rest table are handed in by whichever poser drew the frames.
68
75
  */
@@ -159,6 +166,15 @@ export interface SeamKindReading {
159
166
  worst: SeamHit | null;
160
167
  }
161
168
 
169
+ /** Connectivity of two initially overlapping single material components, at grid resolution. */
170
+ export interface SeamContactFrame {
171
+ index: number;
172
+ time: number;
173
+ status: 'joined' | 'separated' | 'unavailable';
174
+ /** Non-null exactly when the observation is unavailable; never a measured zero. */
175
+ reason: string | null;
176
+ }
177
+
162
178
  /** One pair's reading over one frame set. */
163
179
  export interface SeamPairReading {
164
180
  a: string;
@@ -186,6 +202,8 @@ export interface SeamPairReading {
186
202
  /** Positive-alpha cells contributed by the named slot where all OTHER slots have zero alpha. Independent of the band. */
187
203
  exposureA: SeamKindReading;
188
204
  exposureB: SeamKindReading;
205
+ /** Every sampled frame, including unavailable frames. Independent of raw gap/ghost and their summaries. */
206
+ jointContact: { frames: SeamContactFrame[] };
189
207
  }
190
208
 
191
209
  /** `seam.json`: one frame set's reading, every pair the flag names, in the flag's order. */
@@ -196,7 +214,7 @@ export interface SeamFile {
196
214
  skin?: string;
197
215
  fps: number;
198
216
  /** What the numbers mean, stated in the file so a reader does not need this module. */
199
- reading: { read: string; cell: string; drawn: string; band: string; carried: string; gap: string; ghost: string; box: string; status: string; ghostA: string; ghostB: string; gapOpen: string; gapCovered: string; exposureA: string; exposureB: string };
217
+ reading: { read: string; cell: string; drawn: string; band: string; carried: string; gap: string; ghost: string; box: string; status: string; ghostA: string; ghostB: string; gapOpen: string; gapCovered: string; exposureA: string; exposureB: string; jointContact: string };
200
218
  pairs: SeamPairReading[];
201
219
  }
202
220
 
@@ -219,6 +237,8 @@ export interface SeamPlant {
219
237
  hiddenGhost?: boolean;
220
238
  /** Bridge unrelated overlap cells with the old global hull. */
221
239
  globalHull?: boolean;
240
+ /** Force a measured contact to one answer, ignoring the rendered material. */
241
+ contact?: 'joined' | 'separated';
222
242
  /** Wrong composite readings: half opacity, only the pair, or every gap forced to one kind. */
223
243
  gapCoverage?: 'half' | 'pair' | 'open' | 'covered';
224
244
  /** Report a pair with no band as read, rather than as a SKIP. */
@@ -473,8 +493,44 @@ function markHull(hull: ReadonlyArray<[number, number]>, grid: Grid, cells: Uint
473
493
  }
474
494
  }
475
495
 
496
+ /** Count edge-connected components, stopping after the second: only exactly one is supported. */
497
+ function components(cells: Uint8Array, width: number): number {
498
+ const seen = new Uint8Array(cells.length);
499
+ const queue = new Int32Array(cells.length);
500
+ let count = 0;
501
+ for (let k = 0; k < cells.length; k++) {
502
+ if (!cells[k] || seen[k]) continue;
503
+ if (++count === 2) return count;
504
+ let head = 0, tail = 1;
505
+ queue[0] = k; seen[k] = 1;
506
+ while (head < tail) {
507
+ const i = queue[head++];
508
+ const visit = (j: number): void => {
509
+ if (j >= 0 && j < cells.length && cells[j] && !seen[j]) { seen[j] = 1; queue[tail++] = j; }
510
+ };
511
+ if (i % width > 0) visit(i - 1);
512
+ if (i % width < width - 1) visit(i + 1);
513
+ visit(i - width); visit(i + width);
514
+ }
515
+ }
516
+ return count;
517
+ }
518
+
519
+ type Contact = Pick<SeamContactFrame, 'status' | 'reason'>;
520
+ const unavailableContact = (reason: string): Contact => ({ status: 'unavailable', reason });
521
+
522
+ /** Four neighbours mean a shared cell edge; diagonals alone do not connect material. No distance parameter. */
523
+ function contactOf(restA: Uint8Array, restB: Uint8Array, drawnA: Uint8Array, drawnB: Uint8Array, width: number, plant: SeamPlant): Contact {
524
+ for (const [name, cells] of [['a setup', restA], ['b setup', restB], ['a frame', drawnA], ['b frame', drawnB]] as const) {
525
+ const n = components(cells, width);
526
+ if (n !== 1) return unavailableContact(`${name}: ${n === 0 ? 'no' : 'multiple'} edge-connected half-opacity material components; exactly one is required`);
527
+ }
528
+ const union = drawnA.map((v, k) => v || drawnB[k]);
529
+ return { status: plant.contact ?? (components(union, width) === 1 ? 'joined' : 'separated'), reason: null };
530
+ }
531
+
476
532
  /** One frame's counts, or `null` when the two drawings share no cell at the setup pose. See the module comment. */
477
- function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: SeamFrame, pages: ReadonlyMap<string, Plate>, grid: Grid, visibility: readonly Uint8Array[]): { gap: Cells; ghost: Cells; ghostA: Cells; ghostB: Cells; gapOpen: Cells; gapCovered: Cells } | null {
533
+ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: SeamFrame, pages: ReadonlyMap<string, Plate>, grid: Grid, visibility: readonly Uint8Array[]): { gap: Cells; ghost: Cells; ghostA: Cells; ghostB: Cells; gapOpen: Cells; gapCovered: Cells; contact: Contact } | null {
478
534
  const { width, height } = grid.viewport;
479
535
  const restA = restPiece(A);
480
536
  const restB = restPiece(B);
@@ -520,6 +576,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
520
576
  const hull = hullOf(carried);
521
577
  const drawnA = drawnCells(A.piece, A.page, grid.viewport);
522
578
  const drawnB = drawnCells(B.piece, B.page, grid.viewport);
579
+ const contact = contactOf(bandA, bandB, drawnA, drawnB, width, plant);
523
580
  const [visibleA, visibleB] = visibility;
524
581
  const posedIdsA = triangleIds(A.triangles, A.piece.world, grid);
525
582
  const posedIdsB = triangleIds(B.triangles, B.piece.world, grid);
@@ -536,7 +593,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
536
593
  const gapCovered: Cells = { px: 0, box: null };
537
594
  let alphaCells: Uint8Array | null = null;
538
595
  const take = (into: Cells, qx: number, qy: number): void => takeCell(into, qx, qy, cell);
539
- if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
596
+ if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered, contact };
540
597
  const inBand = (p: [number, number] | null): boolean => p !== null && p[0] >= bx0 - cell / 2 && p[0] <= bx1 + cell / 2 && p[1] >= by0 - cell / 2 && p[1] <= by1 + cell / 2;
541
598
  for (let py = 0; py < height; py++) {
542
599
  for (let px = 0; px < width; px++) {
@@ -570,7 +627,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
570
627
  }
571
628
  }
572
629
  }
573
- return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
630
+ return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered, contact };
574
631
  }
575
632
 
576
633
  const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
@@ -634,9 +691,12 @@ export function seamsOf(
634
691
  const openGaps: SeamHit[] = [];
635
692
  const coveredGaps: SeamHit[] = [];
636
693
  const exposuresA: SeamHit[] = [], exposuresB: SeamHit[] = [];
694
+ const contacts: SeamContactFrame[] = [];
637
695
  for (const frame of frames) {
638
696
  const A = sideOf(frame, a);
639
697
  const B = sideOf(frame, b);
698
+ const contact = (reading: Contact): void => { contacts.push({ index: frame.index, time: frame.time, ...reading }); };
699
+ if (A === null || B === null) contact(unavailableContact('both named slots must draw on this frame'));
640
700
  const sides = [A, B].filter((side): side is Side => side !== null);
641
701
  if (sides.length === 0) continue;
642
702
  const sizes = sides.map((side) => texelSize(side.piece, side.rest, side.page));
@@ -652,7 +712,9 @@ export function seamsOf(
652
712
  drawn++;
653
713
  const cell = exposureCell;
654
714
  const read = readFrame(A, B, cell, plant, frame, pages, exposureGrid, exposure.map((m) => m.visible));
655
- if (read === null) continue;
715
+ if (read === null) { contact(unavailableContact('no shared half-opacity cell at the setup pose; no band to identify a joint')); continue; }
716
+ const fragmented = [a, b].some(slot => frame.pieces.filter(p => p.slot === slot).length !== 1);
717
+ contact(fragmented ? unavailableContact('each named slot must draw exactly one fragment for material contact') : read.contact);
656
718
  banded++;
657
719
  for (const [cells, into] of [[read.gap, gaps], [read.ghost, ghosts], [read.ghostA, ghostsA], [read.ghostB, ghostsB], [read.gapOpen, openGaps], [read.gapCovered, coveredGaps]] as const) {
658
720
  if (cells.px > 0 && cells.box !== null) into.push({ index: frame.index, time: frame.time, px: cells.px, cell, area: cells.px * cell * cell, box: cells.box });
@@ -663,7 +725,7 @@ export function seamsOf(
663
725
  const opens = [...new Set([...gaps, ...ghosts].map((f) => f.index))].sort((x, y) => x - y);
664
726
  const maxPx = Math.max(0, ...gaps.map((f) => f.px), ...ghosts.map((f) => f.px));
665
727
  const status = banded === 0 && !plant.skipAsRead ? 'skip' : 'read';
666
- return { a, b, drawn, banded, status, opens, maxPx, gap, ghost, ghostA: kindOf(ghostsA), ghostB: kindOf(ghostsB), gapOpen: kindOf(openGaps), gapCovered: kindOf(coveredGaps), exposureA: kindOf(exposuresA), exposureB: kindOf(exposuresB) };
728
+ return { a, b, drawn, banded, status, opens, maxPx, gap, ghost, ghostA: kindOf(ghostsA), ghostB: kindOf(ghostsB), gapOpen: kindOf(openGaps), gapCovered: kindOf(coveredGaps), exposureA: kindOf(exposuresA), exposureB: kindOf(exposuresB), jointContact: { frames: contacts } };
667
729
  });
668
730
  }
669
731
 
@@ -685,6 +747,7 @@ export const SEAM_READING: SeamFile['reading'] = {
685
747
  exposureA: 'positive-rendered-alpha cells from named slot a where removing every fragment of that slot leaves alpha 0 from all other skeleton slots, regardless of draw order; measured over the whole drawing even without a seam band or partner; same hit, span and worst units as gap',
686
748
  exposureB: 'the same background exposure for named slot b; swapping the pair exchanges exposureA and exposureB; exposure does not enter opens or maxPx and is not a change-from-setup measurement',
687
749
  gapCovered: 'the gap cells whose centre has positive rendered alpha from any skeleton drawing, including the pair; alpha 1..127 covers too — this is not the pair\'s half-opacity test, and no presentation background enters',
750
+ jointContact: 'every sampled frame: joined or separated only when a setup band exists and each named drawing has exactly one edge-connected half-opacity material component at setup and frame; joined means their frame union is edge-connected (overlap or shared cell edge), separated means it is not; diagonal contact alone is separated. Otherwise unavailable with a reason, including multiple fragments of a named slot. Uses the existing origin-aligned texel grid, alpha and attachment maps, independent of other slots and draw order. Raw gap measures swept support, not certified tearing. Contact is pair material connectivity, not anatomical joint intent; joined does not rule out local holes/notches. No statement about sub-cell contact; raw gap/ghost, opens/maxPx and exit status are unchanged',
688
751
  };
689
752
 
690
753
  /** The file for one frame set. */
@@ -730,5 +793,12 @@ function seamSummary(animation: string | null, fps: number, pair: SeamPairReadin
730
793
 
731
794
  /** The seam summary plus whole-drawing background exposures, including on SKIP pairs. */
732
795
  export function seamLine(animation: string | null, fps: number, pair: SeamPairReading): string {
733
- return `${seamSummary(animation, fps, pair)}; ${kindText(`exposure ${JSON.stringify(pair.a)}`, pair.exposureA)}; ${kindText(`exposure ${JSON.stringify(pair.b)}`, pair.exposureB)}`;
796
+ const contacts = pair.jointContact.frames;
797
+ const contactText = contacts.length === 0 ? 'unavailable: no sampled frames' : null;
798
+ const summary = (status: SeamContactFrame['status']): string => {
799
+ const frames = contacts.filter(f => f.status === status);
800
+ return `${status} on ${frames.length} frame(s) [${indexRuns(frames.map(f => f.index))}]`;
801
+ };
802
+ const reasons = [...new Set(contacts.flatMap(f => f.reason === null ? [] : [f.reason]))];
803
+ return `${seamSummary(animation, fps, pair)}; ${kindText(`exposure ${JSON.stringify(pair.a)}`, pair.exposureA)}; ${kindText(`exposure ${JSON.stringify(pair.b)}`, pair.exposureB)}; joint contact: ${contactText ?? `${summary('joined')}, ${summary('separated')}, ${summary('unavailable')}`}${reasons.length ? ` (${reasons.join('; ')})` : ''}`;
734
804
  }
@@ -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
+ }