@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 +93 -2
- package/package.json +1 -1
- package/src/cli/core_commands.ts +37 -1
- package/src/cli/shared.ts +13 -1
- package/src/render.ts +9 -5
- package/src/render_core.ts +5 -3
- package/src/render_shared.ts +8 -1
- package/src/seam.ts +77 -7
- package/src/texture_scale.ts +153 -0
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>"
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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
|
}
|
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
|
-
|
|
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
|
+
}
|