rig-parts 1.83.2 → 1.84.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 +86 -0
- package/docs/STABILITY.md +7 -0
- package/package.json +1 -1
- package/src/artjoints.ts +94 -0
- package/src/build.ts +3 -0
- package/src/check.ts +20 -4
- package/src/requirements.ts +7 -2
- package/src/rig.ts +4 -3
- package/src/turn.ts +4 -0
- package/src/turncoverage.ts +156 -0
- package/src/turnfield.ts +49 -0
package/docs/AUTHORING.md
CHANGED
|
@@ -5118,6 +5118,7 @@ and stay absent).
|
|
|
5118
5118
|
| <a id="RIG_TURN_SURFACE_OVERLAP_DEPTH"></a>`RIG_TURN_SURFACE_OVERLAP_DEPTH` | same-group overlap neighbours demand incompatible emitted offsets at one target setup point | separate the conflicting support or provide coincident neighbour fields; the refusal names the fields and target vertex |
|
|
5119
5119
|
| <a id="RIG_TURN_SURFACE_MESH_BINDINGS"></a>`RIG_TURN_SURFACE_MESH_BINDINGS` | an authored surface mesh has unresolved/nonclosing weights or shares a frame moved by a unit/rear turn carrier; declared stop draw aliases resolve to their links for setup sampling | provide weighted bindings closing at 1 in the authored frame; `<link>_d` resolves only for links declared in `config.stops`; use independent chains for parts with different roles |
|
|
5120
5120
|
| <a id="RIG_TURN_SURFACE_MESH_ATTACHMENT"></a>`RIG_TURN_SURFACE_MESH_ATTACHMENT` | a surface mesh has an alternate drawing without its own registered depth sampling frame | use the base attachment; explicit islands retain their own slot, attachment and bindings |
|
|
5121
|
+
| <a id="RIG_TURN_BODY_UNCOVERED"></a>`RIG_TURN_BODY_UNCOVERED` | **Warning:** a shown part without a body role overlaps a moved `turn.body` surface at setup alpha >= 128 and has different emitted endpoint displacement | declare the intended body role and inspect both endpoints; no role or depth is inferred |
|
|
5121
5122
|
| <a id="RIG_TURN_SURFACE_DEPTH_REQUIRED"></a>`RIG_TURN_SURFACE_DEPTH_REQUIRED` | a part with role `"surface"` in `config.turn.roles` or `config.turn.body.roles` carries no depth sheet in `parts.json` | run `assemble` with `config.depth` or provide `<part>_depth.png` in `parts.json` |
|
|
5122
5123
|
| <a id="RIG_TURN_GRID_BUDGET"></a>`RIG_TURN_GRID_BUDGET` | a `"surface"` grid exceeds 2,400 triangles on one slot or 4,000 triangles across all `"surface"` slots | lower `config.turn.grid` / `config.turn.body.grid` `[cols, rows]` within the per-slot and total budgets |
|
|
5123
5124
|
| <a id="RIG_TURN_HEAD_BONE"></a>`RIG_TURN_HEAD_BONE` | `buildRig` found no `"head"` (or body turn root) bone when emitting 2.5D turn carriers | declare `"head"` (or the body turn root bone) in `config.bones` |
|
|
@@ -6020,6 +6021,71 @@ on a bone of length 0 are NOT MEASURABLE and the run is not PASS (`CK68`–`CK79
|
|
|
6020
6021
|
frame the slide peaks; a seam on a rig with no `parts.json` is refused before anything
|
|
6021
6022
|
is built (`CK83`–`CK85`, `RQ30`–`RQ35`).
|
|
6022
6023
|
|
|
6024
|
+
### Art joints on a fixed skeleton
|
|
6025
|
+
|
|
6026
|
+
When art is fitted to a skeleton whose setup must stay fixed, declare
|
|
6027
|
+
`fixed_skeleton` in the existing `--requirements` file (issue #880):
|
|
6028
|
+
|
|
6029
|
+
```json
|
|
6030
|
+
{
|
|
6031
|
+
"spec": "spine-parts-requirements/1",
|
|
6032
|
+
"fps": 12,
|
|
6033
|
+
"requirements": [],
|
|
6034
|
+
"fixed_skeleton": {
|
|
6035
|
+
"source": "independent joint measurements on the fitted art",
|
|
6036
|
+
"within_px": 4,
|
|
6037
|
+
"joints": [
|
|
6038
|
+
{ "name": "shoulder", "bone": "upper_arm", "at": [26, 57] }
|
|
6039
|
+
]
|
|
6040
|
+
}
|
|
6041
|
+
}
|
|
6042
|
+
```
|
|
6043
|
+
|
|
6044
|
+
The block explicitly identifies this as a fixed-skeleton comparison; a generation
|
|
6045
|
+
control pose does not. `source` is required nonempty provenance text, recorded
|
|
6046
|
+
without interpretation. Each `at` is an independently measured point on the art
|
|
6047
|
+
**as placed on the rig's stage**, in stage pixels from its top-left, x right and y
|
|
6048
|
+
down, the same space as a requirement's `target.stage`. Convert painting/source
|
|
6049
|
+
coordinates through the actual placement before declaring them: raw source pixels,
|
|
6050
|
+
bone-local offsets and normalized coordinates are not stage pixels. Each nonempty
|
|
6051
|
+
`name` is unique and each `bone` explicitly names its matching bone origin in
|
|
6052
|
+
`rig.json`. No anatomical names, hidden joints, correspondences or positions are
|
|
6053
|
+
inferred; only the listed measurements are covered. An unmeasured joint must not
|
|
6054
|
+
be supplied as the bone's own position.
|
|
6055
|
+
|
|
6056
|
+
`check` uses the existing setup-still renderer, requesting `--geometry`, and reads
|
|
6057
|
+
`geometry.json`'s `setup.bones` through its existing geometry reader. It converts
|
|
6058
|
+
the renderer's setup-world origin to stage pixels through the coordinate door;
|
|
6059
|
+
it never substitutes an animation's first frame or a bone's parent-local `x,y`.
|
|
6060
|
+
This also works for an external `rig.json` with no `parts.json` or idle. The
|
|
6061
|
+
Euclidean distance, art point and setup origin are printed **for every declared
|
|
6062
|
+
joint** and written to `check.json`'s `ART_JOINT_DISTANCE`, along with `source`,
|
|
6063
|
+
`instrument` and `within_px`. This is a setup-placement check, not a motion check
|
|
6064
|
+
or automatic detection of joints in an image.
|
|
6065
|
+
|
|
6066
|
+
`within_px` is optional and, when present, must be finite and nonnegative. The
|
|
6067
|
+
example's `4` is an authored demonstration, not a calibrated or default tolerance.
|
|
6068
|
+
Distance **greater than** the declared limit fails; equality passes. Without a
|
|
6069
|
+
limit, the block and every joint are **SKIP**, with the measured distances and
|
|
6070
|
+
`within_px: null`; this line is excluded from measured bars and contributes no
|
|
6071
|
+
PASS. Other independent checks may still pass. With no `fixed_skeleton` block,
|
|
6072
|
+
this measurement adds no output or render call. `fps` and `requirements` remain
|
|
6073
|
+
required by the enclosing file; they do not change this setup measurement.
|
|
6074
|
+
|
|
6075
|
+
| rule | change |
|
|
6076
|
+
| --- | --- |
|
|
6077
|
+
| <a id="CHECK_ART_JOINT_DISTANCE"></a>`CHECK_ART_JOINT_DISTANCE` | The named joint is farther from its setup bone origin than `fixed_skeleton.within_px`; the message gives distance and limit. Correct a wrong art measurement or bone correspondence, or supply art aligned to the fixed skeleton. Choose any changed limit explicitly from the consumer's requirement; bones and art are never moved by this check. |
|
|
6078
|
+
| <a id="CHECK_ART_JOINT_MEASURED"></a>`CHECK_ART_JOINT_MEASURED` | Supply a finite renderer setup origin and a finite measurable distance; missing geometry is never taken as zero. The existing `CHECK_GEOMETRY_FILE` refusal names missing/malformed geometry. |
|
|
6079
|
+
|
|
6080
|
+
`REQUIREMENTS_FIELD` and `REQUIREMENTS_RESOLVES` cover malformed declarations:
|
|
6081
|
+
supply nonempty provenance and a nonempty joint list, unique joint names, finite
|
|
6082
|
+
`[x, y]` points, known bone names and an explicit valid limit or omit the limit.
|
|
6083
|
+
Unknown fields are refused.
|
|
6084
|
+
|
|
6085
|
+
| line | measures | bar | SKIP |
|
|
6086
|
+
| --- | --- | --- | --- |
|
|
6087
|
+
| <a id="ART_JOINT_DISTANCE"></a>`ART_JOINT_DISTANCE` | Each independently measured art joint versus its explicitly named bone's renderer setup origin, in stage pixels; printed and recorded per joint | distance <= the declared `fixed_skeleton.within_px`, with no rounding slack | no limit declared: every row and the block retain their distances as SKIP, never a pass; no declaration: no line |
|
|
6088
|
+
|
|
6023
6089
|
## 8. What one character costs
|
|
6024
6090
|
|
|
6025
6091
|
| step | reference-sample | reference-demo | source |
|
|
@@ -6324,6 +6390,26 @@ art. An authored `parts.json` depth reference remains an active standalone
|
|
|
6324
6390
|
guards still apply. With binding inactive there is no added reference, file or
|
|
6325
6391
|
legacy-byte change. #773 adds no image generation, geometry or motion model.
|
|
6326
6392
|
|
|
6393
|
+
### Missing body-turn coverage
|
|
6394
|
+
|
|
6395
|
+
`RIG_TURN_BODY_UNCOVERED` warns when a shown part without a
|
|
6396
|
+
`turn.body.roles` entry overlaps a moved body surface. It names both parts,
|
|
6397
|
+
both endpoint times, their actual emitted displacement vectors and the maximum
|
|
6398
|
+
relative displacement at a setup painting-pixel centre. Both drawings require
|
|
6399
|
+
alpha >= 128 and actual covering setup triangles; bounding boxes and transparent
|
|
6400
|
+
pixels alone do not count. Excluded alpha does not count. The diagnostic reads
|
|
6401
|
+
positive bindings in the declared torso subtree, excluding head/neck subtrees.
|
|
6402
|
+
Explicit body roles and bindings wholly inheriting an emitted body-turn carrier
|
|
6403
|
+
count as declared coverage; identical inherited motion needs no second role. Differences no larger than the producer's six-place
|
|
6404
|
+
encoding unit (0.000001 rig px) are ignored. This is a warning about a missing
|
|
6405
|
+
declaration, not an automatic seam verdict or a rule for limb crossings.
|
|
6406
|
+
|
|
6407
|
+
Warnings belong to the stage report only. They add no rig, motion or PNG field
|
|
6408
|
+
and never assign a role, depth, binding or draw order. A part already bound to
|
|
6409
|
+
`chest` does not inherit a weighted topwear surface's per-vertex deform. Declare
|
|
6410
|
+
its actual intended role, then inspect setup, both turn endpoints and their diff;
|
|
6411
|
+
an explicit role alone does not establish that two drawings remain coincident.
|
|
6412
|
+
|
|
6327
6413
|
### Unit depth precedence
|
|
6328
6414
|
|
|
6329
6415
|
For `"unit"` roles, a transported part's sheet is recorded and validated but is not
|
package/docs/STABILITY.md
CHANGED
|
@@ -491,6 +491,13 @@ inverse-bind deform offsets in motion, without adding a Core attachment depth
|
|
|
491
491
|
field. Head yaw/pitch and independent body yaw use their existing turn groups.
|
|
492
492
|
Blended surfaces retain the documented per-group masks and setup-overlap
|
|
493
493
|
neighbour field rules in [AUTHORING](AUTHORING.md#per-layer-depth-sheets-773).
|
|
494
|
+
`RIG_TURN_BODY_UNCOVERED` is a report-only warning for a shown torso part
|
|
495
|
+
without a body role that has opaque setup-triangle overlap with a moved body
|
|
496
|
+
surface and different emitted endpoint displacement. It names the part, surface,
|
|
497
|
+
times, vectors and relative travel. Head/neck bindings, transparent/excluded
|
|
498
|
+
alpha, explicit/inherited body roles and identical inherited motion are excluded as
|
|
499
|
+
described in [AUTHORING](AUTHORING.md#missing-body-turn-coverage). It assigns
|
|
500
|
+
no role or depth and changes no emitted file bytes.
|
|
494
501
|
The unit-depth precedence and its primary-surface sampling are documented in
|
|
495
502
|
[AUTHORING](AUTHORING.md#unit-depth-precedence).
|
|
496
503
|
The separate ordering scalar is unchanged. No-image outputs retain legacy bytes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.84.0",
|
|
4
4
|
"description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/artjoints.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Explicit art-joint measurements on a fixed skeleton (issue #880). */
|
|
2
|
+
import { refuseIfAny, type Problem } from './errors.ts';
|
|
3
|
+
import { cropToSpineY } from './coords.ts';
|
|
4
|
+
import { edgeLength } from './length.ts';
|
|
5
|
+
|
|
6
|
+
export interface ArtJoint {
|
|
7
|
+
name: string;
|
|
8
|
+
bone: string;
|
|
9
|
+
/** Measured art point in stage pixels, x right and y down. */
|
|
10
|
+
at: [number, number];
|
|
11
|
+
}
|
|
12
|
+
export interface FixedSkeleton {
|
|
13
|
+
/** Where the independent art measurements came from; never interpreted. */
|
|
14
|
+
source: string;
|
|
15
|
+
joints: ArtJoint[];
|
|
16
|
+
/** Maximum Euclidean distance in stage pixels. Absent means measurement only. */
|
|
17
|
+
within_px?: number;
|
|
18
|
+
}
|
|
19
|
+
const object = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
20
|
+
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
21
|
+
const free = (k: string, v: unknown): boolean => (k.startsWith('x-') && k.length > 2) || ((k === 'note' || k.endsWith('_note')) && typeof v === 'string');
|
|
22
|
+
|
|
23
|
+
/** Validate without deriving any art point, bone correspondence or limit. */
|
|
24
|
+
export function fixedSkeletonProblems(value: unknown, at: string): Problem[] {
|
|
25
|
+
const problems: Problem[] = [];
|
|
26
|
+
const fail = (where: string, detail: string): void => { problems.push({ code: 'REQUIREMENTS_FIELD', object: where, detail }); };
|
|
27
|
+
const keys = (v: Record<string, unknown>, allowed: string[], where: string): void => {
|
|
28
|
+
for (const k of Object.keys(v)) if (!allowed.includes(k) && !free(k, v[k])) fail(`${where}.${k}`, `is unknown; fields are ${allowed.join(', ')}`);
|
|
29
|
+
};
|
|
30
|
+
if (!object(value)) return [{ code: 'REQUIREMENTS_FIELD', object: at, detail: 'requires {source, joints, within_px?}; omit fixed_skeleton for a rig without this declaration' }];
|
|
31
|
+
keys(value, ['source', 'joints', 'within_px'], at);
|
|
32
|
+
if (typeof value.source !== 'string' || value.source.trim() === '') fail(`${at}.source`, 'requires the provenance of the independent art-joint measurements as nonempty text');
|
|
33
|
+
if ('within_px' in value && (!finite(value.within_px) || value.within_px < 0)) fail(`${at}.within_px`, `is ${JSON.stringify(value.within_px)}; a finite nonnegative stage-pixel limit is required, or omit it for a SKIP-class measurement`);
|
|
34
|
+
if (!Array.isArray(value.joints) || value.joints.length === 0) fail(`${at}.joints`, 'requires a nonempty list of {name, bone, at: [x, y]} measured on the art in stage pixels (x right, y down)');
|
|
35
|
+
else {
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
value.joints.forEach((joint, i) => {
|
|
38
|
+
const where = `${at}.joints[${i}]`;
|
|
39
|
+
if (!object(joint)) { fail(where, 'requires {name, bone, at: [x, y]}'); return; }
|
|
40
|
+
keys(joint, ['name', 'bone', 'at'], where);
|
|
41
|
+
for (const k of ['name', 'bone']) if (typeof joint[k] !== 'string' || joint[k].trim() === '') fail(`${where}.${k}`, 'requires a nonempty name');
|
|
42
|
+
if (typeof joint.name === 'string') {
|
|
43
|
+
if (seen.has(joint.name)) fail(`${where}.name`, `declares joint "${joint.name}" twice; each measured joint is declared once`);
|
|
44
|
+
seen.add(joint.name);
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(joint.at) || joint.at.length !== 2 || !joint.at.every(finite)) fail(`${where}.at`, 'requires a finite [x, y] measured on the art in stage pixels (x right, y down)');
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return problems;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const ART_JOINT_LINE = 'ART_JOINT_DISTANCE';
|
|
53
|
+
export interface ArtJointRow extends ArtJoint {
|
|
54
|
+
/** Bone origin in the same stage-pixel space as at. */
|
|
55
|
+
bone_at: [number, number];
|
|
56
|
+
distance_px: number;
|
|
57
|
+
status: 'PASS' | 'FAIL' | 'SKIP';
|
|
58
|
+
}
|
|
59
|
+
export interface ArtJointLine {
|
|
60
|
+
status: 'PASS' | 'FAIL' | 'SKIP';
|
|
61
|
+
instrument: 'rigc-geometry/1 setup.bones';
|
|
62
|
+
source: string;
|
|
63
|
+
within_px: number | null;
|
|
64
|
+
joints: ArtJointRow[];
|
|
65
|
+
reason?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Compare independent art points to renderer setup origins, never animation frame zero. */
|
|
69
|
+
export function artJointLine(
|
|
70
|
+
fixed: FixedSkeleton,
|
|
71
|
+
stage: { x: number; y: number; height: number },
|
|
72
|
+
setup: (bone: string) => { worldX: number; worldY: number },
|
|
73
|
+
problems: Problem[],
|
|
74
|
+
): ArtJointLine {
|
|
75
|
+
refuseIfAny(fixedSkeletonProblems(fixed, 'fixed_skeleton'));
|
|
76
|
+
const limit = fixed.within_px;
|
|
77
|
+
const joints = fixed.joints.map<ArtJointRow>(j => {
|
|
78
|
+
const world = setup(j.bone);
|
|
79
|
+
if (!Number.isFinite(world.worldX) || !Number.isFinite(world.worldY)) refuseIfAny([{ code: 'CHECK_ART_JOINT_MEASURED', object: `joint "${j.name}" (bone "${j.bone}")`, detail: 'has no finite renderer setup origin; a missing position is never a zero distance' }]);
|
|
80
|
+
const boneAt: [number, number] = [world.worldX - stage.x, cropToSpineY(world.worldY - stage.y, stage.height)];
|
|
81
|
+
const distance = edgeLength(j.at[0] - boneAt[0], j.at[1] - boneAt[1]);
|
|
82
|
+
if (!Number.isFinite(distance)) refuseIfAny([{ code: 'CHECK_ART_JOINT_MEASURED', object: `joint "${j.name}" (bone "${j.bone}")`, detail: 'has no finite art-to-setup distance; finite stage-pixel coordinates are required' }]);
|
|
83
|
+
const status = limit === undefined ? 'SKIP' : distance > limit ? 'FAIL' : 'PASS';
|
|
84
|
+
if (status === 'FAIL') problems.push({ code: 'CHECK_ART_JOINT_DISTANCE', object: `joint "${j.name}" (bone "${j.bone}")`, detail: `art at [${j.at.join(', ')}] is ${distance} stage px from setup origin [${boneAt.join(', ')}]; distance <= declared fixed_skeleton.within_px ${limit} px is required — correct the art measurement/correspondence if wrong, or supply art aligned to the fixed skeleton; bones are not moved` });
|
|
85
|
+
return { name: j.name, bone: j.bone, at: [...j.at] as [number, number], bone_at: boneAt, distance_px: distance, status };
|
|
86
|
+
});
|
|
87
|
+
return { status: limit === undefined ? 'SKIP' : joints.some(j => j.status === 'FAIL') ? 'FAIL' : 'PASS', instrument: 'rigc-geometry/1 setup.bones', source: fixed.source, within_px: limit ?? null, joints,
|
|
88
|
+
...(limit === undefined ? { reason: 'fixed_skeleton.within_px is absent; distances are measured, no limit is invented and no joint passes' } : {}) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function artJointLines(line: ArtJointLine): string[] {
|
|
92
|
+
return [`${ART_JOINT_LINE}: ${line.status}${line.reason === undefined ? '' : ` — ${line.reason}`}; source ${JSON.stringify(line.source)}; ${line.instrument}`,
|
|
93
|
+
...line.joints.map(j => `${ART_JOINT_LINE}: joint "${j.name}" (bone "${j.bone}") ${j.status} — art [${j.at.join(', ')}], setup [${j.bone_at.join(', ')}], distance ${j.distance_px} px; ${line.within_px === null ? 'no declared limit (SKIP)' : `limit <= ${line.within_px} px`}`)];
|
|
94
|
+
}
|
package/src/build.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ART_JOINT_LINE, artJointLines } from './artjoints.ts';
|
|
1
2
|
import { applyTakenLayers } from './takelayers.ts';
|
|
2
3
|
import { ownershipLines, provenanceLines } from './provenance.ts';
|
|
3
4
|
import { readSourceDepth } from './depthsheets.ts';
|
|
@@ -1941,6 +1942,8 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
|
|
|
1941
1942
|
if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
|
|
1942
1943
|
const windBand = fig[WIND_BAND_LINE];
|
|
1943
1944
|
if (windBand !== undefined) for (const l of windBandLines(windBand)) log(` ${l}`);
|
|
1945
|
+
const artJoints = fig[ART_JOINT_LINE];
|
|
1946
|
+
if (artJoints !== undefined) for (const line of artJointLines(artJoints)) log(` ${line}`);
|
|
1944
1947
|
if (r.requirements !== null) {
|
|
1945
1948
|
for (const [name, line] of Object.entries(r.requirements.lines)) log(` ${requirementText(name, line)}`);
|
|
1946
1949
|
log(` ${summaryText(r.requirements.summary)}`);
|
package/src/check.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ART_JOINT_LINE, artJointLine, type ArtJointLine } from './artjoints.ts';
|
|
1
2
|
import { baseAnchorMoves, type AnchorRegion } from './baseanchor.ts';
|
|
2
3
|
/**
|
|
3
4
|
* The check stage: build a rig through rig-c, gate it under both
|
|
@@ -2694,6 +2695,8 @@ export interface CheckFigures {
|
|
|
2694
2695
|
config?: InputFileRecord;
|
|
2695
2696
|
/** Written only under `--requirements` (issue #93): the file's fps, the summary, and one line per declared requirement, by name. */
|
|
2696
2697
|
requirements?: RequirementsBlock;
|
|
2698
|
+
/** Issue #880: only when requirements declares fixed_skeleton. */
|
|
2699
|
+
[ART_JOINT_LINE]?: ArtJointLine;
|
|
2697
2700
|
PASS: boolean;
|
|
2698
2701
|
}
|
|
2699
2702
|
|
|
@@ -2940,7 +2943,7 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
|
|
|
2940
2943
|
isRecord(fig.sets) &&
|
|
2941
2944
|
Object.values(fig.sets).some((s) => isRecord(s) && typeof s.seam_mean === 'number' && typeof s.seam_px_over_40 === 'number' && seamFails(s.seam_mean, s.seam_px_over_40, typeof s.seam_frame_scale === 'number' ? s.seam_frame_scale : 1));
|
|
2942
2945
|
if ((typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) || setFailed) out.push('seam');
|
|
2943
|
-
for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE]) {
|
|
2946
|
+
for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, ART_JOINT_LINE]) {
|
|
2944
2947
|
const l = fig[n];
|
|
2945
2948
|
if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
|
|
2946
2949
|
}
|
|
@@ -3049,7 +3052,7 @@ export function checkStatuses(fig: CheckFigures): Record<string, CheckStatus> {
|
|
|
3049
3052
|
const bar = (name: string, measured: boolean): CheckStatus => (!measured ? 'SKIP' : notMet.includes(name) ? 'FAIL' : 'PASS');
|
|
3050
3053
|
const out: Record<string, CheckStatus> = { gate: bar('gate', true), loop: bar('loop', fig.loop_max_diff !== null), seam: bar('seam', fig.seam_mean !== null) };
|
|
3051
3054
|
for (const n of JUDGEMENT_LINES) out[n] = fig[n].status;
|
|
3052
|
-
for (const n of [FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, MOTION_SEAM_LINE, CLOTH_LINE, ...REPORTED_LINES, SOURCE_LINE, WIND_BAND_LINE] as const) {
|
|
3055
|
+
for (const n of [FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, MOTION_SEAM_LINE, CLOTH_LINE, ...REPORTED_LINES, SOURCE_LINE, WIND_BAND_LINE, ART_JOINT_LINE] as const) {
|
|
3053
3056
|
const l = fig[n];
|
|
3054
3057
|
if (l !== undefined) out[n] = l.status;
|
|
3055
3058
|
}
|
|
@@ -6402,7 +6405,9 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6402
6405
|
const setsFigures: Record<string, SetCheckFigures> = {};
|
|
6403
6406
|
// Issue #671: a failing set's hint, read with its seam; never written to check.json.
|
|
6404
6407
|
const setHints = new Map<string, string>();
|
|
6405
|
-
|
|
6408
|
+
const fixedSkeleton = reqFile?.fixed_skeleton;
|
|
6409
|
+
let artJoints: ArtJointLine | null = null;
|
|
6410
|
+
if (inp.parts !== null || inp.source !== null || fixedSkeleton !== undefined) {
|
|
6406
6411
|
try {
|
|
6407
6412
|
mkdirSync(stillDir, { recursive: true });
|
|
6408
6413
|
const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
|
|
@@ -6426,9 +6431,18 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6426
6431
|
if (sb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still)', sb, buildGateLines(sb.out)));
|
|
6427
6432
|
// The stage is the parts' canvas wherever parts.json is read (CHECK_RIG_STAGE_IS_THE_CANVAS), so this is its longest side.
|
|
6428
6433
|
const maxSide = Math.ceil(Math.max(inp.stage.width, inp.stage.height));
|
|
6429
|
-
const sr = rigc(['render', '--candidate', join(stillDir, 'build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'render')]);
|
|
6434
|
+
const sr = rigc(['render', '--candidate', join(stillDir, 'build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), ...(fixedSkeleton === undefined ? [] : ['--geometry']), '--out', join(stillDir, 'render')]);
|
|
6430
6435
|
if (sr.status !== 0) refuseIfAny(rigcFailed('render (the setup-pose still)', sr, sr.out.split('\n').filter((l) => l.includes('FAIL'))));
|
|
6431
6436
|
const stillSet = readFrameSet(join(stillDir, 'render'));
|
|
6437
|
+
if (fixedSkeleton !== undefined) {
|
|
6438
|
+
const geometryPath = join(stillSet.dir, GEOMETRY_FILE);
|
|
6439
|
+
const geometry = readGeometry(geometryPath, stillSet.written);
|
|
6440
|
+
artJoints = artJointLine(fixedSkeleton, inp.stage, bone => {
|
|
6441
|
+
const track = boneTrackOf(geometry, geometryPath, stillSet, bone);
|
|
6442
|
+
if (typeof track === 'string') return geometryProblem(geometryPath, track);
|
|
6443
|
+
return track.setup;
|
|
6444
|
+
}, problems);
|
|
6445
|
+
}
|
|
6432
6446
|
seamViewport = stillSet.viewport;
|
|
6433
6447
|
if (keepStills) cpSync(join(stillDir, 'render'), inp.parts === null ? keptDir : join(out, 'setup_still_auto'), { recursive: true });
|
|
6434
6448
|
if (inp.parts !== null) {
|
|
@@ -6621,6 +6635,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6621
6635
|
...(Object.keys(skipped).length === 0 ? {} : { skipped }),
|
|
6622
6636
|
...(Object.keys(setsFigures).length === 0 ? {} : { sets: setsFigures }),
|
|
6623
6637
|
...(config === undefined ? {} : { config }),
|
|
6638
|
+
...(artJoints === null ? {} : { [ART_JOINT_LINE]: artJoints }),
|
|
6624
6639
|
...(reqBlock === null ? {} : { requirements: reqBlock }),
|
|
6625
6640
|
PASS: false,
|
|
6626
6641
|
};
|
|
@@ -6684,6 +6699,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6684
6699
|
}
|
|
6685
6700
|
// Issues #320, #333: the fold bar is counted where it is written — on every rig whose motion holds an animation.
|
|
6686
6701
|
if (folds !== null) tally(FOLD_LINE, null);
|
|
6702
|
+
if (artJoints !== null) tally(ART_JOINT_LINE, artJoints.status === 'SKIP' ? (artJoints.reason ?? 'no declared limit') : null);
|
|
6687
6703
|
// Issue #348: the level bar where it is written; a line whose every foot no frame posed says SKIP and is not measured.
|
|
6688
6704
|
if (level !== null) tally(LEVEL_LINE, level.status === 'SKIP' ? (level.reason ?? '') : null);
|
|
6689
6705
|
// Issue #347: the stress bar is counted where it is written — on every rig with a handwear part and an idle.
|
package/src/requirements.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fixedSkeletonProblems, type FixedSkeleton } from './artjoints.ts';
|
|
1
2
|
/**
|
|
2
3
|
* What a scene requires of a rig's motion, declared in a file `check` is given
|
|
3
4
|
* (`--requirements`, issue #93, increment 2 of #87) and measured from the
|
|
@@ -190,6 +191,8 @@ export interface RequirementsFile {
|
|
|
190
191
|
* not declared: no fold in a named animation is meant.
|
|
191
192
|
*/
|
|
192
193
|
may_fold: MayFold[];
|
|
194
|
+
/** Issue #880: an explicit fixed-skeleton art comparison, absent on other rigs. */
|
|
195
|
+
fixed_skeleton?: FixedSkeleton;
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
/** One declared fold (issue #320): a slot, a named animation, and why the fold is the art's. */
|
|
@@ -280,7 +283,8 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
280
283
|
problems.push({ code: 'REQUIREMENTS_SELF_TARGET', object: `${label} field "target"`, detail });
|
|
281
284
|
};
|
|
282
285
|
if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
|
|
283
|
-
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS, 'may_fold'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests, may_fold; note or *_note as a string; x-<name> as a record)');
|
|
286
|
+
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS, 'may_fold', 'fixed_skeleton'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests, may_fold, fixed_skeleton; note or *_note as a string; x-<name> as a record)');
|
|
287
|
+
if ('fixed_skeleton' in raw) problems.push(...fixedSkeletonProblems(raw.fixed_skeleton, `${at} fixed_skeleton`));
|
|
284
288
|
let fps = NaN;
|
|
285
289
|
if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
|
|
286
290
|
else fps = raw.fps;
|
|
@@ -464,7 +468,7 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
464
468
|
}
|
|
465
469
|
}
|
|
466
470
|
refuseIfAny(problems);
|
|
467
|
-
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests, may_fold: mayFold };
|
|
471
|
+
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests, may_fold: mayFold, ...(raw.fixed_skeleton === undefined ? {} : { fixed_skeleton: raw.fixed_skeleton as FixedSkeleton }) };
|
|
468
472
|
}
|
|
469
473
|
|
|
470
474
|
// ---------------------------------------------------------------------------
|
|
@@ -542,6 +546,7 @@ export function resolveRequirements(file: RequirementsFile, rig: Record<string,
|
|
|
542
546
|
const target = (object: string, t: TargetRef): void => {
|
|
543
547
|
if ('bone' in t) bone(`${object} target`, t.bone);
|
|
544
548
|
};
|
|
549
|
+
for (const j of file.fixed_skeleton?.joints ?? []) bone(`fixed_skeleton joint "${j.name}"`, j.bone);
|
|
545
550
|
for (const r of file.requirements) {
|
|
546
551
|
const object = `requirement "${r.name}" (${r.kind})`;
|
|
547
552
|
animation(object, r.animation);
|
package/src/rig.ts
CHANGED
|
@@ -3668,6 +3668,7 @@ export function buildWaistPairSource(
|
|
|
3668
3668
|
hasAlternateFaceSet,
|
|
3669
3669
|
surfaceMeshes: turnSurfaceMeshes(cfg, rig, parts.rig_size),
|
|
3670
3670
|
neighbourMeshes: turnNeighbourMeshes(cfg, rig, parts, new Map(outImages)),
|
|
3671
|
+
coverageMeshes: cfg.turn.body === undefined ? undefined : turnNeighbourMeshes(cfg, rig, parts, new Map(outImages), true),
|
|
3671
3672
|
});
|
|
3672
3673
|
}
|
|
3673
3674
|
bindFaceSurfaceDeform(motion, (merged) => {
|
|
@@ -4206,13 +4207,13 @@ function turnSurfaceMeshes(cfg: CharacterConfig, rig: RigSpec, canvas: readonly
|
|
|
4206
4207
|
}
|
|
4207
4208
|
|
|
4208
4209
|
/** Actual shown support; Core's own grid topology is inspection metadata only. */
|
|
4209
|
-
export function turnNeighbourMeshes(cfg: CharacterConfig, rig: RigSpec, parts: PartsFile, emittedImages: ReadonlyMap<string, Raster
|
|
4210
|
+
export function turnNeighbourMeshes(cfg: CharacterConfig, rig: RigSpec, parts: PartsFile, emittedImages: ReadonlyMap<string, Raster>, coverage = false) {
|
|
4210
4211
|
const meshes: NonNullable<Parameters<typeof buildDepth25DTurn>[2]['neighbourMeshes']>[number][] = [];
|
|
4211
4212
|
const canvas = parts.rig_size;
|
|
4212
4213
|
const aliases = new Map<string, string>((rig.stops ?? []).flatMap(s => s.links.map(link => [`${link}_d`, link])));
|
|
4213
4214
|
const world = computeWorldTransforms(rig.bones.map(b => b.parent === undefined ? b : { ...b, parent: aliases.get(b.parent) ?? b.parent }));
|
|
4214
|
-
for (const part of new Set([...Object.keys(cfg.turn?.roles ?? {}), ...Object.keys(cfg.turn?.body?.roles ?? {})])) {
|
|
4215
|
-
if (cfg.meshes[part] !== undefined && [cfg.turn?.roles?.[part], cfg.turn?.body?.roles?.[part]].includes('surface')) continue;
|
|
4215
|
+
for (const part of new Set([...Object.keys(cfg.turn?.roles ?? {}), ...Object.keys(cfg.turn?.body?.roles ?? {}), ...(coverage ? parts.parts.map(p => p.name) : [])])) {
|
|
4216
|
+
if (!coverage && cfg.meshes[part] !== undefined && [cfg.turn?.roles?.[part], cfg.turn?.body?.roles?.[part]].includes('surface')) continue;
|
|
4216
4217
|
const record = parts.parts.find(p => p.name === part);
|
|
4217
4218
|
if (record === undefined) continue;
|
|
4218
4219
|
const split = cfg.regions[part] !== undefined && cfg.motion.blink?.still?.[part] !== undefined
|
package/src/turn.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { turnOverlapOwners } from './turnoverlap.ts';
|
|
|
37
37
|
import { sampleTurnTriangleField, turnTriangleCoordinates } from './turnfield.ts';
|
|
38
38
|
import { PartsError } from './errors.ts';
|
|
39
39
|
import { f32 } from '@rig-c/core/compile';
|
|
40
|
+
import { warnUncoveredBodyTurn } from './turncoverage.ts';
|
|
40
41
|
import { evaluateDeformTransform } from '@rig-c/core/src/deformgen.ts';
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -1505,6 +1506,8 @@ export interface Depth25DTurnOptions {
|
|
|
1505
1506
|
}>;
|
|
1506
1507
|
/** Final weighted unit/rear drawings are support only, never surface targets. */
|
|
1507
1508
|
neighbourMeshes?: Depth25DTurnOptions['surfaceMeshes'];
|
|
1509
|
+
/** Shown setup geometry for coverage diagnostics only; never a role opt-in. */
|
|
1510
|
+
coverageMeshes?: Depth25DTurnOptions['surfaceMeshes'];
|
|
1508
1511
|
}
|
|
1509
1512
|
|
|
1510
1513
|
interface Depth25DGroupSpec {
|
|
@@ -2216,4 +2219,5 @@ export function buildDepth25DTurn(
|
|
|
2216
2219
|
}
|
|
2217
2220
|
|
|
2218
2221
|
refuseIfAny(problems);
|
|
2222
|
+
if (opts.warnings !== undefined) warnUncoveredBodyTurn(rig, motion, opts);
|
|
2219
2223
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** Report missing body-turn declarations from shown setup alpha and emitted fields. */
|
|
2
|
+
import { f32 } from '@rig-c/core/compile';
|
|
3
|
+
import { DRAWN_ALPHA } from '@rig-c/core/src/passthrough.ts';
|
|
4
|
+
import { evaluateDeformTransform, type DeformTransform } from '@rig-c/core/src/deformgen.ts';
|
|
5
|
+
import { computeWorldTransforms, paintingToSetupPoint, toWorld } from './coords.ts';
|
|
6
|
+
import { extendDepthSilhouette, sampleDepthFloat } from './depthsheets.ts';
|
|
7
|
+
import { edgeLength } from './length.ts';
|
|
8
|
+
import type { Depth25DTurnOptions } from './turn.ts';
|
|
9
|
+
import { rasterTurnTriangleOwnership, type TurnTriangleQuery } from './turnfield.ts';
|
|
10
|
+
|
|
11
|
+
type Mesh = NonNullable<Depth25DTurnOptions['coverageMeshes']>[number];
|
|
12
|
+
type Rig = Parameters<typeof computeWorldTransforms>[0];
|
|
13
|
+
interface Animation {
|
|
14
|
+
tracks: Array<{ bone?: string; group?: string; property: string; keys: Array<{ t: number; v?: number[] | Record<string, number[]>; derive?: { degrees: number; carried?: number; depth?: number | Record<string, number> } }> }>;
|
|
15
|
+
deform?: Array<{ slot: string; attachment: string; keys: Array<{ t: number; vertices?: number[]; transform?: DeformTransform }> }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Warning only: overlap cannot prove the author's intended role. Explicit or inherited body
|
|
20
|
+
* roles already declare coverage; this reader never assigns one. A one-millionth
|
|
21
|
+
* pixel is the producer's six-place encoding unit, not a visual acceptance bar.
|
|
22
|
+
*/
|
|
23
|
+
export function warnUncoveredBodyTurn(
|
|
24
|
+
rig: { bones: Rig; stops?: ReadonlyArray<{ links: readonly string[] }> },
|
|
25
|
+
motion: { groups?: Record<string, string[]>; animations: Record<string, unknown> },
|
|
26
|
+
opts: Depth25DTurnOptions,
|
|
27
|
+
): void {
|
|
28
|
+
const body = opts.turn.body, meshes = opts.coverageMeshes;
|
|
29
|
+
const anim = motion.animations.body_turn as Animation | undefined;
|
|
30
|
+
if (body === undefined || meshes === undefined || anim === undefined || opts.warnings === undefined) return;
|
|
31
|
+
const aliases = new Map<string, string>((rig.stops ?? []).flatMap(s => s.links.map(b => [`${b}_d`, b] as const)));
|
|
32
|
+
const resolve = (b: string) => aliases.get(b) ?? b;
|
|
33
|
+
const bones = rig.bones.map(b => b.parent === undefined ? b : { ...b, parent: resolve(b.parent) });
|
|
34
|
+
const world = computeWorldTransforms(bones), parents = new Map(bones.map(b => [b.name, b.parent]));
|
|
35
|
+
const chain = (bone: string): string[] => {
|
|
36
|
+
const result: string[] = [];
|
|
37
|
+
for (let b: string | undefined = resolve(bone); b !== undefined && !result.includes(b); b = parents.get(b)) result.push(b);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
const root = body.bone ?? (parents.has('chest') ? 'chest' : 'hip');
|
|
41
|
+
const torso = (m: Mesh) => m.weights.every(row => row.filter(w => w.weight > 0).every(w => {
|
|
42
|
+
const path = chain(w.bone);
|
|
43
|
+
return path.includes(root) && !path.some(b => ['head', 'head_ctl', 'neck', 'neck_ctl'].includes(b));
|
|
44
|
+
}));
|
|
45
|
+
const positions = new Map(meshes.map(m => [m, m.weights.map(row => {
|
|
46
|
+
let x = 0, y = 0;
|
|
47
|
+
for (const w of row) { const p = toWorld(world.get(resolve(w.bone))!, w.x, w.y); x += p[0] * w.weight; y += p[1] * w.weight; }
|
|
48
|
+
return [x, y];
|
|
49
|
+
})]));
|
|
50
|
+
const times = anim.tracks[0]?.keys;
|
|
51
|
+
if (times === undefined || times.length === 0) return;
|
|
52
|
+
const endpoints = [times[0], times[times.length - 1]].map(({ t }) => {
|
|
53
|
+
const translations = new Map<string, number[]>();
|
|
54
|
+
for (const tr of anim.tracks) {
|
|
55
|
+
const key = tr.keys.find(k => k.t === t);
|
|
56
|
+
if (key === undefined) continue;
|
|
57
|
+
for (const bone of tr.bone === undefined ? motion.groups?.[tr.group!] ?? [] : [tr.bone]) {
|
|
58
|
+
const derive = key.derive, depth = typeof derive?.depth === 'number' ? derive.depth : derive?.depth?.[bone];
|
|
59
|
+
const stated = Array.isArray(key.v) ? key.v[0] : key.v?.[bone]?.[0];
|
|
60
|
+
const value = f32(stated ?? -(depth! - (derive?.carried ?? 0)) * Math.sin(derive!.degrees * Math.PI / 180));
|
|
61
|
+
const parent = parents.get(bone), frame = parent === undefined ? undefined : world.get(resolve(parent));
|
|
62
|
+
const v = tr.property === 'translatex' ? [value, 0] : [0, value];
|
|
63
|
+
translations.set(bone, frame === undefined ? v : [frame.a * v[0] + frame.b * v[1], frame.c * v[0] + frame.d * v[1]]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return new Map(meshes.map(m => {
|
|
67
|
+
const key = anim.deform?.find(d => d.slot === m.slot && d.attachment === m.attachment)?.keys.find(k => k.t === t);
|
|
68
|
+
let generated: number[] | undefined;
|
|
69
|
+
if (key?.transform !== undefined && m.gridSamples !== undefined) {
|
|
70
|
+
const p = opts.parts.find(p => p.name === m.part)!;
|
|
71
|
+
const ext = extendDepthSilhouette(opts.images.get(p.depth!.image)!, opts.images.get(p.name)!);
|
|
72
|
+
const z = m.gridSamples.map(([x, y]) => { const level = sampleDepthFloat(ext, x - p.x, y - p.y); return (p.depth!.near === 'black' ? 255 - level : level) * p.depth!.zScale / 255; });
|
|
73
|
+
generated = evaluateDeformTransform(key.transform, positions.get(m)!.flat(), f32, `body coverage "${m.part}"`, z).offsets;
|
|
74
|
+
}
|
|
75
|
+
let at = 0;
|
|
76
|
+
const vectors = m.weights.map((row, vi) => {
|
|
77
|
+
let x = 0, y = 0;
|
|
78
|
+
for (const w of row) {
|
|
79
|
+
const frame = world.get(resolve(w.bone))!;
|
|
80
|
+
const dx = f32(key?.vertices?.[at++] ?? 0), dy = f32(key?.vertices?.[at++] ?? 0);
|
|
81
|
+
let wx = generated === undefined ? frame.a * dx + frame.b * dy : generated[2 * vi];
|
|
82
|
+
let wy = generated === undefined ? frame.c * dx + frame.d * dy : generated[2 * vi + 1];
|
|
83
|
+
for (const b of chain(w.bone)) { const v = translations.get(b); if (v !== undefined) { wx += v[0]; wy += v[1]; } }
|
|
84
|
+
x += wx * w.weight; y += wy * w.weight;
|
|
85
|
+
}
|
|
86
|
+
return [x, y];
|
|
87
|
+
});
|
|
88
|
+
return [m, vectors] as const;
|
|
89
|
+
}));
|
|
90
|
+
});
|
|
91
|
+
const opaque = (name: string, x: number, y: number) => {
|
|
92
|
+
const p = opts.parts.find(p => p.name === name)!, image = opts.images.get(name)!;
|
|
93
|
+
const px = Math.floor(x - p.x), py = Math.floor(y - p.y);
|
|
94
|
+
return px >= 0 && py >= 0 && px < image.width && py < image.height && image.data[(py * image.width + px) * 4 + 3] >= DRAWN_ALPHA;
|
|
95
|
+
};
|
|
96
|
+
const sample = (m: Mesh, hit: { ids: number[]; weights: number[] }, endpoint: number): number[] => {
|
|
97
|
+
const field = endpoints[endpoint].get(m)!;
|
|
98
|
+
const [a, b, c] = hit.ids.map(i => field[i]), [u, v] = hit.weights;
|
|
99
|
+
// The same translation-invariant barycentric form as sampleTurnTriangleField.
|
|
100
|
+
return [0, 1].map(axis => c[axis] + u * (a[axis] - c[axis]) + v * (b[axis] - c[axis]));
|
|
101
|
+
};
|
|
102
|
+
const keyedBones = new Set(anim.tracks.flatMap(tr => tr.bone === undefined ? motion.groups?.[tr.group!] ?? [] : [tr.bone]));
|
|
103
|
+
const pairs: Array<{ part: Depth25DTurnOptions['parts'][number]; surface: string; source: Depth25DTurnOptions['parts'][number]; targets: Mesh[]; supports: Mesh[] }> = [];
|
|
104
|
+
for (const part of opts.parts) {
|
|
105
|
+
if (body.roles[part.name] !== undefined) continue;
|
|
106
|
+
const targets = meshes.filter(m => m.part === part.name && torso(m));
|
|
107
|
+
if (targets.length === 0 || targets.every(m => m.weights.every(row => row.filter(w => w.weight > 0).every(w => chain(w.bone).some(b => keyedBones.has(b)))))) continue;
|
|
108
|
+
for (const [surface, role] of Object.entries(body.roles)) {
|
|
109
|
+
if (role !== 'surface') continue;
|
|
110
|
+
const source = opts.parts.find(p => p.name === surface)!;
|
|
111
|
+
const supports = meshes.filter(m => m.part === surface);
|
|
112
|
+
pairs.push({ part, surface, source, targets, supports });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const requested = new Map<Mesh, Map<string, TurnTriangleQuery>>();
|
|
116
|
+
for (const { part, source, targets, supports } of pairs) {
|
|
117
|
+
for (let y = Math.max(part.y, source.y); y < Math.min(part.y + part.h, source.y + source.h); y++) {
|
|
118
|
+
for (let x = Math.max(part.x, source.x); x < Math.min(part.x + part.w, source.x + source.w); x++) {
|
|
119
|
+
if (!opaque(part.name, x + 0.5, y + 0.5) || !opaque(source.name, x + 0.5, y + 0.5)) continue;
|
|
120
|
+
for (const target of targets) {
|
|
121
|
+
const point = paintingToSetupPoint([x + 0.5, y + 0.5], target.canvas, target.canvas), key = `${point[0]},${point[1]}`;
|
|
122
|
+
for (const mesh of [target, ...supports]) {
|
|
123
|
+
let cells = requested.get(mesh);
|
|
124
|
+
if (cells === undefined) { cells = new Map(); requested.set(mesh, cells); }
|
|
125
|
+
if (!cells.has(key)) cells.set(key, { x: point[0], y: point[1], point });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const ownership = new Map([...requested].map(([mesh, cells]) => [mesh, rasterTurnTriangleOwnership(positions.get(mesh)!, mesh.triangles ?? [], [...cells.values()])]));
|
|
132
|
+
for (const { part, surface, source, targets, supports } of pairs) {
|
|
133
|
+
const readings = [0, 1].map(() => ({ relative: 0, point: [0, 0], part: [0, 0], surface: [0, 0] }));
|
|
134
|
+
for (let y = Math.max(part.y, source.y); y < Math.min(part.y + part.h, source.y + source.h); y++) {
|
|
135
|
+
for (let x = Math.max(part.x, source.x); x < Math.min(part.x + part.w, source.x + source.w); x++) {
|
|
136
|
+
const px = x + 0.5, py = y + 0.5;
|
|
137
|
+
if (!opaque(part.name, px, py) || !opaque(surface, px, py)) continue;
|
|
138
|
+
for (const target of targets) for (const support of supports) {
|
|
139
|
+
const p = paintingToSetupPoint([px, py], target.canvas, target.canvas);
|
|
140
|
+
const a = ownership.get(target)?.at(p[0], p[1]) ?? null;
|
|
141
|
+
const b = ownership.get(support)?.at(p[0], p[1]) ?? null;
|
|
142
|
+
if (a === null || b === null) continue;
|
|
143
|
+
for (const [endpoint, reading] of readings.entries()) {
|
|
144
|
+
const av = sample(target, a, endpoint), bv = sample(support, b, endpoint);
|
|
145
|
+
const relative = edgeLength(av[0] - bv[0], av[1] - bv[1]);
|
|
146
|
+
if (edgeLength(bv[0], bv[1]) > 0.000001 && relative > reading.relative) Object.assign(reading, { relative, point: [px, py], part: av, surface: bv });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (readings.some(r => r.relative > 0.000001)) opts.warnings.push({
|
|
152
|
+
code: 'RIG_TURN_BODY_UNCOVERED', object: `part "${part.name}"`,
|
|
153
|
+
detail: `overlaps moved body surface "${surface}" at alpha >= ${DRAWN_ALPHA} without a config.turn.body role; body_turn endpoints ${readings.map((r, i) => `t=${i === 0 ? times[0].t : times[times.length - 1].t}: part displacement [${r.part.join(', ')}], surface displacement [${r.surface.join(', ')}], relative ${r.relative} rig px at setup painting [${r.point.join(', ')}]`).join('; ')}; declare the intended body role and inspect both endpoints; no role or depth is inferred`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/turnfield.ts
CHANGED
|
@@ -22,6 +22,55 @@ export function turnTriangleCoordinates(points: ReadonlyArray<readonly number[]>
|
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** A requested setup-grid cell and its exact caller-computed world point. */
|
|
26
|
+
export interface TurnTriangleQuery { x: number; y: number; point: readonly number[] }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rasterise only requested cells, in triangle order. Sparse sorted sample rows
|
|
30
|
+
* bound the storage to the query set, not a world/canvas rectangle. One adjacent
|
|
31
|
+
* row/cell is conservatively visited at each bbox edge; this is a broad phase,
|
|
32
|
+
* never an epsilon in the barycentric test. The original arithmetic below is
|
|
33
|
+
* the sole acceptance rule, including zero-area skips and first-triangle ties.
|
|
34
|
+
*/
|
|
35
|
+
export function rasterTurnTriangleOwnership(points: ReadonlyArray<readonly number[]>, triangles: readonly number[], queries: readonly TurnTriangleQuery[]) {
|
|
36
|
+
const key = (x: number, y: number) => `${x},${y}`;
|
|
37
|
+
const unique = new Map(queries.map(q => [key(q.x, q.y), q]));
|
|
38
|
+
const byY = new Map<number, TurnTriangleQuery[]>();
|
|
39
|
+
for (const q of unique.values()) {
|
|
40
|
+
const row = byY.get(q.point[1]);
|
|
41
|
+
if (row === undefined) byY.set(q.point[1], [q]); else row.push(q);
|
|
42
|
+
}
|
|
43
|
+
const rows = [...byY].sort((a, b) => a[0] - b[0]).map(([y, cells]) => ({ y, cells: cells.sort((a, b) => a.point[0] - b.point[0]) }));
|
|
44
|
+
const bound = <T>(values: readonly T[], value: number, coordinate: (v: T) => number, upper: boolean): number => {
|
|
45
|
+
let lo = 0, hi = values.length;
|
|
46
|
+
while (lo < hi) { const mid = (lo + hi) >>> 1, n = coordinate(values[mid]); if (n < value || (upper && n === value)) lo = mid + 1; else hi = mid; }
|
|
47
|
+
return lo;
|
|
48
|
+
};
|
|
49
|
+
const owners = new Map<string, { ids: number[]; weights: number[] }>();
|
|
50
|
+
for (let i = 0; i < triangles.length; i += 3) {
|
|
51
|
+
const ids = triangles.slice(i, i + 3), [a, b, c] = ids.map(j => points[j]);
|
|
52
|
+
if (a === undefined || b === undefined || c === undefined) continue;
|
|
53
|
+
const area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
54
|
+
if (area === 0) continue;
|
|
55
|
+
const minX = Math.min(a[0], b[0], c[0]), maxX = Math.max(a[0], b[0], c[0]);
|
|
56
|
+
const minY = Math.min(a[1], b[1], c[1]), maxY = Math.max(a[1], b[1], c[1]);
|
|
57
|
+
const fromY = Math.max(0, bound(rows, minY, r => r.y, false) - 1), toY = Math.min(rows.length, bound(rows, maxY, r => r.y, true) + 1);
|
|
58
|
+
for (let yi = fromY; yi < toY; yi++) {
|
|
59
|
+
const row = rows[yi].cells;
|
|
60
|
+
const fromX = Math.max(0, bound(row, minX, q => q.point[0], false) - 1), toX = Math.min(row.length, bound(row, maxX, q => q.point[0], true) + 1);
|
|
61
|
+
for (let xi = fromX; xi < toX; xi++) {
|
|
62
|
+
const q = row[xi], k = key(q.x, q.y);
|
|
63
|
+
if (owners.has(k)) continue;
|
|
64
|
+
const [x, y] = q.point;
|
|
65
|
+
const cross = (p: readonly number[], r: readonly number[]) => (p[0] - x) * (r[1] - y) - (p[1] - y) * (r[0] - x);
|
|
66
|
+
const weights = [cross(b, c) / area, cross(c, a) / area, cross(a, b) / area];
|
|
67
|
+
if (weights.every(w => w >= 0)) owners.set(k, { ids, weights });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { at: (x: number, y: number) => owners.get(key(x, y)) ?? null };
|
|
72
|
+
}
|
|
73
|
+
|
|
25
74
|
/** Named refusal outside support; never fall back to target depth or nearest geometry. */
|
|
26
75
|
export function sampleTurnTriangleField(field: TurnTriangleField, point: readonly number[], object: string): [number, number] {
|
|
27
76
|
const fail = (detail: string): never => { throw new PartsError([{ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object, detail: `neighbour "${field.label}" ${detail}` }]); };
|