@rig-c/review 0.5.0 → 0.6.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 CHANGED
@@ -382,6 +382,22 @@ body. A drawer keeps its title and close button in place while its body
382
382
  scrolls. The per-inspection **Repeat idle** toggle sits in the Animate
383
383
  transport row.
384
384
 
385
+ Animate's **Environment** block drives the player's physics while you
386
+ inspect: a **Wind** base (−100…100), **Breeze**, a **Gust** pulse
387
+ (peak 0…200, duration 0.1…20 s, **Repeat** every max(6, duration + 0.6) s)
388
+ and **Gravity** (0…4), applied through `player.environment` in the same frame
389
+ step that draws. The gust clock is the stepped player time, so pausing freezes
390
+ it. **Jolt** sends a translate-and-return impulse through `player.moved`;
391
+ with **Drag to trail** on, dragging the rig pane moves the rig's physics
392
+ instead of the camera (Space still pans). **Reset physics** returns the
393
+ runtime start values and re-poses from the current time, clearing the physics
394
+ history. The physics constraints are listed read only as the compiled model
395
+ declares them; `@rig-c/play` exposes no per-constraint mix, so none is offered.
396
+ Until a control is touched the player stays at the runtime start values. The
397
+ state is kept in the local draft and recorded in feedback as
398
+ `context.environment`; nothing is written to the model or motion. The compare
399
+ page does not have this block.
400
+
385
401
  The view bar is one row and keeps the layer toggles in reach in every section: **Images**
386
402
  (`I`), **Mesh edges** (`M`) and **Bone points** (`B`). The keys do nothing while
387
403
  a text field, textarea, select or editable text has focus, with Ctrl/Alt/Cmd
@@ -439,13 +455,38 @@ This explorer is inspection. Producer-backed optimization and candidate Apply/un
439
455
 
440
456
  ## Chest regions (designation only)
