@rig-c/review 0.4.0 → 0.5.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/README.md +46 -12
- package/docs/DESIGN.md +10 -6
- package/package.json +1 -1
- package/src/chest-regions.ts +253 -0
- package/web/chest-regions-tool.ts +51 -0
- package/web/compare.css +1 -0
- package/web/index.html +3 -6
- package/web/main.ts +69 -38
- package/web/rig-overlays.ts +10 -2
- package/web/styles.css +78 -21
- package/web/view-controls.ts +11 -5
- package/web/workspace-layout.ts +2 -1
- package/web/workspace-ui.ts +23 -11
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Compare feedback requires side `a` or `b` and carries both package source hashes
|
|
|
69
69
|
|
|
70
70
|
## Feedback ownership
|
|
71
71
|
|
|
72
|
-
Opening leaves status **pending**. The person explicitly saves **approved**, **changes-requested**, or **pending**, checkpoint outcomes/notes, and feedback containing an inclusive frame interval, a bone/slot/rig target, defect/quality category and one-line summary. Frame numbers are entered explicitly; the app does not invent a source FPS.
|
|
72
|
+
Opening leaves status **pending**. The person explicitly saves **approved**, **changes-requested**, or **pending**, checkpoint outcomes/notes, and feedback containing an inclusive frame interval, a bone/slot/rig target, defect/quality category and one-line summary. Frame numbers are entered explicitly; the app does not invent a source FPS. **Write review**, at the top right, opens the review dialog: review status, reviewed source, selected checkpoint, structured feedback and the frame-strip export. Its **Submit review** saves that explicit status and feedback; closing the dialog keeps the unsubmitted draft. The page has no feedback export buttons: `rig-review feedback --format json` and `--format issue` (below) are the export path, and carry the original source binding and reproducible inspection context. Each submission records `reviewedSource`: package revision, actual viewer/runtime versions, and separate exact-byte SHA-256 plus path for the reviewed rig and motion; an absent artifact stays null. An active compiled model is the rig identity; raw skeleton JSON is evidence with playback unavailable.
|
|
73
73
|
|
|
74
74
|
New submissions validate bone/slot names against the exact loaded model; the target field offers those names. A rig target names the package id. Source-only packages cannot validate a bone/slot target and say so explicitly. When a matching animation has a declared producer clock, the inclusive interval cannot end beyond the last declared sample at or before the actual clip end. Unknown clocks keep manual frame intervals without inventing a limit. `context.interval` records the chosen interval; inspection `time`/`frame` remain separate. Older invalid feedback stays readable with per-row warnings and exports its original evidence, but cannot be resubmitted until corrected or removed.
|
|
75
75
|
|
|
@@ -93,7 +93,7 @@ rig-review feedback /absolute/output --reference /absolute/original.mp4 --format
|
|
|
93
93
|
rig-review feedback /absolute/a --compare /absolute/b --reference /absolute/original.mp4 --animation dance --format issue
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
The header counts feedback records by defect/quality and by build; blocks group by category, then exact target kind/name. Overlapping intervals and A/B sides remain distinct items. Each block retains its id, inclusive interval, one-line summary and selected/full paired provenance. Historical tool identities and validation warnings remain visible; output never migrates approval, writes state or publishes an issue. Default output and `--format json` remain the existing session JSON. Only feedback accepts `--format`, with `json` or `issue` as its value.
|
|
96
|
+
The header counts feedback records by defect/quality and by build; blocks group by category, then exact target kind/name. Overlapping intervals and A/B sides remain distinct items. Each block retains its id, inclusive interval, one-line summary and selected/full paired provenance. Historical tool identities and validation warnings remain visible; output never migrates approval, writes state or publishes an issue. Default output and `--format json` remain the existing session JSON. Only feedback accepts `--format`, with `json` or `issue` as its value. These two formats are how submitted feedback leaves the tool; the browser page offers no JSON or Markdown download.
|
|
97
97
|
|
|
98
98
|
## Development
|
|
99
99
|
|
|
@@ -334,7 +334,9 @@ active pane, and each canonical camera center with visible bounds. Reloading a m
|
|
|
334
334
|
it does not restart an idle loop or submit a review. Coordinates adapt to the
|
|
335
335
|
current viewport instead of replaying another window's CSS pan offsets.
|
|
336
336
|
Historical links without a sync mode restore with sync off to preserve their
|
|
337
|
-
saved views; fresh inspections start with sync on.
|
|
337
|
+
saved views; fresh inspections start with sync on. The page keeps the current
|
|
338
|
+
fragment in the address bar as you inspect, so copying the address copies the
|
|
339
|
+
view; there is no separate copy button. Legacy modeling/motion
|
|
338
340
|
workspace selections are accepted without restoring a top-level workspace switch. A link
|
|
339
341
|
for a different source binding is refused.
|
|
340
342
|
|
|
@@ -354,25 +356,45 @@ or corrupt saved data remains available to download, with an explicit warning;
|
|
|
354
356
|
recovering a conflicting draft requires choosing a pending review. Storage
|
|
355
357
|
errors never count as a successful save. Nothing is written to the server or
|
|
356
358
|
input package merely by restoring a page. Browser storage is convenient local
|
|
357
|
-
recovery, not a durable backup;
|
|
359
|
+
recovery, not a durable backup; submit the review (and retrieve it with
|
|
360
|
+
`rig-review feedback`) when it must be kept.
|
|
358
361
|
Control capabilities are excluded from links and browser persistence.
|
|
359
362
|
|
|
360
363
|
## Workspace settings and panels
|
|
361
364
|
|
|
362
365
|
The review page has a left column (Parts explorer, with checkpoints and the
|
|
363
366
|
reference select folded above it), the stage, the Inspector below it with
|
|
364
|
-
**Setup
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
367
|
+
**Setup** and **Animate** sections, and a status footer. The compare page
|
|
368
|
+
keeps sources on the left and the Parts explorer on the right. The page
|
|
369
|
+
header carries the **Setup** / **Animate** mode switch next to the package
|
|
370
|
+
title; it is the only way to change the Inspector's section. Setup is the
|
|
371
|
+
mesh-review composition (selected part, part presets, modeling-review
|
|
372
|
+
proposals) and holds nothing that seeks or plays; the angle inspection, the
|
|
373
|
+
transport and frame navigation are in Animate. **Write review** at the top
|
|
374
|
+
right opens the review dialog, a modal holding the review status, reviewed
|
|
375
|
+
source, selected checkpoint, structured feedback and frame-strip export, with
|
|
376
|
+
its own **Submit review**. Escape or Close dismisses it and returns focus to
|
|
377
|
+
**Write review**; the draft keeps saving whether the dialog is open or not,
|
|
378
|
+
and the footer and the dialog both show its state.
|
|
379
|
+
The left column's header (Parts title and search) stays fixed at the top;
|
|
380
|
+
the checkpoint strip, the part list and Settings scroll beneath it as one
|
|
381
|
+
body. A drawer keeps its title and close button in place while its body
|
|
382
|
+
scrolls. The per-inspection **Repeat idle** toggle sits in the Animate
|
|
383
|
+
transport row.
|
|
384
|
+
|
|
385
|
+
The view bar is one row and keeps the layer toggles in reach in every section: **Images**
|
|
368
386
|
(`I`), **Mesh edges** (`M`) and **Bone points** (`B`). The keys do nothing while
|
|
369
387
|
a text field, textarea, select or editable text has focus, with Ctrl/Alt/Cmd
|
|
370
388
|
held, or on key repeat; Space stays pan and Enter stays frame entry. A disabled
|
|
371
389
|
toggle shows why as a tooltip, and the footer shows the same layer status. The
|
|
372
390
|
footer also gathers the message, player, layout, renderer, idle, remote control
|
|
373
|
-
and draft state; **View region** opens the numeric view.
|
|
391
|
+
and draft state; **View region** opens the numeric view. Side by side,
|
|
392
|
+
Reference and Rig are in the **View** menu at the right end of the view bar,
|
|
393
|
+
labelled with the current choice.
|
|
374
394
|
|
|
375
|
-
The side and bottom panels are resizable.
|
|
395
|
+
The side and bottom panels are resizable. The separators are thin (4px)
|
|
396
|
+
lines whose colour is never the scrollbar's; the grab area is wider than the
|
|
397
|
+
line. Drag a separator, or focus it
|
|
376
398
|
and use the arrow keys (Shift for larger steps) and Home/End. Escape, a
|
|
377
399
|
cancelled pointer or window blur during a drag returns the previous size. The
|
|
378
400
|
stage keeps a usable minimum. When the window is too narrow, the side columns
|
|
@@ -380,9 +402,11 @@ stage keeps a usable minimum. When the window is too narrow, the side columns
|
|
|
380
402
|
drawers opened from buttons above the stage instead of shrinking to nothing. Responsive clamping
|
|
381
403
|
never overwrites the desired size, so a wider window restores it.
|
|
382
404
|
|
|
383
|
-
Settings, at the
|
|
405
|
+
Settings, at the end of the left column's scrolling body, holds browser defaults: Theme, Sync cameras by
|
|
384
406
|
default, Repeat idle by default, and Reset layout. Defaults seed a new
|
|
385
|
-
inspection only.
|
|
407
|
+
inspection only. Theme is the page's only appearance control: it shows the
|
|
408
|
+
current inspection's appearance, and changing it applies it and saves it as the
|
|
409
|
+
default. A view link or source draft keeps its own appearance, camera
|
|
386
410
|
sync and idle choice, and restoring it neither changes the saved defaults nor
|
|
387
411
|
starts playback. Reset layout restores panel sizes and nothing else.
|
|
388
412
|
|
|
@@ -413,6 +437,16 @@ Source-bound links/drafts and explicit feedback preserve that side/slot plus exa
|
|
|
413
437
|
|
|
414
438
|
This explorer is inspection. Producer-backed optimization and candidate Apply/undo/export remain separate work; the explorer does not generate mesh, rig or motion bytes.
|
|
415
439
|
|
|
440
|
+
## Chest regions (designation only)
|
|
441
|
+
|
|
442
|
+
Setup's **Chest regions** tool records two chest deformation regions in the public generator's `family-chest-regions/1` schema: per region a name, a bone chosen from the loaded model's actual bones, a core centre and semi-axes, a spacing, a linear band and an optional medial clip x, plus the contour settings (tolerance, margin, spacing, max edge, stray, bridges). Values are crop pixels, x screen-right and y down. The fields are numeric only; there are no drag handles.
|
|
443
|
+
|
|
444
|
+
Each core is drawn on the rig pane as the generator's 32-sample polygon on its 1/256-pixel grid. The overlay needs the loaded model to fix the crop: its declared stage must be the crop frame with the root at its bottom centre (`x = -width/2`, `y = 0`) and the drawn root bone must sit at the world origin, so crop `(x, y)` is world `(x - width/2, height - y)`. Otherwise the tool shows `CHEST_REGION_MAPPING_UNAVAILABLE: <what is missing>`, draws nothing and still exports from a crop size you enter.
|
|
445
|
+
|
|
446
|
+
**Download chest-regions.json** saves two files in the browser: `chest-regions.json`, formatted exactly as the public example files, and `chest-regions.binding.json`, which names the package source hash, the rig and motion SHA-256 values, the export time, the viewer and runtime versions and the mapping used. Export refuses `CHEST_REGION_NAME_DUPLICATE`, `CHEST_REGION_BONE_UNKNOWN <bone>`, `CHEST_REGION_OUT_OF_CROP <name>`, `CHEST_REGION_OFF_GRID` and `CHEST_REGIONS_OVERLAP <a> <b>`. The overlap test is geometric: two regions are refused when the distance between their polygons is less than the sum of their bands. The generator refuses per mesh vertex, so this check can refuse a pair it would accept, never the reverse.
|
|
447
|
+
|
|
448
|
+
Field values stay in the inspection's local draft. They never enter the review session's feedback, nothing is written into the package, and an export is review evidence, not an approval. Regenerating a mesh from these regions is the producer's job (see `docs/DESIGN.md`).
|
|
449
|
+
|
|
416
450
|
## License
|
|
417
451
|
|
|
418
452
|
MIT. See [LICENSE](LICENSE).
|
package/docs/DESIGN.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
The delivery unit is an existing output folder or an optional curated review package handed from an agent to a person. This reusable app owns inspection, reference comparison, annotations and feedback export. Upstream compiler, splitter and runtime remain their respective tools. The existing producing agent needs no change to open its output here.
|
|
6
6
|
|
|
7
|
-
The person uses one inspection workspace with **Setup
|
|
7
|
+
The person uses one inspection workspace with **Setup** and **Animate** sections and a review dialog for feedback (the compare page keeps **View**, **Rig**, **Playback** and **Feedback** sections). Setup covers setup geometry, source art, pivot/joint placement, meshes, weights and draw order; Animate covers angle coverage, frame intervals, reference video/poses, contact/deformation and audio inspection. Motion is strictly **view, annotate and export**: time/angle controls navigate inspection. This app does not author animation keys, retime motion or write `motion.json`. Future modeling touch-up remains in scope as a design goal; it is not implemented by this starter.
|
|
8
8
|
|
|
9
9
|
## Implemented foundation
|
|
10
10
|
|
|
@@ -38,12 +38,15 @@ Any future modeling edit needs provenance, undo/redo, explicit units and an expo
|
|
|
38
38
|
|
|
39
39
|
The single review page is one inspection workspace: a left column, the stage, the Inspector under the stage and one status footer. There is no right panel.
|
|
40
40
|
|
|
41
|
-
- **Left column.**
|
|
42
|
-
- **
|
|
43
|
-
- **
|
|
41
|
+
- **Left column.** A fixed header row (Parts title and slot search) and one scrolling body. Nothing sits above the header and it never scrolls. The body holds, in order, the checkpoints/reference strip (current checkpoint title, checkpoints and the reference select, folded into a compact strip that opens when the package supplies checkpoints or references), the part list and browser Settings; it is the column's only scroll container.
|
|
42
|
+
- **Header.** The **Setup** / **Animate** mode switch sits next to the package title and is the Inspector's only section control: its buttons carry `data-inspector` and `aria-pressed`, and every section change (click, `showInspector` from a part selection or a tool, a restored bookmark) goes through them. On the right, **Write review** (primary) opens the review dialog. The top bar has no feedback export buttons; `rig-review feedback --format json|issue` is the export path.
|
|
43
|
+
- **View bar.** One row: the persistent image / mesh edges / bone points layer toggles (keys `I`, `M`, `B`), camera sync and zoom, then the **View** menu (a `<details>` holding Side by side, Reference and Rig, labelled with the effective choice) at the right end. The toggles stay native checkboxes, so their checked state is the pressed state, the draft input and the `layers` control value. A disabled toggle names the footer's layer reason as its tooltip. Turning images off leaves the mesh/bone overlay alone. Appearance lives only in Settings (its Theme select shows the inspection's current appearance and drives it), the per-inspection Repeat idle toggle lives in the Animate transport row, and there is no Copy view link button: the persisted `#review=` fragment stays in the address bar and the numeric region stays behind **View region**.
|
|
44
|
+
- **Panel headers and scrolling.** The left column's header is a fixed row outside its scrolling body. The Inspector heading, the Settings title and each drawer's header (title and close) carry `.sticky-header` and are their panel's first child, so the body scrolls beneath them. Every scrolling region shares one thin scrollbar rule on `*` whose colours are the `--rv-scrollbar-*` tokens. Resize separators are drawn 4px with a 16px transparent hit area (22px on coarse pointers) and use `--rv-separator` / `--rv-separator-hover`, never a scrollbar colour.
|
|
45
|
+
- **Inspector.** Two sections and no tab row: **Setup** is the mesh-review composition (selected part, part presets, the modeling-review proposals for mesh density, contour curvature and chest position) and holds no control that seeks or plays; **Animate** has the transport with the Repeat idle toggle, frame navigation, the motion-review proposals and angle inspection with its axis panel, sound cues and report events. Their element ids and saved section names (`rig`, `playback`) are unchanged. A saved `view` or `feedback` section from older links and drafts restores without selecting a section or opening the dialog.
|
|
46
|
+
- **Review dialog.** A native modal `<dialog id="review-dialog">` holds the former Feedback section unchanged (review status, reviewed source with the read-only source-file list, selected checkpoint, structured feedback, frame-strip export; every element id kept) and the **Submit review** button (`#save`). **Write review**, a part's or a proposal tool's Add feedback opens it with `showModal()` and pauses playback; Escape or Close dismisses it and focus returns to **Write review**. Its inputs stay in the document while it is closed, so the draft mechanism (autosave, recovery, conflict handling) is unchanged and closing loses nothing. The modal makes the rest of the page inert, so the dialog repeats the footer's message and draft state in its own status line; the footer keeps reporting both.
|
|
44
47
|
- **Footer.** Message (truncated, full text as tooltip) on the left; player, effective layout, renderer, layers, idle, remote control and draft state in the middle; the numeric view region (behind **View region**) and the source hash on the right.
|
|
45
48
|
|
|
46
|
-
The compare page keeps its own markup and stylesheet: sources left, Parts explorer right
|
|
49
|
+
The compare page keeps its own markup and stylesheet: sources left, Parts explorer right, its four Inspector sections, its export buttons and its separator colour. It shares the workspace coordinator, so its separators are drawn at the same 4px.
|
|
47
50
|
|
|
48
51
|
## Inspection strips
|
|
49
52
|
|
|
@@ -86,8 +89,9 @@ Rules:
|
|
|
86
89
|
- Every colour token is defined for both schemes: `:root` holds dark (the default before any script runs) and `:root[data-theme=light]` redefines the same set. The appearance control resolves `system` through `prefers-color-scheme` and always writes `data-theme=dark|light` on the root element, so the stylesheets carry no per-component light overrides.
|
|
87
90
|
- State colours: pressed and current selections use the `--pass` family, primary actions and links `--link`, dirty and draft notices `--warn`, errors `--fail`.
|
|
88
91
|
- The stage (pane gradient, grid dots, pane labels, empty-state text, video letterbox) stays dark in both schemes so source pixels and overlays read the same; its tokens carry equal values in both blocks.
|
|
92
|
+
- Resize separators and scrollbars never share a colour: `--rv-separator` and `--rv-separator-hover` resolve to values different from both `--rv-scrollbar-*` colours in each scheme (the review page; the compare stylesheet keeps its own separator colour).
|
|
89
93
|
|
|
90
|
-
`tests/theme.test.ts` enforces the first two rules.
|
|
94
|
+
`tests/theme.test.ts` enforces the first two rules and the separator/scrollbar rule.
|
|
91
95
|
|
|
92
96
|
## Comparison eligibility
|
|
93
97
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chest-region designation (`family-chest-regions/1`): pure geometry, validation and the exact JSON text the
|
|
3
|
+
* public generator's example files use. No DOM, no filesystem. Review designates and exports these values as
|
|
4
|
+
* evidence; regenerating a mesh from them is the producer path, never this module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The generator's coordinate grid: every point is a multiple of 1/256 crop pixel. */
|
|
8
|
+
export const CHEST_REGION_GRID = 256;
|
|
9
|
+
export const CHEST_REGIONS_SPEC = 'family-chest-regions/1';
|
|
10
|
+
|
|
11
|
+
export type CropPoint = [number, number];
|
|
12
|
+
export type ChestSegment = string | [string, [number, number], [number, number]];
|
|
13
|
+
export interface ChestRegion { name: string; bone: string; shape: 'polygon'; points: CropPoint[]; spacing: number; band: number }
|
|
14
|
+
export interface ChestContour { tolerance: number; margin: number; spacing: number; maxEdge: number; stray: number; bridges: boolean; regions: ChestRegion[] }
|
|
15
|
+
export interface ChestMesh { contour: ChestContour; r?: number; segments?: ChestSegment[] }
|
|
16
|
+
export interface ChestRegionsDocument {
|
|
17
|
+
spec: typeof CHEST_REGIONS_SPEC;
|
|
18
|
+
coordinates: string;
|
|
19
|
+
publicGenerator: string;
|
|
20
|
+
mesh: { name: string; value: ChestMesh };
|
|
21
|
+
sourceRule?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ChestRegionInput {
|
|
25
|
+
name: string;
|
|
26
|
+
bone: string;
|
|
27
|
+
center: [number, number];
|
|
28
|
+
semiAxes: [number, number];
|
|
29
|
+
spacing: number;
|
|
30
|
+
band: number;
|
|
31
|
+
/** Medial clip line x: the polygon side toward this x stops at it (x ≤ clip when clip > centre x, x ≥ clip when clip < centre x). */
|
|
32
|
+
clip?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface ChestRegionsInput {
|
|
35
|
+
crop: { w: number; h: number };
|
|
36
|
+
generator: string;
|
|
37
|
+
mesh?: string;
|
|
38
|
+
contour: Omit<ChestContour, 'regions'>;
|
|
39
|
+
regions: ChestRegionInput[];
|
|
40
|
+
r?: number;
|
|
41
|
+
segments?: unknown;
|
|
42
|
+
sourceRule?: string;
|
|
43
|
+
/** The loaded model's bone names; when present every region bone must be one of them. */
|
|
44
|
+
bones?: readonly string[] | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ChestRegionError extends Error {
|
|
48
|
+
constructor(readonly code: string, detail: string) { super(`${code} ${detail}`.trim()); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const quantize = (value: number): number => { const q = Math.round(value * CHEST_REGION_GRID) / CHEST_REGION_GRID; return q === 0 ? 0 : q; };
|
|
52
|
+
const onGrid = (value: number): boolean => Number.isInteger(value * CHEST_REGION_GRID);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The 32-sample ellipse the examples encode: sample i at angle θ = 2πi/samples, starting at +x (screen-right)
|
|
56
|
+
* and turning toward +y (down, crop pixels), each coordinate rounded to the nearest 1/256 pixel. A clip x
|
|
57
|
+
* (on the grid) then stops the side that faces it.
|
|
58
|
+
*/
|
|
59
|
+
export function ellipsePolygon(center: [number, number], semiAxes: [number, number], samples = 32, clip?: number): CropPoint[] {
|
|
60
|
+
const [cx, cy] = center, [ax, ay] = semiAxes, points: CropPoint[] = [];
|
|
61
|
+
for (let i = 0; i < samples; i++) {
|
|
62
|
+
const angle = 2 * Math.PI * i / samples;
|
|
63
|
+
let x = quantize(cx + ax * Math.cos(angle));
|
|
64
|
+
const y = quantize(cy + ay * Math.sin(angle));
|
|
65
|
+
if (clip !== undefined) x = clip > cx ? Math.min(x, clip) : Math.max(x, clip);
|
|
66
|
+
points.push([x, y]);
|
|
67
|
+
}
|
|
68
|
+
return points;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function finite(value: unknown, region: string, field: string, min: number, inclusive = true): number {
|
|
72
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || (inclusive ? value < min : value <= min)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `${region} ${field}`);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pointSegmentDistance(p: CropPoint, a: CropPoint, b: CropPoint): number {
|
|
77
|
+
const dx = b[0] - a[0], dy = b[1] - a[1], length = dx * dx + dy * dy;
|
|
78
|
+
const t = length === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length));
|
|
79
|
+
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
|
80
|
+
}
|
|
81
|
+
const cross = (o: CropPoint, a: CropPoint, b: CropPoint) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
82
|
+
function segmentsCross(a: CropPoint, b: CropPoint, c: CropPoint, d: CropPoint): boolean {
|
|
83
|
+
const d1 = cross(c, d, a), d2 = cross(c, d, b), d3 = cross(a, b, c), d4 = cross(a, b, d);
|
|
84
|
+
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
|
|
85
|
+
}
|
|
86
|
+
function inside(p: CropPoint, polygon: CropPoint[]): boolean {
|
|
87
|
+
let hit = false;
|
|
88
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
89
|
+
const [xi, yi] = polygon[i]!, [xj, yj] = polygon[j]!;
|
|
90
|
+
if ((yi > p[1]) !== (yj > p[1]) && p[0] < (xj - xi) * (p[1] - yi) / (yj - yi) + xi) hit = !hit;
|
|
91
|
+
}
|
|
92
|
+
return hit;
|
|
93
|
+
}
|
|
94
|
+
/** Distance between two closed polygons; 0 when their edges cross or one contains the other. */
|
|
95
|
+
export function polygonDistance(a: CropPoint[], b: CropPoint[]): number {
|
|
96
|
+
if (inside(a[0]!, b) || inside(b[0]!, a)) return 0;
|
|
97
|
+
let best = Infinity;
|
|
98
|
+
for (let i = 0; i < a.length; i++) {
|
|
99
|
+
const a0 = a[i]!, a1 = a[(i + 1) % a.length]!;
|
|
100
|
+
for (let j = 0; j < b.length; j++) {
|
|
101
|
+
const b0 = b[j]!, b1 = b[(j + 1) % b.length]!;
|
|
102
|
+
if (segmentsCross(a0, a1, b0, b1)) return 0;
|
|
103
|
+
best = Math.min(best, pointSegmentDistance(a0, b0, b1), pointSegmentDistance(a1, b0, b1), pointSegmentDistance(b0, a0, a1), pointSegmentDistance(b1, a0, a1));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return best;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Review's pre-check for the generator's no-overlap rule: two regions are refused when their supports (polygon
|
|
111
|
+
* grown by its band) overlap, i.e. polygon distance < bandA + bandB. The generator itself refuses per mesh
|
|
112
|
+
* vertex (`RIG_CONTOUR_REGIONS_OVERLAP`, a vertex weighted by two falloffs); without a mesh this geometric test
|
|
113
|
+
* is the stricter side of that rule: it can refuse a pair the generator would accept, never the reverse.
|
|
114
|
+
*/
|
|
115
|
+
export function regionsOverlap(a: { points: CropPoint[]; band: number }, b: { points: CropPoint[]; band: number }): boolean {
|
|
116
|
+
return polygonDistance(a.points, b.points) < a.band + b.band;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function coordinatesText(crop: { w: number; h: number }, regions: ChestRegionInput[]): string {
|
|
120
|
+
const [left, right] = [...regions].sort((a, b) => a.center[0] - b.center[0]);
|
|
121
|
+
return `${crop.w}x${crop.h} crop pixels, x screen-right and y down; ${left!.bone} screen-left, ${right!.bone} screen-right`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function segmentList(value: unknown): ChestSegment[] {
|
|
125
|
+
if (!Array.isArray(value)) throw new ChestRegionError('CHEST_SEGMENTS_INVALID', 'expected an array');
|
|
126
|
+
const point = (v: unknown): v is [number, number] => Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number' && Number.isFinite(n));
|
|
127
|
+
return value.map((item: unknown, i) => {
|
|
128
|
+
if (typeof item === 'string' && item) return item;
|
|
129
|
+
if (Array.isArray(item) && item.length === 3 && typeof item[0] === 'string' && item[0] && point(item[1]) && point(item[2])) return [item[0], [item[1][0], item[1][1]], [item[2][0], item[2][1]]] as ChestSegment;
|
|
130
|
+
throw new ChestRegionError('CHEST_SEGMENTS_INVALID', `segments[${i}]`);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function buildChestRegions(input: ChestRegionsInput): ChestRegionsDocument {
|
|
135
|
+
const w = finite(input.crop?.w, 'crop', 'w', 0, false), h = finite(input.crop?.h, 'crop', 'h', 0, false);
|
|
136
|
+
if (typeof input.generator !== 'string' || !input.generator.trim()) throw new ChestRegionError('CHEST_REGIONS_GENERATOR_MISSING', '');
|
|
137
|
+
if (input.regions.length !== 2) throw new ChestRegionError('CHEST_REGIONS_COUNT', String(input.regions.length));
|
|
138
|
+
const c = input.contour;
|
|
139
|
+
for (const field of ['tolerance', 'margin', 'spacing', 'maxEdge', 'stray'] as const) finite(c[field], 'contour', field, 0);
|
|
140
|
+
if (typeof c.bridges !== 'boolean') throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', 'contour bridges');
|
|
141
|
+
const names = new Set<string>(), regions: ChestRegion[] = [];
|
|
142
|
+
for (const region of input.regions) {
|
|
143
|
+
const name = region.name;
|
|
144
|
+
if (typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,64}$/.test(name)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `${String(name)} name`);
|
|
145
|
+
if (names.has(name)) throw new ChestRegionError('CHEST_REGION_NAME_DUPLICATE', name);
|
|
146
|
+
names.add(name);
|
|
147
|
+
if (typeof region.bone !== 'string' || !region.bone) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `${name} bone`);
|
|
148
|
+
if (input.bones && !input.bones.includes(region.bone)) throw new ChestRegionError('CHEST_REGION_BONE_UNKNOWN', region.bone);
|
|
149
|
+
const cx = finite(region.center?.[0], name, 'center x', -Infinity), cy = finite(region.center?.[1], name, 'center y', -Infinity);
|
|
150
|
+
const ax = finite(region.semiAxes?.[0], name, 'semi-axis x', 0, false), ay = finite(region.semiAxes?.[1], name, 'semi-axis y', 0, false);
|
|
151
|
+
const spacing = finite(region.spacing, name, 'spacing', 0, false), band = finite(region.band, name, 'band', 0);
|
|
152
|
+
if (region.clip !== undefined) finite(region.clip, name, 'clip', -Infinity);
|
|
153
|
+
for (const [field, value] of [['center x', cx], ['center y', cy], ['band', band], ...(region.clip === undefined ? [] : [['clip', region.clip]])] as [string, number][]) {
|
|
154
|
+
if (!onGrid(value)) throw new ChestRegionError('CHEST_REGION_OFF_GRID', `${name} ${field}`);
|
|
155
|
+
}
|
|
156
|
+
const points = ellipsePolygon([cx, cy], [ax, ay], 32, region.clip);
|
|
157
|
+
if (points.some(([x, y]) => x < 0 || y < 0 || x > w || y > h)) throw new ChestRegionError('CHEST_REGION_OUT_OF_CROP', name);
|
|
158
|
+
regions.push({ name, bone: region.bone, shape: 'polygon', points, spacing, band });
|
|
159
|
+
}
|
|
160
|
+
if (regionsOverlap(regions[0]!, regions[1]!)) throw new ChestRegionError('CHEST_REGIONS_OVERLAP', `${regions[0]!.name} ${regions[1]!.name}`);
|
|
161
|
+
const mesh: ChestMesh = { contour: { tolerance: c.tolerance, margin: c.margin, spacing: c.spacing, maxEdge: c.maxEdge, stray: c.stray, bridges: c.bridges, regions } };
|
|
162
|
+
if (input.r !== undefined) mesh.r = finite(input.r, 'mesh', 'r', 0);
|
|
163
|
+
if (input.segments !== undefined) mesh.segments = segmentList(input.segments);
|
|
164
|
+
const meshName = input.mesh ?? 'topwear';
|
|
165
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(meshName)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', 'mesh name');
|
|
166
|
+
return { spec: CHEST_REGIONS_SPEC, coordinates: coordinatesText({ w, h }, input.regions), publicGenerator: input.generator, mesh: { name: meshName, value: mesh }, ...(input.sourceRule ? { sourceRule: input.sourceRule } : {}) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Python-style JSON text (`json.dumps(value, indent=2)` + newline), the examples' exact formatting; `{float}` marks a Python float. */
|
|
170
|
+
type Emit = { float: number } | number | string | boolean | Emit[] | { [key: string]: Emit };
|
|
171
|
+
const isFloat = (v: unknown): v is { float: number } => !!v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 1 && 'float' in v;
|
|
172
|
+
function numberText(value: number, float: boolean): string {
|
|
173
|
+
if (!Number.isFinite(value)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', 'non-finite number');
|
|
174
|
+
const v = value === 0 ? 0 : value, text = String(v);
|
|
175
|
+
if (/e/i.test(text)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `number ${text} needs exponent notation`);
|
|
176
|
+
return float && Number.isInteger(v) ? `${text}.0` : text;
|
|
177
|
+
}
|
|
178
|
+
const stringText = (value: string) => JSON.stringify(value).replace(/[\u007f-]/g, ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
179
|
+
function emit(value: Emit, depth: number): string {
|
|
180
|
+
const pad = ' '.repeat(depth + 1), close = ' '.repeat(depth);
|
|
181
|
+
if (isFloat(value)) return numberText(value.float, true);
|
|
182
|
+
if (typeof value === 'number') return numberText(value, false);
|
|
183
|
+
if (typeof value === 'string') return stringText(value);
|
|
184
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
185
|
+
if (Array.isArray(value)) return value.length ? `[\n${value.map(v => pad + emit(v, depth + 1)).join(',\n')}\n${close}]` : '[]';
|
|
186
|
+
const entries = Object.entries(value);
|
|
187
|
+
return entries.length ? `{\n${entries.map(([k, v]) => `${pad}${stringText(k)}: ${emit(v, depth + 1)}`).join(',\n')}\n${close}}` : '{}';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function serializeChestRegions(doc: ChestRegionsDocument): string {
|
|
191
|
+
const mesh = doc.mesh.value, contour = mesh.contour;
|
|
192
|
+
const value: { [key: string]: Emit } = {
|
|
193
|
+
spec: doc.spec,
|
|
194
|
+
coordinates: doc.coordinates,
|
|
195
|
+
publicGenerator: doc.publicGenerator,
|
|
196
|
+
[`meshes.${doc.mesh.name}`]: {
|
|
197
|
+
contour: {
|
|
198
|
+
tolerance: contour.tolerance, margin: contour.margin, spacing: contour.spacing, maxEdge: contour.maxEdge, stray: contour.stray, bridges: contour.bridges,
|
|
199
|
+
regions: contour.regions.map(r => ({ name: r.name, bone: r.bone, shape: r.shape, points: r.points.map(([x, y]) => [{ float: x }, { float: y }]), spacing: r.spacing, band: r.band })),
|
|
200
|
+
},
|
|
201
|
+
...(mesh.r === undefined ? {} : { r: mesh.r }),
|
|
202
|
+
...(mesh.segments === undefined ? {} : { segments: mesh.segments as Emit[] }),
|
|
203
|
+
},
|
|
204
|
+
...(doc.sourceRule === undefined ? {} : { sourceRule: doc.sourceRule }),
|
|
205
|
+
};
|
|
206
|
+
return emit(value, 0) + '\n';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Crop → rig world mapping, only when the loaded model fixes it. */
|
|
210
|
+
export type CropWorldMapping =
|
|
211
|
+
| { available: true; crop: { w: number; h: number }; toWorld: (x: number, y: number) => { x: number; y: number }; basis: string }
|
|
212
|
+
| { available: false; reason: string };
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The rig-parts crop convention is world = (x − W/2, H − y): root at the crop's bottom centre, world y up. A
|
|
216
|
+
* model fixes W×H only when its declared `stage` is exactly that frame (x = −width/2, y = 0) and the root bone's
|
|
217
|
+
* drawn world position is the origin. Anything else is reported as missing; no frame is assumed.
|
|
218
|
+
*/
|
|
219
|
+
export function cropWorldMapping(stage: { x: number; y: number; width: number; height: number } | null | undefined, root: { x: number; y: number } | null): CropWorldMapping {
|
|
220
|
+
const missing = (what: string): CropWorldMapping => ({ available: false, reason: `CHEST_REGION_MAPPING_UNAVAILABLE: ${what}` });
|
|
221
|
+
if (!stage) return missing('the model declares no stage');
|
|
222
|
+
if (![stage.x, stage.y, stage.width, stage.height].every(Number.isFinite) || stage.width <= 0 || stage.height <= 0) return missing('the model stage is not a positive finite box');
|
|
223
|
+
if (stage.x !== -stage.width / 2 || stage.y !== 0) return missing(`the model stage (${stage.x}, ${stage.y}, ${stage.width}×${stage.height}) is not a crop frame with the root at its bottom centre`);
|
|
224
|
+
if (!root) return missing('no drawn root bone position');
|
|
225
|
+
if (Math.abs(root.x) > 1e-6 || Math.abs(root.y) > 1e-6) return missing(`the drawn root bone is at (${root.x}, ${root.y}), not the world origin`);
|
|
226
|
+
const { x: ox, y: oy, width, height } = stage;
|
|
227
|
+
return { available: true, crop: { w: width, h: height }, toWorld: (x, y) => ({ x: ox + x, y: oy + height - y }), basis: `model stage ${width}×${height} at (${ox}, ${oy}), root at world origin: world = (x − ${width / 2}, ${height} − y)` };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface ChestRegionsBinding {
|
|
231
|
+
schema: 'rig-review-chest-regions-binding/1';
|
|
232
|
+
file: { name: 'chest-regions.json'; sha256: string };
|
|
233
|
+
package: string;
|
|
234
|
+
model: string | null;
|
|
235
|
+
motion: string | null;
|
|
236
|
+
exportedAt: string;
|
|
237
|
+
tools: { viewerVersion: string; runtimeVersion: string };
|
|
238
|
+
mapping: string;
|
|
239
|
+
evidence: string;
|
|
240
|
+
}
|
|
241
|
+
export function chestRegionsBinding(input: { fileSha256: string; sourceHash: string; reviewedSource: { viewerVersion: string; runtimeVersion: string; rig: { sha256: string } | null; motion: { sha256: string } | null }; exportedAt: string; mapping: string }): ChestRegionsBinding {
|
|
242
|
+
return {
|
|
243
|
+
schema: 'rig-review-chest-regions-binding/1',
|
|
244
|
+
file: { name: 'chest-regions.json', sha256: input.fileSha256 },
|
|
245
|
+
package: input.sourceHash,
|
|
246
|
+
model: input.reviewedSource.rig?.sha256 ?? null,
|
|
247
|
+
motion: input.reviewedSource.motion?.sha256 ?? null,
|
|
248
|
+
exportedAt: input.exportedAt,
|
|
249
|
+
tools: { viewerVersion: input.reviewedSource.viewerVersion, runtimeVersion: input.reviewedSource.runtimeVersion },
|
|
250
|
+
mapping: input.mapping,
|
|
251
|
+
evidence: 'Designation is review evidence, not an approval. Nothing was written into the package; regeneration is the producer path.',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Setup's Chest regions tool: form fields → designation input, without DOM access (main.ts owns the elements). */
|
|
2
|
+
import {buildChestRegions,ChestRegionError,serializeChestRegions,type ChestRegionsDocument,type ChestRegionsInput,type CropWorldMapping} from '../src/chest-regions.ts';
|
|
3
|
+
|
|
4
|
+
export const chestRegionRows=[0,1] as const;
|
|
5
|
+
export const chestRegionFields=['name','bone','cx','cy','ax','ay','spacing','band','clip'] as const;
|
|
6
|
+
export const chestContourFields=[['tolerance','chest-tolerance'],['margin','chest-margin'],['spacing','chest-spacing'],['maxEdge','chest-max-edge'],['stray','chest-stray']] as const;
|
|
7
|
+
export const chestRowId=(row:number,field:typeof chestRegionFields[number])=>`chest-${row}-${field}`;
|
|
8
|
+
/** Every input id the tool owns; the inspection draft captures them like any other form input. */
|
|
9
|
+
export const chestInputIds=[
|
|
10
|
+
'chest-crop-w','chest-crop-h','chest-generator','chest-source-rule','chest-r','chest-segments','chest-bridges',
|
|
11
|
+
...chestContourFields.map(([,id])=>id),
|
|
12
|
+
...chestRegionRows.flatMap(row=>chestRegionFields.map(field=>chestRowId(row,field))),
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export interface ChestFieldValue {value:string;checked?:boolean}
|
|
16
|
+
export type ChestFieldReader=(id:string)=>ChestFieldValue|undefined;
|
|
17
|
+
|
|
18
|
+
const invalid=(what:string)=>new ChestRegionError('CHEST_REGION_VALUE_INVALID',what);
|
|
19
|
+
function numberField(read:ChestFieldReader,id:string,what:string,optional=false):number|undefined {
|
|
20
|
+
const raw=(read(id)?.value??'').trim();
|
|
21
|
+
if(!raw){if(optional)return undefined;throw invalid(what);}
|
|
22
|
+
const value=Number(raw);if(!Number.isFinite(value))throw invalid(what);return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Reads the tool's fields; crop comes from the model mapping when it exists, else from the crop fields. */
|
|
26
|
+
export function readChestForm(read:ChestFieldReader,mapping:CropWorldMapping,bones:readonly string[]|null):ChestRegionsInput {
|
|
27
|
+
const crop=mapping.available?mapping.crop:{w:numberField(read,'chest-crop-w','crop w')!,h:numberField(read,'chest-crop-h','crop h')!};
|
|
28
|
+
const contour={tolerance:0,margin:0,spacing:0,maxEdge:0,stray:0,bridges:read('chest-bridges')?.checked===true};
|
|
29
|
+
for(const [key,id] of chestContourFields)contour[key]=numberField(read,id,`contour ${key}`)!;
|
|
30
|
+
const regions=chestRegionRows.map(row=>{
|
|
31
|
+
const name=(read(chestRowId(row,'name'))?.value??'').trim()||`row ${row+1}`,num=(field:typeof chestRegionFields[number],optional=false)=>numberField(read,chestRowId(row,field),`${name} ${field}`,optional);
|
|
32
|
+
const clip=num('clip',true);
|
|
33
|
+
return {name:(read(chestRowId(row,'name'))?.value??'').trim(),bone:read(chestRowId(row,'bone'))?.value??'',center:[num('cx')!,num('cy')!] as [number,number],semiAxes:[num('ax')!,num('ay')!] as [number,number],spacing:num('spacing')!,band:num('band')!,...(clip===undefined?{}:{clip})};
|
|
34
|
+
});
|
|
35
|
+
const segmentsText=(read('chest-segments')?.value??'').trim();let segments:unknown;
|
|
36
|
+
if(segmentsText){try{segments=JSON.parse(segmentsText);}catch{throw new ChestRegionError('CHEST_SEGMENTS_INVALID','not JSON');}}
|
|
37
|
+
const r=numberField(read,'chest-r','mesh r',true),sourceRule=(read('chest-source-rule')?.value??'').trim();
|
|
38
|
+
return {crop,generator:(read('chest-generator')?.value??'').trim(),contour,regions,...(r===undefined?{}:{r}),...(segments===undefined?{}:{segments}),...(sourceRule?{sourceRule}:{}),bones};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ChestEvaluation={ok:true;document:ChestRegionsDocument;text:string}|{ok:false;error:string};
|
|
42
|
+
export function evaluateChestForm(read:ChestFieldReader,mapping:CropWorldMapping,bones:readonly string[]|null):ChestEvaluation {
|
|
43
|
+
try{const document=buildChestRegions(readChestForm(read,mapping,bones));return {ok:true,document,text:serializeChestRegions(document)};}
|
|
44
|
+
catch(error){if(error instanceof ChestRegionError)return {ok:false,error:error.message};throw error;}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Polygons in rig world coordinates for the overlay; none without a model-fixed mapping. */
|
|
48
|
+
export function chestOverlayPolygons(evaluation:ChestEvaluation,mapping:CropWorldMapping):{name:string;points:{x:number;y:number}[]}[] {
|
|
49
|
+
if(!evaluation.ok||!mapping.available)return [];
|
|
50
|
+
return evaluation.document.mesh.value.contour.regions.map(region=>({name:region.name,points:region.points.map(([x,y])=>mapping.toWorld(x,y))}));
|
|
51
|
+
}
|
package/web/compare.css
CHANGED
|
@@ -114,6 +114,7 @@ body{display:grid;grid-template-rows:auto minmax(0,1fr) auto}.layout{grid-templa
|
|
|
114
114
|
.workspace-drawer:not([open]){display:none}.workspace-drawer[open]{display:flex;flex-direction:column;gap:8px}
|
|
115
115
|
.workspace-drawer::backdrop{background:var(--rv-backdrop)}
|
|
116
116
|
.workspace-drawer-close{align-self:flex-end;flex:none}
|
|
117
|
+
.workspace-drawer-header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex:none}
|
|
117
118
|
.workspace-drawer>.left-panel{display:flex!important;flex-direction:column;flex:1;overflow:hidden;min-height:0;padding:0}
|
|
118
119
|
.workspace-drawer>.parts-explorer{display:block;grid-template-columns:none;flex:1;width:auto;min-width:0;min-height:0;height:auto;overflow:auto}
|
|
119
120
|
.workspace-drawer>.inspector{flex:1;height:auto!important;min-height:0!important;max-height:none;overflow:hidden}
|