441
457
 
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.
458
+ In **Setup**, the view bar's **Chest regions** button (pressed state; hidden in Animate) opens the chest-region editor bar directly under the view bar and enables editing on the rig pane. The bar has an **R** and an **L** group, each with **X**, **Y**, **R** (core radius, 6–120) and **Band** (4–120) in crop pixels, x screen-right and y down; **Mirror R → L** copies R's y, core radius and band to L; **Download JSON**; and **Apply · Rebuild**.
459
+
460
+ On the rig pane each region is drawn as two circles: the core (full weight) and a dashed outer circle at core + band, R in one colour and L in another (two theme tokens per side), with a centre handle, a core handle on the right edge, a band handle on the top edge and a label `R (x,y) r:… b:…` under the outer circle. Dragging the centre moves the region, dragging the core edge sets r and dragging the band handle sets the band, all in crop pixels through the current camera scale. Each drag and each committed bar value is clamped live with the public example rebuild script's non-overlap rule: integer values, both cores plus 8 px must fit the centre distance minus a 2 px margin (both shrink by one factor) and the bands share what remains. A side the rule changed flashes its label and the footer says `CHEST_REGIONS_CLAMPED`. A handle drag never moves the camera; a drag anywhere else, or with Space held, still pans, and the wheel still zooms.
461
+
462
+ **Apply · Rebuild** refuses by name, `MODELING_INPUT_UNAVAILABLE: no producer handoff — export the JSON and rebuild with the producer`, in its tooltip, the editor bar and the footer. Review never runs a generator, a rebuild script or a manifest command; regenerating the rig from these regions is the producer's job.
463
+
464
+ The editor's values are the fields of Setup's **Chest regions · detail** block (R is region 1, L is region 2), which also holds what the bar does not: per region a name, a bone chosen from the loaded model's actual bones, the two semi-axes, a spacing and an optional medial clip x, plus the contour settings (tolerance, margin, spacing, max edge, stray, bridges). Setting R in the bar writes both semi-axes. A region with equal semi-axes and no clip exports as the examples' circle, `{"name", "shape": "circle", "cx", "cy", "r", "bone", "spacing", "band"}`; unequal semi-axes or a clip export as the generator's 32-sample polygon on its 1/256-pixel grid and are drawn filled, without handles.
465
+
466
+ 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 editor bar and the detail block show `CHEST_REGION_MAPPING_UNAVAILABLE: <what is missing>`, nothing is drawn, and the values still edit numerically and export from a crop size you enter.
467
+
468
+ **Download JSON** (and the detail block's **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>`. Two circles overlap when their centre distance minus both cores is less than the sum of their bands; any other pair is tested through polygons. The generator refuses per mesh vertex, so this check can refuse a pair it would accept, never the reverse.
469
+
470
+ 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 (see `docs/DESIGN.md`).
471
+
472
+ ## Mesh edit (typed delta, preview only)
473
+
474
+ In **Setup**, the view bar's **Edit mesh** button (pressed state; hidden in Animate) edits the selected slot's actual mesh attachment. With **Whole rig** selected it refuses `MESH_EDIT_SELECT_SLOT`; a slot whose active attachment is not a mesh refuses `MESH_EDIT_NO_MESH <slot>`. Entering pauses playback, turns the chest-region editor off and opens the mesh editor bar under the view bar. The rig pane dims except the slot's own triangles; the mesh is drawn at full opacity with every vertex as a handle, hull edges, interior triangulation edges and added constraint edges each in their own theme token.
475
+
476
+ The bar has four sub-modes, one pressed at a time, with single keys that avoid the layer keys `I` / `M` / `B`:
477
+
478
+ | Key | Sub-mode | Pointer |
479
+ | --- | --- | --- |
480
+ | `V` | Modify | drag a vertex, or an edge to move both ends; Shift-click adds to the selection; drag empty space to box-select; arrow keys nudge the selection 1 crop px (Shift 10) |
481
+ | `C` | Create | click empty space to add a vertex; click an edge to split it at the click point |
482
+ | `X` | Delete | click a vertex to remove it and its edges; click an edge to remove it (hull edges are refused, `MESH_EDIT_HULL_EDGE`) |
483
+ | `E` | Edges | click vertex A then vertex B to add a constraint edge; click a constraint edge to remove it; a hull edge is refused and an existing edge records nothing |
443
484
 
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.
485
+ Hit-testing is in screen pixels through the current camera: a vertex within 6 px (8 on touch) wins over an edge within 4 px. In a sub-mode a press never pans; Space-drag still pans and the wheel still zooms. Escape leaves the sub-mode, a second Escape leaves mesh edit. **Undo** / **Redo** (Cmd/Ctrl-Z, Shift-Cmd/Ctrl-Z) step one action at a time — an edge or multi-selection drag is one action of several `move` ops. **Reset** drops the attachment's whole delta after a confirmation.
445
486
 
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.
487
+ Everything is a typed delta over the loaded mesh, in crop pixels (x right, y down, the chest regions' crop mapping): `{slot, skin, attachment, source:{vertexCount, triangleCount, hullLength, hash}, ops}` with ordered ops `move {v, from, to}`, `add {at, onEdge?}`, `remove {v}`, `edge {a, b}` and `unedge {a, b}`. Source vertex ids are the attachment's indices; an added vertex takes the next id; a removed id is never reused. `hullLength` is the model's `hull` (the first `hull` vertices, in order, are the hull) and `hash` is the SHA-256 of the attachment's entry in the hash-verified model. The vertices are the slot's last drawn posed vertices, so `basis` names the animation and time they were held at. The overlay previews moved, added and removed vertices and the constraint edges over the original triangulation; nothing is retriangulated in the browser (the status line says *preview only — the producer retriangulates*).
447
488
 
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`).
489
+ **Download JSON** saves `mesh-edit.json` and `mesh-edit.binding.json` (package source hash, rig and motion SHA-256, the attachment and its hash, export time, viewer and runtime versions and the mapping). **Apply · Rebuild** refuses `MODELING_INPUT_UNAVAILABLE` as the chest editor's does. The delta rides in the inspection's local draft bound to the attachment hash (at most 300 ops per attachment); it never enters the review session and nothing is written into the package.
449
490
 
450
491
  ## License
451
492
 
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** 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.
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, audio inspection and the inspection environment (wind, gravity, impulses and a read-only physics constraint list through the published player's `environment`/`moved`; its state is feedback context, never a model or motion edit). 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,13 +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.** 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.
41
+ - **Left column.** A fixed header row (Parts title and slot search on one line; the search's name, *Find slot*, is its `aria-label`) 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. Settings opens and closes from its own header (`#settings-toggle`, `aria-expanded`, controlling `#settings-body`); it starts collapsed and the state is a browser preference.
42
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
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
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
+ - **Chest-region editor.** In Setup only, the view bar's **Chest regions** toggle (`#chest-edit`, `aria-pressed`) opens `#chest-editor-bar` under the view bar: R / L groups (X, Y, R, Band), Mirror R → L, Download JSON and **Apply · Rebuild**, which refuses `MODELING_INPUT_UNAVAILABLE: no producer handoff — export the JSON and rebuild with the producer` and runs nothing. The circles, their centre / core / band handles and labels are drawn on `#rig-overlay` through the crop → world mapping; handle drags (move, r, band) are clamped live with the example rebuild script's non-overlap rule (`CHEST_REGIONS_CLAMPED`) and never move the camera, while other drags and Space-drags pan. The values are the Setup **Chest regions · detail** block's fields, so its draft, export and binding apply unchanged; the block keeps names, bones, semi-axes, clip and the contour. This is designation evidence, not a mesh editor: nothing is regenerated here.
46
+ - **Mesh edit.** In Setup only, the view bar's **Edit mesh** toggle (`#mesh-edit`, `aria-pressed`) opens `#mesh-editor-bar`: sub-modes Modify / Create / Delete / Edges (keys `V` / `C` / `X` / `E`, one pressed), Undo, Redo, Reset, Download JSON and **Apply · Rebuild** refusing `MODELING_INPUT_UNAVAILABLE`. Whole rig refuses `MESH_EDIT_SELECT_SLOT`; a non-mesh slot `MESH_EDIT_NO_MESH <slot>`. It records a typed delta over the actual loaded attachment (`src/mesh-edit.ts`) and previews it over the original triangulation on the overlay; it is not a mesher and never retriangulates, writes the package or runs a producer. The chest editor and mesh edit are exclusive: turning one on turns the other off.
45
47
  - **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
48
  - **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.
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.
49
+ - **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. The View region body is a `position:fixed` popover above the 40px footer, right-aligned, its width and height clamped to the viewport and its content scrolling inside, so the footer's clipping never hides it and opening it never scrolls the page.
48
50
 
49
51
  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.
50
52
 
@@ -63,6 +65,10 @@ The Parts explorer (the review page's left column; the compare page's right one)
63
65
 
64
66
  Published Play1.12.0 supplies a frozen `slotIdentity()` snapshot coherent with `posedFrame().generation`. Review retains copied frame/identity together only after equal-generation and source-slot/order checks, uses the player's default public filter, and never seeks/reposes/changes physics merely to read identity. Exact owning skin/key, active set/skin and null reasons come from that snapshot, not a unique-declaration or texture match. A resolved identity can remain known without visible geometry or fit bounds. Clip declarations do not invent keys when the API reports no drawable attachment.
65
67
 
68
+ Selecting the selected row again (click, Space or Enter) clears the selection, the same as **Whole rig**; the control value is then `null`.
69
+
70
+ Each row shows a 24×24 CSS-pixel thumbnail (2× backing store) of the attachment the held identity snapshot says is active: the region the compiled model's `pages[].regions[]` declares under that attachment's path (or name), cut from the loaded page image by its page UVs. It is drawn when the row scrolls into view and cached per skin/attachment key. A row whose attachment has no such region (no active attachment, a non-image kind, no or several regions of that name, a rotated packing, a page that did not load) shows the type icon, and its tooltip names the reason. A whole atlas page is never a part's thumbnail. Row text and the slot count are unchanged; the compare page registers no thumbnail source and keeps its icons.
71
+
66
72
  Live selection follows a slot. A source-bound bookmark captures its exact identity tuple at that pose; restore validates it after applying time/animation/set and drawing, with transactional rollback on a named mismatch. Old slot-only bookmarks resolve through current public identity. Generation belongs to current readback/feedback, not a stable source target. Named weights and original part alpha remain unavailable without an exact original-source mapping. A conservative bounds box and counts are not source-faithfulness or physics validation.
67
73
 
68
74
  A future optimization operation sends explicit source-bound selection/bounds to the owning producer. That producer returns a new package revision/hash and provenance including producer version/request identity; Review never regenerates rig, motion or mesh bytes. Candidate Apply/undo/export requires its own implemented contract and acceptance, rather than being inferred from the explorer.
@@ -73,7 +79,7 @@ Side and bottom panels have pointer and keyboard separators with separator seman
73
79
 
74
80
  The allocator measures the layout rectangle minus stage chrome and keeps a 240×180 CSS-pixel stage minimum. It shrinks side panels proportionally toward their own minima; when the width still cannot hold them it turns the left, then the right column, and when the height cannot hold it the Inspector, into a native modal drawer opened from a trigger outside it. Effective sizes are computed from desired sizes and never written back; a separator inverts the allocator so a clamped pixel value is not saved as the desktop desire.
75
81
 
76
- Browser defaults live in one closed, versioned record: theme, default camera sync, default idle repeat and the three desired panel sizes. The saved `rightWidth` is the Parts explorer's width wherever the page shows it, so the review page's left parts column keeps the width chosen before the move; `leftWidth` is the compare page's sources column. Each field carries a logical stamp, so stale whole-record writes from other tabs converge per field without wall-clock ordering; an equal-stamp disagreement quarantines only that field until an explicit edit repairs it. Denied, unavailable or corrupt storage falls back to in-page defaults. The legacy same-origin appearance key migrates once, only when no new record exists. Reset layout changes geometry only.
82
+ Browser defaults live in one closed, versioned record: theme, default camera sync, default idle repeat, whether Settings is collapsed (`settingsCollapsed`, default `true`) and the three desired panel sizes. A record saved before `settingsCollapsed` existed carries neither the value nor its stamp and loads as collapsed at stamp revision 0; the schema name is unchanged. The saved `rightWidth` is the Parts explorer's width wherever the page shows it, so the review page's left parts column keeps the width chosen before the move; `leftWidth` is the compare page's sources column. Each field carries a logical stamp, so stale whole-record writes from other tabs converge per field without wall-clock ordering; an equal-stamp disagreement quarantines only that field until an explicit edit repairs it. Denied, unavailable or corrupt storage falls back to in-page defaults. The legacy same-origin appearance key migrates once, only when no new record exists. Reset layout changes geometry only.
77
83
 
78
84
  Content state stays source-bound. Each Inspector section's x/y scroll offset is captured before its panel hides and restored after it shows, after two frames of layout, and again when its content extent changes. Restoring clamps to the current extent without replacing the remembered offset, and intentional user scrolling supersedes a pending restore. Offsets are kept in the source draft's UI state, never in the global record or a copied view link.
79
85
 
@@ -96,6 +96,8 @@ For a matching report/actual model animation with an explicit bounded clock, `fr
96
96
 
97
97
  The server derives `context.interval:{frameStart,frameEnd,inclusive:true}` from the chosen top-level annotation. Contradictory supplied interval data is refused; optional inspection `time` and `frame` remain separate and do not replace it. Compare uses the selected side's catalog and the CLI's exact animation; a row cannot borrow another side's names or bypass the animation binding.
98
98
 
99
+ A single-review note also records `context.environment`, the Animate environment it was written under: `{applied, wind:{base, breeze, gust:{peak, duration, repeat, active, value}, total}, gravity, standing:{wind:[x,y], gravity:[x,y]}}`, where `standing` is what the player reported (x right, y down) and `applied:false` means the runtime start values (`wind [1, 0]`, `gravity [0, 1]`). It is `null` when no compiled model was drawn. Records without it (older sessions, compare) stay valid; a present value must have exactly this shape within the control limits. The issue export adds an `Environment:` line for it. The environment is inspection context only: it is never written to the model, the motion or any producer file.
100
+
99
101
  Both session responses include server-derived `feedbackWarnings:[{id,error}]`. Reading an older session preserves invalid target rows and previously accepted integer indices outside JavaScript's safe range, together with source identity, inspection context and original state-file bytes; a missing chosen interval can be derived in memory. Contradictory existing interval evidence remains visible with its warning. The UI shows these warnings and prevents resubmission until the affected rows are corrected or removed. Strict saves independently validate every row and ignore a client's attempt to clear warnings; exports retain the warnings and original feedback evidence. Syntax and source-binding failures remain refusals rather than silently repaired data.
100
102
 
101
103
  The state filename is a digest of identity, revision, source hash and reviewed artifact/tool identity, so package names cannot select arbitrary filesystem paths. Writes are atomic and source files remain untouched. Review/session load and save stream-hash the declared inputs; changed bytes produce HTTP 409 and require restart. Asset reads do not rehash an entire video: single byte ranges return 206 and invalid ranges 416. Asset documents receive a sandbox CSP with no scripts or same-origin permission, so delivered authoring HTML cannot approve its own package. No network listener beyond loopback or cross-origin mutation is offered.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
@@ -10,7 +10,10 @@ export const CHEST_REGIONS_SPEC = 'family-chest-regions/1';
10
10
 
11
11
  export type CropPoint = [number, number];
12
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 }
13
+ export interface ChestPolygonRegion { name: string; bone: string; shape: 'polygon'; points: CropPoint[]; spacing: number; band: number }
14
+ /** The examples' `config.json` circle (key order as there): core radius r at full weight, linear falloff over band. */
15
+ export interface ChestCircleRegion { name: string; shape: 'circle'; cx: number; cy: number; r: number; bone: string; spacing: number; band: number }
16
+ export type ChestRegion = ChestPolygonRegion | ChestCircleRegion;
14
17
  export interface ChestContour { tolerance: number; margin: number; spacing: number; maxEdge: number; stray: number; bridges: boolean; regions: ChestRegion[] }
15
18
  export interface ChestMesh { contour: ChestContour; r?: number; segments?: ChestSegment[] }
16
19
  export interface ChestRegionsDocument {
@@ -115,6 +118,48 @@ export function polygonDistance(a: CropPoint[], b: CropPoint[]): number {
115
118
  export function regionsOverlap(a: { points: CropPoint[]; band: number }, b: { points: CropPoint[]; band: number }): boolean {
116
119
  return polygonDistance(a.points, b.points) < a.band + b.band;
117
120
  }
121
+ /** Two circles are tested exactly (centre distance minus both cores); any other pair through polygons. */
122
+ export function chestRegionsOverlap(a: ChestRegion, b: ChestRegion): boolean {
123
+ if (a.shape === 'circle' && b.shape === 'circle') return Math.hypot(b.cx - a.cx, b.cy - a.cy) - a.r - b.r < a.band + b.band;
124
+ return regionsOverlap({ points: regionPoints(a), band: a.band }, { points: regionPoints(b), band: b.band });
125
+ }
126
+ export const regionPoints = (region: ChestRegion): CropPoint[] => region.shape === 'circle' ? ellipsePolygon([region.cx, region.cy], [region.r, region.r]) : region.points;
127
+
128
+ /** Python's `round` (half to even), which the example's rebuild script applies before clamping. */
129
+ export function pythonRound(value: number): number {
130
+ const floor = Math.floor(value), fraction = value - floor;
131
+ if (fraction > 0.5) return floor + 1;
132
+ if (fraction < 0.5) return floor;
133
+ return floor % 2 === 0 ? floor : floor + 1;
134
+ }
135
+ export interface ChestCircle { cx: number; cy: number; r: number; band: number }
136
+ export const CHEST_CIRCLE_LIMITS = { r: [6, 120], band: [4, 120] } as const;
137
+ const clampInt = (value: number, [low, high]: readonly [number, number]) => Math.max(low, Math.min(high, value));
138
+ /**
139
+ * The example rebuild script's `clamp_non_overlapping`, reproduced: integer values, r 6–120 and band 4–120,
140
+ * both cores plus 8 px must fit the centre distance minus the margin (both shrink by one factor), and the band
141
+ * sum is limited to what remains. Pure; returns the clamped pair and whether the non-overlap rule changed it.
142
+ */
143
+ export function clampNonOverlapping(right: ChestCircle, left: ChestCircle, margin = 2): { right: ChestCircle; left: ChestCircle; clamped: boolean } {
144
+ const integer = (c: ChestCircle): ChestCircle => ({ cx: pythonRound(c.cx), cy: pythonRound(c.cy), r: pythonRound(c.r), band: pythonRound(c.band) });
145
+ const r = integer(right), l = integer(left);
146
+ r.r = clampInt(r.r, CHEST_CIRCLE_LIMITS.r); l.r = clampInt(l.r, CHEST_CIRCLE_LIMITS.r);
147
+ r.band = clampInt(r.band, CHEST_CIRCLE_LIMITS.band); l.band = clampInt(l.band, CHEST_CIRCLE_LIMITS.band);
148
+ const dist = Math.hypot(l.cx - r.cx, l.cy - r.cy), avail = Math.max(16, dist - margin);
149
+ let clamped = false;
150
+ if (r.r + l.r + 8 > avail) {
151
+ const scale = Math.max(0.25, (avail - 8) / Math.max(1, r.r + l.r));
152
+ r.r = Math.max(6, Math.floor(r.r * scale)); l.r = Math.max(6, Math.floor(l.r * scale));
153
+ clamped = true;
154
+ }
155
+ const maxBandSum = Math.max(8, Math.floor(avail - (r.r + l.r))), bandSum = r.band + l.band;
156
+ if (bandSum > maxBandSum) {
157
+ const ratio = maxBandSum / Math.max(1, bandSum);
158
+ r.band = Math.max(4, Math.floor(r.band * ratio)); l.band = Math.max(4, maxBandSum - r.band);
159
+ clamped = true;
160
+ }
161
+ return { right: r, left: l, clamped };
162
+ }
118
163
 
119
164
  function coordinatesText(crop: { w: number; h: number }, regions: ChestRegionInput[]): string {
120
165
  const [left, right] = [...regions].sort((a, b) => a.center[0] - b.center[0]);
@@ -153,11 +198,17 @@ export function buildChestRegions(input: ChestRegionsInput): ChestRegionsDocumen
153
198
  for (const [field, value] of [['center x', cx], ['center y', cy], ['band', band], ...(region.clip === undefined ? [] : [['clip', region.clip]])] as [string, number][]) {
154
199
  if (!onGrid(value)) throw new ChestRegionError('CHEST_REGION_OFF_GRID', `${name} ${field}`);
155
200
  }
201
+ // Equal semi-axes without a clip are the examples' circle; anything else stays the 32-sample polygon.
202
+ if (ax === ay && region.clip === undefined) {
203
+ if (cx - ax < 0 || cy - ax < 0 || cx + ax > w || cy + ax > h) throw new ChestRegionError('CHEST_REGION_OUT_OF_CROP', name);
204
+ regions.push({ name, shape: 'circle', cx, cy, r: ax, bone: region.bone, spacing, band });
205
+ continue;
206
+ }
156
207
  const points = ellipsePolygon([cx, cy], [ax, ay], 32, region.clip);
157
208
  if (points.some(([x, y]) => x < 0 || y < 0 || x > w || y > h)) throw new ChestRegionError('CHEST_REGION_OUT_OF_CROP', name);
158
209
  regions.push({ name, bone: region.bone, shape: 'polygon', points, spacing, band });
159
210
  }
160
- if (regionsOverlap(regions[0]!, regions[1]!)) throw new ChestRegionError('CHEST_REGIONS_OVERLAP', `${regions[0]!.name} ${regions[1]!.name}`);
211
+ if (chestRegionsOverlap(regions[0]!, regions[1]!)) throw new ChestRegionError('CHEST_REGIONS_OVERLAP', `${regions[0]!.name} ${regions[1]!.name}`);
161
212
  const mesh: ChestMesh = { contour: { tolerance: c.tolerance, margin: c.margin, spacing: c.spacing, maxEdge: c.maxEdge, stray: c.stray, bridges: c.bridges, regions } };
162
213
  if (input.r !== undefined) mesh.r = finite(input.r, 'mesh', 'r', 0);
163
214
  if (input.segments !== undefined) mesh.segments = segmentList(input.segments);
@@ -196,7 +247,9 @@ export function serializeChestRegions(doc: ChestRegionsDocument): string {
196
247
  [`meshes.${doc.mesh.name}`]: {
197
248
  contour: {
198
249
  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 })),
250
+ regions: contour.regions.map((r): Emit => r.shape === 'circle'
251
+ ? { name: r.name, shape: r.shape, cx: r.cx, cy: r.cy, r: r.r, bone: r.bone, spacing: r.spacing, band: r.band }
252
+ : { 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
253
  },
201
254
  ...(mesh.r === undefined ? {} : { r: mesh.r }),
202
255
  ...(mesh.segments === undefined ? {} : { segments: mesh.segments as Emit[] }),
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Inspection-only environment for Animate: a pure wind model (base, breeze, gust),
3
+ * the jolt packet, the drag-to-trail mapping and the read-only physics constraint
4
+ * list. Nothing here writes the model or motion; the player stays the only solver.
5
+ *
6
+ * The wind model ports the semantics of the public parts-examples viewer's
7
+ * `wind-controls.mjs`: the same breeze formula, gust envelope, repeat period and
8
+ * limits. Its clock is advanced by the caller with the step the player takes, so a
9
+ * paused player freezes the gust.
10
+ */
11
+ import type {Json} from './manifest.ts';
12
+
13
+ export const BASE_WIND_LIMIT=100;
14
+ export const MAX_GUST_PEAK=200;
15
+ export const MIN_GUST_DURATION=0.1;
16
+ export const MAX_GUST_DURATION=20;
17
+ export const GUST_DURATION=2.4;
18
+ export const GUST_PEAK=48;
19
+ export const GUST_PERIOD=6;
20
+ export const GRAVITY_MIN=0;
21
+ export const GRAVITY_MAX=4;
22
+ export const GRAVITY_DEFAULT=1;
23
+ /** `@rig-c/play`'s start values (`environment(null)`): x right, y down. */
24
+ export const RUNTIME_START={wind:[1,0] as [number,number],gravity:[0,1] as [number,number]};
25
+
26
+ const bounded=(value:number,min:number,max:number,name:string):number=>{
27
+ if(!Number.isFinite(value)||value<min||value>max)throw new RangeError(`${name} must be finite and within [${min}, ${max}]`);
28
+ return value;
29
+ };
30
+
31
+ /** The gentle breeze at clock time `t`. */
32
+ export function breezeAt(t:number):number {return 2*Math.sin(2.2*t)+0.8*Math.sin(5.1*t);}
33
+ /** One gust pulse `peak·sin²(π·age/duration)` inside `[0, duration)`, 0 outside. */
34
+ export function gustEnvelope(age:number,peak:number,duration:number):number {
35
+ return age>=0&&age<duration?peak*Math.sin(Math.PI*age/duration)**2:0;
36
+ }
37
+ /** Repeat period: never shorter than GUST_PERIOD, and 0.6 s of calm after a long pulse. */
38
+ export function gustPeriod(duration:number):number {return Math.max(GUST_PERIOD,duration+0.6);}
39
+
40
+ export interface WindSnapshot {version:1;base:number;breeze:boolean;repeat:boolean;gustPeak:number;gustDuration:number}
41
+ export interface WindReading {
42
+ base:number;breeze:boolean;repeat:boolean;time:number;
43
+ /** The breeze term (0 when the breeze is off). */
44
+ gentle:number;gust:number;gusting:boolean;
45
+ /** Seconds since the active pulse began, or null when no pulse was started. */
46
+ gustAge:number|null;
47
+ wind:number;gustPeak:number;gustDuration:number;activeGustPeak:number;activeGustDuration:number;
48
+ }
49
+ export interface WindModel {
50
+ read():WindReading;snapshot():WindSnapshot;restore(value:WindSnapshot|null):void;
51
+ setBase(value:number):void;setGustPeak(value:number):void;setGustDuration(value:number):void;
52
+ setBreeze(value:boolean):void;setRepeat(value:boolean):void;triggerGust():void;
53
+ advance(dt:number):WindReading;
54
+ }
55
+ export const defaultWindSnapshot=():WindSnapshot=>({version:1,base:0,breeze:false,repeat:false,gustPeak:GUST_PEAK,gustDuration:GUST_DURATION});
56
+
57
+ export function createWindModel():WindModel {
58
+ let base=0,breeze=false,repeat=false,time=0,gustStart:number|null=null,gustDirection=1,nextGust:number|null=null;
59
+ let gustPeak=GUST_PEAK,gustDuration=GUST_DURATION,activeGustPeak=GUST_PEAK,activeGustDuration=GUST_DURATION;
60
+ function start(at:number):void {
61
+ gustStart=at;gustDirection=base<0?-1:1;activeGustPeak=gustPeak;activeGustDuration=gustDuration;
62
+ if(repeat)nextGust=at+gustPeriod(activeGustDuration);
63
+ }
64
+ function read():WindReading {
65
+ const age=gustStart===null?activeGustDuration:time-gustStart;
66
+ const gusting=age>=0&&age<activeGustDuration;
67
+ const gust=gusting?gustDirection*gustEnvelope(age,activeGustPeak,activeGustDuration):0;
68
+ const gentle=breeze?breezeAt(time):0;
69
+ return {base,breeze,repeat,time,gentle,gust,gusting,gustAge:gustStart===null?null:age,wind:base+gentle+gust,gustPeak,gustDuration,activeGustPeak,activeGustDuration};
70
+ }
71
+ return {
72
+ read,
73
+ snapshot:()=>({version:1,base,breeze,repeat,gustPeak,gustDuration}),
74
+ restore(value){
75
+ const s=value===null?defaultWindSnapshot():value;
76
+ if(!s||typeof s!=='object'||Array.isArray(s)||s.version!==1)throw new RangeError('wind snapshot must be a version 1 object');
77
+ // Validate the complete configuration before changing the standing state.
78
+ const nextBase=bounded(s.base,-BASE_WIND_LIMIT,BASE_WIND_LIMIT,'base wind');
79
+ const nextPeak=bounded(s.gustPeak,0,MAX_GUST_PEAK,'gust peak');
80
+ const nextDuration=bounded(s.gustDuration,MIN_GUST_DURATION,MAX_GUST_DURATION,'gust duration');
81
+ if(typeof s.breeze!=='boolean'||typeof s.repeat!=='boolean')throw new RangeError('wind snapshot breeze and repeat must be booleans');
82
+ base=nextBase;gustPeak=nextPeak;gustDuration=nextDuration;breeze=s.breeze;repeat=s.repeat;
83
+ time=0;gustStart=null;gustDirection=1;nextGust=null;activeGustPeak=gustPeak;activeGustDuration=gustDuration;
84
+ if(repeat)start(0);
85
+ },
86
+ setBase(value){base=bounded(value,-BASE_WIND_LIMIT,BASE_WIND_LIMIT,'base wind');},
87
+ setGustPeak(value){gustPeak=bounded(value,0,MAX_GUST_PEAK,'gust peak');},
88
+ setGustDuration(value){gustDuration=bounded(value,MIN_GUST_DURATION,MAX_GUST_DURATION,'gust duration');},
89
+ setBreeze(value){breeze=Boolean(value);},
90
+ // A click cannot interrupt a pulse.
91
+ triggerGust(){if(!read().gusting)start(time);},
92
+ setRepeat(value){
93
+ const enabled=Boolean(value);if(enabled===repeat)return;repeat=enabled;
94
+ if(repeat){if(read().gusting)nextGust=gustStart!+gustPeriod(activeGustDuration);else start(time);}
95
+ else nextGust=null; // Finish the active pulse before returning to base.
96
+ },
97
+ advance(dt){
98
+ if(!Number.isFinite(dt)||dt<0||!Number.isFinite(time+dt))throw new RangeError('wind dt must be finite, non-negative and keep a finite clock');
99
+ time+=dt;
100
+ if(repeat&&nextGust!==null&&time>=nextGust){const interval=gustPeriod(gustDuration);start(nextGust+Math.floor((time-nextGust)/interval)*interval);}
101
+ return read();
102
+ },
103
+ };
104
+ }
105
+
106
+ /** The value `player.environment` receives for a reading: wind along x, gravity down. */
107
+ export function environmentFor(reading:WindReading,gravity:number):{wind:[number,number];gravity:[number,number]} {
108
+ return {wind:[reading.wind,0],gravity:[0,bounded(gravity,GRAVITY_MIN,GRAVITY_MAX,'gravity')]};
109
+ }
110
+
111
+ const fixed=(value:number,digits=1)=>(Object.is(value,-0)?0:value).toFixed(digits);
112
+ /** `wind: base + breeze + gust = total` with the gust phase and the stepped clock. */
113
+ export function windReadout(reading:WindReading,gravity:number):string {
114
+ const phase=reading.gusting&&reading.gustAge!==null?` · gust ${reading.gustAge.toFixed(2)}/${reading.activeGustDuration.toFixed(2)} s`:'';
115
+ return `wind: ${fixed(reading.base)} + ${fixed(reading.gentle)} + ${fixed(reading.gust)} = ${fixed(reading.wind)} · gravity ${fixed(gravity,2)} · t ${reading.time.toFixed(3)} s${phase}`;
116
+ }
117
+ export const RUNTIME_START_READOUT=`Runtime start values · wind [${RUNTIME_START.wind.join(', ')}] · gravity [${RUNTIME_START.gravity.join(', ')}] · controls not applied`;
118
+
119
+ /** The example's jolt: translate (18, −10), then the inverse 0.09 s later. */
120
+ export interface JoltStep {at:number;x:number;y:number}
121
+ export const JOLT_DELAY=0.09;
122
+ export function joltPacket(start:number):JoltStep[] {return [{at:start,x:18,y:-10},{at:start+JOLT_DELAY,x:-18,y:10}];}
123
+ /** Splits a queue into the steps due at `now` (in order) and the rest. */
124
+ export function dueJolts(queue:readonly JoltStep[],now:number):{due:JoltStep[];pending:JoltStep[]} {
125
+ const due:JoltStep[]=[],pending:JoltStep[]=[];for(const step of queue)(step.at<=now?due:pending).push(step);
126
+ due.sort((a,b)=>a.at-b.at);return {due,pending};
127
+ }
128
+
129
+ /** A page-pixel pointer delta as `player.moved` wants it: ÷ the root's scale, y negated (world y up). */
130
+ export function dragMoved(dx:number,dy:number,scale:number):{x:number;y:number}|null {
131
+ if(!Number.isFinite(dx)||!Number.isFinite(dy)||!Number.isFinite(scale)||scale<=0)return null;
132
+ return {x:dx/scale,y:-dy/scale};
133
+ }
134
+
135
+ /** Columns read as the compiled model declares them; an absent field stays absent. */
136
+ export const PHYSICS_FIELDS=['wind','gravity','inertia','strength','damping','mix'] as const;
137
+ export type PhysicsField=typeof PHYSICS_FIELDS[number];
138
+ export interface PhysicsConstraintRow {name:string;bone:string|null;declaredIn:string|null;values:Partial<Record<PhysicsField,number>>}
139
+ export type PhysicsConstraints={available:true;rows:PhysicsConstraintRow[]}|{available:false;reason:string};
140
+ export const CONSTRAINT_MIX_READONLY='CONSTRAINT_MIX_READONLY: @rig-c/play exposes no per-constraint mix';
141
+
142
+ /** Lists the document's `constraints` entries of kind `physics` in their order; nothing is computed or defaulted. */
143
+ export function physicsConstraints(document:unknown):PhysicsConstraints {
144
+ if(!document||typeof document!=='object'||Array.isArray(document))return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: model document is not an object'};
145
+ const list=(document as Record<string,unknown>).constraints;
146
+ if(!Array.isArray(list))return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: model declares no constraints list'};
147
+ const rows:PhysicsConstraintRow[]=[];
148
+ for(const item of list){
149
+ if(!item||typeof item!=='object'||Array.isArray(item))continue;
150
+ const entry=item as Record<string,unknown>;if(entry.kind!=='physics')continue;
151
+ if(typeof entry.name!=='string'||!entry.name)return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: a physics constraint has no name'};
152
+ const values:Partial<Record<PhysicsField,number>>={};
153
+ for(const field of PHYSICS_FIELDS){const value=entry[field];if(typeof value==='number'&&Number.isFinite(value))values[field]=value;}
154
+ rows.push({name:entry.name,bone:typeof entry.bone==='string'?entry.bone:null,declaredIn:typeof entry.declaredIn==='string'?entry.declaredIn:null,values});
155
+ }
156
+ if(!rows.length)return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: model declares no physics constraints'};
157
+ return {available:true,rows};
158
+ }
159
+
160
+ /** Reads the reviewed model bytes (hash-checked against the reviewed identity) and lists its physics constraints. */
161
+ export async function loadPhysicsConstraints(url:string,sha256:string,fetcher:typeof fetch=fetch):Promise<PhysicsConstraints> {
162
+ const response=await fetcher(url);if(!response.ok)return {available:false,reason:`PHYSICS_CONSTRAINTS_UNAVAILABLE: model request failed (${response.status})`};
163
+ const bytes=await response.arrayBuffer();if(bytes.byteLength>128*1024*1024)return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: model exceeds 128 MiB'};
164
+ const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(v=>v.toString(16).padStart(2,'0')).join('');
165
+ if(hash!==sha256)return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: model bytes differ from reviewed SHA-256'};
166
+ try{return physicsConstraints(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes)));}
167
+ catch(error){return {available:false,reason:`PHYSICS_CONSTRAINTS_UNAVAILABLE: ${error instanceof Error?error.message:String(error)}`};}
168
+ }
169
+
170
+ /** What a feedback record keeps of the environment it was written under. */
171
+ export interface EnvironmentContext {
172
+ applied:boolean;
173
+ wind:{base:number;breeze:boolean;gust:{peak:number;duration:number;repeat:boolean;active:boolean;value:number};total:number};
174
+ gravity:number;
175
+ /** What the player reported standing when the note was written (x right, y down). */
176
+ standing:{wind:[number,number];gravity:[number,number]};
177
+ }
178
+ export function environmentContext(reading:WindReading,gravity:number,applied:boolean,standing:{wind:readonly number[];gravity:readonly number[]}):EnvironmentContext {
179
+ return {applied,wind:{base:reading.base,breeze:reading.breeze,gust:{peak:reading.gusting?reading.activeGustPeak:reading.gustPeak,duration:reading.gusting?reading.activeGustDuration:reading.gustDuration,repeat:reading.repeat,active:reading.gusting,value:reading.gust},total:reading.wind},gravity,standing:{wind:[standing.wind[0]!,standing.wind[1]!],gravity:[standing.gravity[0]!,standing.gravity[1]!]}};
180
+ }
181
+
182
+ const fail=(field:string,expected:string):never=>{throw new Error(`feedback.context.environment${field}: expected ${expected}`);};
183
+ const exactKeys=(value:unknown,keys:readonly string[],field:string):Record<string,unknown>=>{
184
+ if(!value||typeof value!=='object'||Array.isArray(value))fail(field,'object');
185
+ const o=value as Record<string,unknown>;
186
+ for(const key of Object.keys(o))if(!keys.includes(key))fail(field,`only ${keys.join(', ')}`);
187
+ return o;
188
+ };
189
+ const finite=(value:unknown,field:string):number=>typeof value==='number'&&Number.isFinite(value)?value:fail(field,'finite number');
190
+ const flag=(value:unknown,field:string):boolean=>typeof value==='boolean'?value:fail(field,'boolean');
191
+ const pair=(value:unknown,field:string):[number,number]=>Array.isArray(value)&&value.length===2?[finite(value[0],field),finite(value[1],field)]:fail(field,'[x, y]');
192
+ /** Validates `context.environment`; null means no compiled model was drawn. Absent stays valid for older records. */
193
+ export function parseEnvironmentContext(value:unknown):EnvironmentContext|null {
194
+ if(value===null)return null;
195
+ const o=exactKeys(value,['applied','wind','gravity','standing'],'');
196
+ const wind=exactKeys(o.wind,['base','breeze','gust','total'],'.wind'),gust=exactKeys(wind.gust,['peak','duration','repeat','active','value'],'.wind.gust'),standing=exactKeys(o.standing,['wind','gravity'],'.standing');
197
+ const result:EnvironmentContext={applied:flag(o.applied,'.applied'),
198
+ wind:{base:finite(wind.base,'.wind.base'),breeze:flag(wind.breeze,'.wind.breeze'),gust:{peak:finite(gust.peak,'.wind.gust.peak'),duration:finite(gust.duration,'.wind.gust.duration'),repeat:flag(gust.repeat,'.wind.gust.repeat'),active:flag(gust.active,'.wind.gust.active'),value:finite(gust.value,'.wind.gust.value')},total:finite(wind.total,'.wind.total')},
199
+ gravity:finite(o.gravity,'.gravity'),standing:{wind:pair(standing.wind,'.standing.wind'),gravity:pair(standing.gravity,'.standing.gravity')}};
200
+ if(Math.abs(result.wind.base)>BASE_WIND_LIMIT)fail('.wind.base',`within ±${BASE_WIND_LIMIT}`);
201
+ if(result.wind.gust.peak<0||result.wind.gust.peak>MAX_GUST_PEAK)fail('.wind.gust.peak',`within [0, ${MAX_GUST_PEAK}]`);
202
+ if(result.wind.gust.duration<MIN_GUST_DURATION||result.wind.gust.duration>MAX_GUST_DURATION)fail('.wind.gust.duration',`within [${MIN_GUST_DURATION}, ${MAX_GUST_DURATION}]`);
203
+ if(result.gravity<GRAVITY_MIN||result.gravity>GRAVITY_MAX)fail('.gravity',`within [${GRAVITY_MIN}, ${GRAVITY_MAX}]`);
204
+ return result;
205
+ }
206
+ /** One line for issue text: "under gust 48 / wind 30". */
207
+ export function environmentSummary(value:EnvironmentContext):string {
208
+ const s=value.standing,standing=`standing wind [${s.wind.join(', ')}] · gravity [${s.gravity.join(', ')}]`;
209
+ if(!value.applied)return `runtime start values · ${standing}`;
210
+ const w=value.wind,gust=w.gust.active?`gust ${w.gust.peak} over ${w.gust.duration} s (now ${fixed(w.gust.value)})`:`gust idle (peak ${w.gust.peak}, ${w.gust.duration} s)`;
211
+ return `wind ${w.base}${w.breeze?' + breeze':''} · ${gust}${w.gust.repeat?' · repeat':''} · total ${fixed(w.total)} · gravity ${value.gravity} · ${standing}`;
212
+ }
213
+ export const environmentJson=(value:EnvironmentContext):Json=>JSON.parse(JSON.stringify(value)) as Json;
@@ -4,6 +4,7 @@ import type { CompareBinding } from './compare.ts';
4
4
  import type { CompareSession } from './compare-session.ts';
5
5
  import type { FeedbackHistory, HistoryRetrieval } from './session-history.ts';
6
6
  import type { ReferenceBinding } from './reference.ts';
7
+ import { environmentSummary, parseEnvironmentContext } from './environment.ts';
7
8
  type Category = Feedback['category'];
8
9
  type Side = 'single' | 'a' | 'b';
9
10
  type ParsedFeedbackSession = FeedbackHistory<ReviewSession | CompareSession>;
@@ -155,6 +156,12 @@ function jsonBlock(value: unknown): string {
155
156
  return `${fence}json\n${json}\n${fence}`;
156
157
  }
157
158
 
159
+ /** "Under gust 48 / wind 30": a readable line when the record carries a valid environment; the raw context still follows. */
160
+ function environmentLine(context: Json | undefined): string[] {
161
+ if (context === undefined || context === null || typeof context !== 'object' || Array.isArray(context) || !('environment' in context)) return [];
162
+ try { const value = parseEnvironmentContext(context.environment); return [`- Environment: ${value === null ? 'no compiled model drawn' : feedbackMarkdownText(environmentSummary(value))}`]; }
163
+ catch { return []; }
164
+ }
158
165
  /** Local issue-ready text only. No issue creation, clock inference or source IO. */
159
166
  export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
160
167
  const model = groupFeedbackIssues(input), md = feedbackMarkdownText;
@@ -185,7 +192,8 @@ export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
185
192
  `- Target: ${md(item.target.kind)}: ${md(item.target.name)}`,
186
193
  `- Inclusive frames: ${item.frameStart}–${item.frameEnd}`,
187
194
  `- Side: ${item.side}`,
188
- `- Summary: ${md(item.summary)}`, '',
195
+ `- Summary: ${md(item.summary)}`,
196
+ ...environmentLine(item.context), '',
189
197
  'Selected build identity:', '', jsonBlock(item.selectedSource));
190
198
  if (model.pairedBinding) lines.push('', 'Paired comparison binding:', '', jsonBlock(model.pairedBinding));
191
199
  if (item.warnings.length) lines.push('', 'Validation warnings (record retained):', '', jsonBlock(item.warnings));