@rig-c/review 0.5.0 → 0.7.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.
Files changed (63) hide show
  1. package/README.md +242 -7
  2. package/docs/DESIGN.md +33 -5
  3. package/docs/REVIEW_PACKAGE.md +115 -0
  4. package/package.json +1 -1
  5. package/src/applied-context.ts +285 -0
  6. package/src/baseline.ts +175 -0
  7. package/src/bone-anchors.ts +123 -0
  8. package/src/bones-check.ts +105 -0
  9. package/src/build-route.ts +109 -0
  10. package/src/chest-landmarks.ts +122 -0
  11. package/src/chest-regions.ts +65 -6
  12. package/src/cli.ts +19 -8
  13. package/src/config-patch.ts +213 -0
  14. package/src/cut-edits.ts +184 -0
  15. package/src/environment.ts +226 -0
  16. package/src/feedback-export.ts +25 -1
  17. package/src/front.ts +11 -0
  18. package/src/handoff.ts +208 -0
  19. package/src/idle-tracks.ts +401 -0
  20. package/src/manifest.ts +15 -6
  21. package/src/mesh-edit.ts +317 -0
  22. package/src/mesh-entry.ts +351 -0
  23. package/src/open.ts +12 -5
  24. package/src/physics-tuning.ts +236 -0
  25. package/src/producer-files.ts +11 -0
  26. package/src/producer.ts +158 -0
  27. package/src/readings-run.ts +129 -0
  28. package/src/readings.ts +235 -0
  29. package/src/rebuild.ts +938 -0
  30. package/src/reference.ts +4 -3
  31. package/src/requirements-run.ts +160 -0
  32. package/src/requirements.ts +230 -0
  33. package/src/retarget.ts +211 -0
  34. package/src/retune-route.ts +52 -0
  35. package/src/rig-fields.ts +295 -0
  36. package/src/server.ts +59 -5
  37. package/src/session.ts +12 -0
  38. package/src/setup-mesh.ts +144 -0
  39. package/src/weights.ts +74 -0
  40. package/web/bones-panel.ts +230 -0
  41. package/web/chest-editor.ts +294 -0
  42. package/web/chest-regions-tool.ts +10 -4
  43. package/web/cuts-panel.ts +266 -0
  44. package/web/idle-panel.ts +280 -0
  45. package/web/index.html +2 -2
  46. package/web/inspector-blocks.ts +77 -0
  47. package/web/main.ts +813 -24
  48. package/web/mesh-editor.ts +303 -0
  49. package/web/mesh-panel.ts +273 -0
  50. package/web/part-selection.ts +13 -5
  51. package/web/part-thumbnails.ts +76 -0
  52. package/web/physics-panel.ts +151 -0
  53. package/web/readings-panel.ts +105 -0
  54. package/web/rebuild-client.ts +216 -0
  55. package/web/requirements-panel.ts +238 -0
  56. package/web/retarget-panel.ts +203 -0
  57. package/web/rig-fields-panel.ts +269 -0
  58. package/web/rig-overlays.ts +171 -14
  59. package/web/setup-pose.ts +30 -0
  60. package/web/styles.css +52 -5
  61. package/web/weights-view.ts +159 -0
  62. package/web/workspace-preferences.ts +17 -12
  63. package/web/workspace-ui.ts +5 -3
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Cuts, patches and limbs (#115): the producer config's `assemble.cuts`, `assemble.patches` and `limbs` as the Setup
3
+ * Cuts block edits them on the stage. Every coordinate is rig px of the setup pose, y down — the space of `parts.json`
4
+ * and of the cut polygons and patch boxes (rig-parts AUTHORING §3, `assemble.cuts`: "non-negative integer rig pixels").
5
+ *
6
+ * - a cut's polygon vertex is one anchor at `/assemble/cuts/<i>/polygon/<k>`, a `replace` op naming the cut by `into`;
7
+ * - a patch's box is two corner anchors whose edit is one `replace` at `/assemble/patches/<i>/box` naming the patch;
8
+ * - a limb segment below a joint has its `range` (`[min, max]` degrees from rest, `min <= 0 <= max`), a `replace` (or
9
+ * `add` when the config states none, `remove` to drop it) at `/limbs/<limb>/segments/<k>/range` naming its `part`; its
10
+ * joint is the lower bone's origin, which is a `bones` anchor (`src/bone-anchors.ts`) — a joint drag is a bone op;
11
+ * - a new cut is `add /assemble/cuts/-` with `{from, into, polygon, draw, overlap}` (no `under`: review paints no image)
12
+ * plus `add /regions/<into>` (the coverage rule wants every part a mesh or a region); a new patch is
13
+ * `add /assemble/patches/-` with `{name, box, alpha, draw}` plus `add /regions/<name>` (a patch is always a region).
14
+ *
15
+ * What only the producer can judge (a polygon point past the rig, two cuts overlapping, a cut taking no pixel, an
16
+ * `under` no longer the polygon box's size, a name already taken, a range out of order) is the producer's refusal,
17
+ * shown verbatim. Review refuses before sending only what is not the loader's type: a non-integer or negative point, a
18
+ * polygon under three points, a range that is not two numbers. Pure: no IO, no DOM.
19
+ */
20
+ import type { JsonValue, PatchOp } from './config-patch.ts';
21
+
22
+ export type RigPoint = [number, number];
23
+ export type PatchBox = [number, number, number, number];
24
+ export type CutDraw = 'back' | 'front' | { before: string };
25
+ export interface ConfigCut { index: number; from: string; into: string; polygon: RigPoint[]; draw: CutDraw; overlap: number; under: string | null }
26
+ export interface ConfigPatchEntry { index: number; name: string; box: PatchBox; alpha: string; draw: CutDraw }
27
+ export interface ConfigLimbSegment { limb: string; index: number; part: string; from: string; bone: string; joint: string | null; range: [number, number] | null }
28
+ export interface CutsSource { cuts: ConfigCut[]; patches: ConfigPatchEntry[]; segments: ConfigLimbSegment[]; plan: string[]; skipped: string[] }
29
+
30
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
31
+ const intPoint = (v: unknown): RigPoint | null => Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number' && Number.isFinite(n)) ? [v[0] as number, v[1] as number] : null;
32
+ const draw = (v: unknown): CutDraw | null => v === 'back' || v === 'front' ? v : isObject(v) && typeof v.before === 'string' ? { before: v.before } : null;
33
+
34
+ /** The config's cuts, patches, limb segments and plan part names as written; an unreadable entry is named in `skipped`. */
35
+ export function cutsSource(config: unknown): CutsSource {
36
+ const out: CutsSource = { cuts: [], patches: [], segments: [], plan: [], skipped: [] };
37
+ if (!isObject(config)) return out;
38
+ const assemble = isObject(config.assemble) ? config.assemble : {};
39
+ if (Array.isArray(assemble.plan)) for (const row of assemble.plan) if (Array.isArray(row) && typeof row[0] === 'string') out.plan.push(row[0]);
40
+ if (Array.isArray(assemble.cuts)) assemble.cuts.forEach((q, index) => {
41
+ const polygon = isObject(q) && Array.isArray(q.polygon) ? q.polygon.map(intPoint) : [];
42
+ const d = isObject(q) ? draw(q.draw) : null;
43
+ if (!isObject(q) || typeof q.from !== 'string' || typeof q.into !== 'string' || !d || typeof q.overlap !== 'number' || polygon.length < 3 || polygon.some(p => !p)) { out.skipped.push(`assemble.cuts[${index}] is not {from, into, polygon, draw, overlap}`); return; }
44
+ out.cuts.push({ index, from: q.from, into: q.into, polygon: polygon as RigPoint[], draw: d, overlap: q.overlap, under: typeof q.under === 'string' ? q.under : null });
45
+ });
46
+ if (Array.isArray(assemble.patches)) assemble.patches.forEach((q, index) => {
47
+ const d = isObject(q) ? draw(q.draw) : null;
48
+ if (!isObject(q) || typeof q.name !== 'string' || !Array.isArray(q.box) || q.box.length !== 4 || !q.box.every(n => typeof n === 'number' && Number.isFinite(n)) || typeof q.alpha !== 'string' || !d) { out.skipped.push(`assemble.patches[${index}] is not {name, box, alpha, draw}`); return; }
49
+ out.patches.push({ index, name: q.name, box: [...q.box] as PatchBox, alpha: q.alpha, draw: d });
50
+ });
51
+ if (isObject(config.limbs)) for (const [limb, entry] of Object.entries(config.limbs)) {
52
+ if (!isObject(entry) || !Array.isArray(entry.segments)) { out.skipped.push(`limbs.${limb} has no segments`); continue; }
53
+ entry.segments.forEach((sg, index) => {
54
+ if (!isObject(sg) || typeof sg.part !== 'string' || typeof sg.from !== 'string' || typeof sg.bone !== 'string') { out.skipped.push(`limbs.${limb}.segments[${index}] is not {part, from, bone}`); return; }
55
+ const range = Array.isArray(sg.range) && sg.range.length === 2 && sg.range.every(n => typeof n === 'number' && Number.isFinite(n)) ? [sg.range[0], sg.range[1]] as [number, number] : null;
56
+ out.segments.push({ limb, index, part: sg.part, from: sg.from, bone: sg.bone, joint: typeof sg.joint === 'string' ? sg.joint : null, range });
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ export class CutEditError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
63
+
64
+ /** One pending edit, keyed by its pointer; `value` is the whole new value at that pointer (or `null` to remove a range). */
65
+ export type CutEdit =
66
+ | { kind: 'vertex'; path: string; name: string; label: string; from: RigPoint; value: RigPoint }
67
+ | { kind: 'box'; path: string; name: string; label: string; from: PatchBox; value: PatchBox }
68
+ | { kind: 'range'; path: string; name: string; label: string; from: [number, number] | null; value: [number, number] | null };
69
+ /** A new cut or patch, placed on the stage; it becomes two `add` ops (the entry and its `regions` bone). */
70
+ export type CutAdd =
71
+ | { kind: 'cut'; from: string; into: string; polygon: RigPoint[]; draw: CutDraw; overlap: number; bone: string }
72
+ | { kind: 'patch'; name: string; box: PatchBox; alpha: 'silhouette' | 'box'; draw: CutDraw; bone: string };
73
+
74
+ export const vertexPath = (cut: number, k: number) => `/assemble/cuts/${cut}/polygon/${k}`;
75
+ export const boxPath = (patch: number) => `/assemble/patches/${patch}/box`;
76
+ export const rangePath = (limb: string, k: number) => `/limbs/${limb.replace(/~/g, '~0').replace(/\//g, '~1')}/segments/${k}/range`;
77
+
78
+ /** A rig px value as the loader takes it: a non-negative integer (`CONFIG_FIELD_TYPE` otherwise). */
79
+ function rigInt(v: number, what: string): number {
80
+ if (!Number.isInteger(v) || v < 0) throw new CutEditError('CUTS_VALUE_INVALID', `${what} must be a non-negative integer of rig px (the config's polygons and boxes are)`);
81
+ return v;
82
+ }
83
+ const same = (a: readonly number[] | null, b: readonly number[] | null) => a === b || (!!a && !!b && a.length === b.length && a.every((v, i) => v === b[i]));
84
+
85
+ /** Set a cut vertex; a value equal to the config's removes the pending edit. */
86
+ export function setVertex(edits: ReadonlyMap<string, CutEdit>, cut: ConfigCut, k: number, value: RigPoint): Map<string, CutEdit> {
87
+ const from = cut.polygon[k];
88
+ if (!from) throw new CutEditError('CUTS_VERTEX_UNKNOWN', `${cut.into} has no vertex ${k}`);
89
+ const v: RigPoint = [rigInt(value[0], `${cut.into} vertex ${k} x`), rigInt(value[1], `${cut.into} vertex ${k} y`)];
90
+ const next = new Map(edits), path = vertexPath(cut.index, k);
91
+ if (same(v, from)) next.delete(path); else next.set(path, { kind: 'vertex', path, name: cut.into, label: `${cut.into} vertex ${k}`, from: [...from], value: v });
92
+ return next;
93
+ }
94
+ /** Set a patch box (`[x0, y0, x1, y1]`, `x1`/`y1` exclusive); a box equal to the config's removes the edit. */
95
+ export function setBox(edits: ReadonlyMap<string, CutEdit>, patch: ConfigPatchEntry, value: PatchBox): Map<string, CutEdit> {
96
+ const v = value.map((n, i) => rigInt(n, `${patch.name} box[${i}]`)) as PatchBox;
97
+ if (v[2] <= v[0] || v[3] <= v[1]) throw new CutEditError('CUTS_VALUE_INVALID', `${patch.name} box must have x1 > x0 and y1 > y0`);
98
+ const next = new Map(edits), path = boxPath(patch.index);
99
+ if (same(v, patch.box)) next.delete(path); else next.set(path, { kind: 'box', path, name: patch.name, label: `${patch.name} box`, from: [...patch.box] as PatchBox, value: v });
100
+ return next;
101
+ }
102
+ /** Set (or with null, drop) a segment's range; only a segment below a joint takes one (the loader's `CONFIG_KEY_KNOWN`). */
103
+ export function setRange(edits: ReadonlyMap<string, CutEdit>, segment: ConfigLimbSegment, value: [number, number] | null): Map<string, CutEdit> {
104
+ if (!segment.joint) throw new CutEditError('CUTS_RANGE_UPPER', `${segment.part} is the upper segment of ${segment.limb}; only a segment below a joint takes a range`);
105
+ if (value && !value.every(n => typeof n === 'number' && Number.isFinite(n))) throw new CutEditError('CUTS_VALUE_INVALID', `${segment.part} range must be two numbers of degrees`);
106
+ const next = new Map(edits), path = rangePath(segment.limb, segment.index);
107
+ if (same(value, segment.range)) next.delete(path); else next.set(path, { kind: 'range', path, name: segment.part, label: `${segment.limb} ${segment.joint} range (${segment.part})`, from: segment.range ? [...segment.range] : null, value: value ? [...value] : null });
108
+ return next;
109
+ }
110
+ /** Where a vertex stands now: its pending value, else the config's. */
111
+ export const vertexAt = (edits: ReadonlyMap<string, CutEdit>, cut: ConfigCut, k: number): RigPoint => (edits.get(vertexPath(cut.index, k)) as Extract<CutEdit, { kind: 'vertex' }> | undefined)?.value ?? cut.polygon[k]!;
112
+ export const boxAt = (edits: ReadonlyMap<string, CutEdit>, patch: ConfigPatchEntry): PatchBox => (edits.get(boxPath(patch.index)) as Extract<CutEdit, { kind: 'box' }> | undefined)?.value ?? patch.box;
113
+ export const rangeAt = (edits: ReadonlyMap<string, CutEdit>, s: ConfigLimbSegment): [number, number] | null => { const e = edits.get(rangePath(s.limb, s.index)) as Extract<CutEdit, { kind: 'range' }> | undefined; return e ? e.value : s.range; };
114
+
115
+ /** A drag moves a point by the pointer's delta snapped to whole rig px. */
116
+ export const dragPoint = (from: RigPoint, start: RigPoint, now: RigPoint): RigPoint => [0, 1].map(i => { const d = Math.round(now[i]! - start[i]!); return from[i]! + (d === 0 ? 0 : d); }) as RigPoint;
117
+
118
+ /**
119
+ * The pending edits and additions as config patch ops, in order: the edits (each one op at its own pointer, naming its
120
+ * entry), then each addition's entry and its `regions` bone. A new name is not checked against the config: the
121
+ * producer's `CONFIG_PART_UNIQUE` says so, verbatim.
122
+ */
123
+ export function cutOps(edits: ReadonlyMap<string, CutEdit>, adds: readonly CutAdd[], config: unknown): PatchOp[] {
124
+ const ops: PatchOp[] = [];
125
+ for (const e of edits.values()) {
126
+ if (e.kind === 'range') {
127
+ if (e.value === null) ops.push({ op: 'remove', path: e.path, name: e.name });
128
+ else ops.push({ op: e.from === null ? 'add' : 'replace', path: e.path, value: [...e.value], name: e.name });
129
+ } else ops.push({ op: 'replace', path: e.path, value: [...e.value], name: e.name });
130
+ }
131
+ const assemble = isObject(config) && isObject(config.assemble) ? config.assemble : {};
132
+ let cuts = Array.isArray(assemble.cuts) ? assemble.cuts.length : null, patches = Array.isArray(assemble.patches) ? assemble.patches.length : null;
133
+ let regions = isObject(config) && isObject(config.regions);
134
+ for (const a of adds) {
135
+ const list = a.kind === 'cut' ? 'cuts' : 'patches', has = a.kind === 'cut' ? cuts !== null : patches !== null;
136
+ const entry: JsonValue = a.kind === 'cut'
137
+ ? { from: a.from, into: a.into, polygon: a.polygon.map(p => [rigInt(p[0], `${a.into} x`), rigInt(p[1], `${a.into} y`)]), draw: a.draw as JsonValue, overlap: rigInt(a.overlap, `${a.into} overlap`) }
138
+ : { name: a.name, box: a.box.map((n, i) => rigInt(n, `${a.name} box[${i}]`)), alpha: a.alpha, draw: a.draw as JsonValue };
139
+ if (a.kind === 'cut' && a.polygon.length < 3) throw new CutEditError('CUTS_POLYGON_SHORT', `${a.into} needs at least three points`);
140
+ ops.push(has ? { op: 'add', path: `/assemble/${list}/-`, value: entry } : { op: 'add', path: `/assemble/${list}`, value: [entry] });
141
+ if (a.kind === 'cut') cuts = (cuts ?? 0) + 1; else patches = (patches ?? 0) + 1;
142
+ const name = a.kind === 'cut' ? a.into : a.name;
143
+ if (!regions) { ops.push({ op: 'add', path: '/regions', value: {} }); regions = true; }
144
+ ops.push({ op: 'add', path: `/regions/${name.replace(/~/g, '~0').replace(/\//g, '~1')}`, value: a.bone });
145
+ }
146
+ return ops;
147
+ }
148
+
149
+ /**
150
+ * A segment's range as an arc for the overlay: the joint (the lower bone's origin), the bone's rest direction (rig px,
151
+ * y down) and the two ends `min`, `max` turned counter-clockwise on screen (rigc's sense), sampled every `step` degrees.
152
+ */
153
+ export function rangeArc(joint: RigPoint, toward: RigPoint, range: [number, number], radius: number, step = 5): RigPoint[] {
154
+ const rest = Math.atan2(toward[1] - joint[1], toward[0] - joint[0]);
155
+ const [lo, hi] = range[0] <= range[1] ? range : [range[1], range[0]];
156
+ const out: RigPoint[] = [[joint[0], joint[1]]];
157
+ for (let d = lo; ; d = Math.min(hi, d + step)) {
158
+ // Counter-clockwise on screen with y down is a negative angle in rig px.
159
+ const a = rest - (d * Math.PI) / 180;
160
+ out.push([joint[0] + radius * Math.cos(a), joint[1] + radius * Math.sin(a)]);
161
+ if (d >= hi) break;
162
+ }
163
+ return out;
164
+ }
165
+
166
+ export const CUTS_DRAFT_SCHEMA = 'rig-review-cuts-draft/1';
167
+ export const cutsDraftText = (edits: ReadonlyMap<string, CutEdit>, adds: readonly CutAdd[]) => JSON.stringify({ schema: CUTS_DRAFT_SCHEMA, edits: [...edits.values()], adds });
168
+ /** Edits back from a draft, kept only where the pointer still names the same entry with the same config value. */
169
+ export function cutsDraft(text: string, source: CutsSource): { edits: Map<string, CutEdit>; adds: CutAdd[] } {
170
+ const empty = { edits: new Map<string, CutEdit>(), adds: [] as CutAdd[] };
171
+ let doc: unknown;
172
+ try { doc = JSON.parse(text); } catch { return empty; }
173
+ if (!isObject(doc) || doc.schema !== CUTS_DRAFT_SCHEMA || !Array.isArray(doc.edits)) return empty;
174
+ let edits = new Map<string, CutEdit>();
175
+ for (const e of doc.edits as CutEdit[]) {
176
+ try {
177
+ if (e.kind === 'vertex') { const m = /^\/assemble\/cuts\/(\d+)\/polygon\/(\d+)$/.exec(e.path); const cut = m ? source.cuts.find(c => c.index === Number(m[1])) : undefined; if (cut && cut.into === e.name && same(cut.polygon[Number(m![2])] ?? null, e.from)) edits = setVertex(edits, cut, Number(m![2]), e.value); }
178
+ else if (e.kind === 'box') { const patch = source.patches.find(q => boxPath(q.index) === e.path); if (patch && patch.name === e.name && same(patch.box, e.from)) edits = setBox(edits, patch, e.value); }
179
+ else if (e.kind === 'range') { const s = source.segments.find(g => rangePath(g.limb, g.index) === e.path); if (s && s.part === e.name && same(s.range, e.from)) edits = setRange(edits, s, e.value); }
180
+ } catch { /* a draft value the editor would refuse is dropped */ }
181
+ }
182
+ const adds = Array.isArray(doc.adds) ? (doc.adds as CutAdd[]).filter(a => isObject(a) && (a.kind === 'cut' || a.kind === 'patch')) : [];
183
+ return { edits, adds };
184
+ }
@@ -0,0 +1,226 @@
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
+ /** The whole standing state, clock and active pulse included (#103: an Apply's reload resumes the same world). */
55
+ state():WindState;resume(value:WindState):void;
56
+ }
57
+ export interface WindState {snapshot:WindSnapshot;time:number;gustStart:number|null;gustDirection:number;nextGust:number|null;activeGustPeak:number;activeGustDuration:number}
58
+ export const defaultWindSnapshot=():WindSnapshot=>({version:1,base:0,breeze:false,repeat:false,gustPeak:GUST_PEAK,gustDuration:GUST_DURATION});
59
+
60
+ export function createWindModel():WindModel {
61
+ let base=0,breeze=false,repeat=false,time=0,gustStart:number|null=null,gustDirection=1,nextGust:number|null=null;
62
+ let gustPeak=GUST_PEAK,gustDuration=GUST_DURATION,activeGustPeak=GUST_PEAK,activeGustDuration=GUST_DURATION;
63
+ function start(at:number):void {
64
+ gustStart=at;gustDirection=base<0?-1:1;activeGustPeak=gustPeak;activeGustDuration=gustDuration;
65
+ if(repeat)nextGust=at+gustPeriod(activeGustDuration);
66
+ }
67
+ function read():WindReading {
68
+ const age=gustStart===null?activeGustDuration:time-gustStart;
69
+ const gusting=age>=0&&age<activeGustDuration;
70
+ const gust=gusting?gustDirection*gustEnvelope(age,activeGustPeak,activeGustDuration):0;
71
+ const gentle=breeze?breezeAt(time):0;
72
+ return {base,breeze,repeat,time,gentle,gust,gusting,gustAge:gustStart===null?null:age,wind:base+gentle+gust,gustPeak,gustDuration,activeGustPeak,activeGustDuration};
73
+ }
74
+ return {
75
+ read,
76
+ snapshot:()=>({version:1,base,breeze,repeat,gustPeak,gustDuration}),
77
+ restore(value){
78
+ const s=value===null?defaultWindSnapshot():value;
79
+ if(!s||typeof s!=='object'||Array.isArray(s)||s.version!==1)throw new RangeError('wind snapshot must be a version 1 object');
80
+ // Validate the complete configuration before changing the standing state.
81
+ const nextBase=bounded(s.base,-BASE_WIND_LIMIT,BASE_WIND_LIMIT,'base wind');
82
+ const nextPeak=bounded(s.gustPeak,0,MAX_GUST_PEAK,'gust peak');
83
+ const nextDuration=bounded(s.gustDuration,MIN_GUST_DURATION,MAX_GUST_DURATION,'gust duration');
84
+ if(typeof s.breeze!=='boolean'||typeof s.repeat!=='boolean')throw new RangeError('wind snapshot breeze and repeat must be booleans');
85
+ base=nextBase;gustPeak=nextPeak;gustDuration=nextDuration;breeze=s.breeze;repeat=s.repeat;
86
+ time=0;gustStart=null;gustDirection=1;nextGust=null;activeGustPeak=gustPeak;activeGustDuration=gustDuration;
87
+ if(repeat)start(0);
88
+ },
89
+ setBase(value){base=bounded(value,-BASE_WIND_LIMIT,BASE_WIND_LIMIT,'base wind');},
90
+ setGustPeak(value){gustPeak=bounded(value,0,MAX_GUST_PEAK,'gust peak');},
91
+ setGustDuration(value){gustDuration=bounded(value,MIN_GUST_DURATION,MAX_GUST_DURATION,'gust duration');},
92
+ setBreeze(value){breeze=Boolean(value);},
93
+ // A click cannot interrupt a pulse.
94
+ triggerGust(){if(!read().gusting)start(time);},
95
+ setRepeat(value){
96
+ const enabled=Boolean(value);if(enabled===repeat)return;repeat=enabled;
97
+ if(repeat){if(read().gusting)nextGust=gustStart!+gustPeriod(activeGustDuration);else start(time);}
98
+ else nextGust=null; // Finish the active pulse before returning to base.
99
+ },
100
+ state:()=>({snapshot:{version:1,base,breeze,repeat,gustPeak,gustDuration},time,gustStart,gustDirection,nextGust,activeGustPeak,activeGustDuration}),
101
+ resume(value){
102
+ const saved=structuredClone(value);this.restore(saved.snapshot);
103
+ time=saved.time;gustStart=saved.gustStart;gustDirection=saved.gustDirection;nextGust=saved.nextGust;activeGustPeak=saved.activeGustPeak;activeGustDuration=saved.activeGustDuration;
104
+ },
105
+ advance(dt){
106
+ 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');
107
+ time+=dt;
108
+ if(repeat&&nextGust!==null&&time>=nextGust){const interval=gustPeriod(gustDuration);start(nextGust+Math.floor((time-nextGust)/interval)*interval);}
109
+ return read();
110
+ },
111
+ };
112
+ }
113
+
114
+ /** The value `player.environment` receives for a reading: wind along x, gravity down. */
115
+ export function environmentFor(reading:WindReading,gravity:number):{wind:[number,number];gravity:[number,number]} {
116
+ return {wind:[reading.wind,0],gravity:[0,bounded(gravity,GRAVITY_MIN,GRAVITY_MAX,'gravity')]};
117
+ }
118
+
119
+ const fixed=(value:number,digits=1)=>(Object.is(value,-0)?0:value).toFixed(digits);
120
+ /** `wind: base + breeze + gust = total` with the gust phase and the stepped clock. */
121
+ export function windReadout(reading:WindReading,gravity:number):string {
122
+ const phase=reading.gusting&&reading.gustAge!==null?` · gust ${reading.gustAge.toFixed(2)}/${reading.activeGustDuration.toFixed(2)} s`:'';
123
+ 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}`;
124
+ }
125
+ export const RUNTIME_START_READOUT=`Runtime start values · wind [${RUNTIME_START.wind.join(', ')}] · gravity [${RUNTIME_START.gravity.join(', ')}] · controls not applied`;
126
+
127
+ /** The example's jolt: translate (18, −10), then the inverse 0.09 s later. */
128
+ export interface JoltStep {at:number;x:number;y:number}
129
+ export const JOLT_DELAY=0.09;
130
+ export function joltPacket(start:number):JoltStep[] {return [{at:start,x:18,y:-10},{at:start+JOLT_DELAY,x:-18,y:10}];}
131
+ /** Splits a queue into the steps due at `now` (in order) and the rest. */
132
+ export function dueJolts(queue:readonly JoltStep[],now:number):{due:JoltStep[];pending:JoltStep[]} {
133
+ const due:JoltStep[]=[],pending:JoltStep[]=[];for(const step of queue)(step.at<=now?due:pending).push(step);
134
+ due.sort((a,b)=>a.at-b.at);return {due,pending};
135
+ }
136
+
137
+ /** A page-pixel pointer delta as `player.moved` wants it: ÷ the root's scale, y negated (world y up). */
138
+ export function dragMoved(dx:number,dy:number,scale:number):{x:number;y:number}|null {
139
+ if(!Number.isFinite(dx)||!Number.isFinite(dy)||!Number.isFinite(scale)||scale<=0)return null;
140
+ return {x:dx/scale,y:-dy/scale};
141
+ }
142
+
143
+ /** Columns read as the compiled model declares them; an absent field stays absent. */
144
+ export const PHYSICS_FIELDS=['wind','gravity','inertia','strength','damping','mix'] as const;
145
+ export type PhysicsField=typeof PHYSICS_FIELDS[number];
146
+ export interface PhysicsConstraintRow {name:string;bone:string|null;declaredIn:string|null;values:Partial<Record<PhysicsField,number>>}
147
+ export type PhysicsConstraints={available:true;rows:PhysicsConstraintRow[]}|{available:false;reason:string};
148
+ export const CONSTRAINT_MIX_READONLY='CONSTRAINT_MIX_READONLY: @rig-c/play exposes no per-constraint mix';
149
+
150
+ /** Lists the document's `constraints` entries of kind `physics` in their order; nothing is computed or defaulted. */
151
+ export function physicsConstraints(document:unknown):PhysicsConstraints {
152
+ if(!document||typeof document!=='object'||Array.isArray(document))return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: model document is not an object'};
153
+ const list=(document as Record<string,unknown>).constraints;
154
+ if(!Array.isArray(list))return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: model declares no constraints list'};
155
+ const rows:PhysicsConstraintRow[]=[];
156
+ for(const item of list){
157
+ if(!item||typeof item!=='object'||Array.isArray(item))continue;
158
+ const entry=item as Record<string,unknown>;if(entry.kind!=='physics')continue;
159
+ if(typeof entry.name!=='string'||!entry.name)return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: a physics constraint has no name'};
160
+ const values:Partial<Record<PhysicsField,number>>={};
161
+ for(const field of PHYSICS_FIELDS){const value=entry[field];if(typeof value==='number'&&Number.isFinite(value))values[field]=value;}
162
+ rows.push({name:entry.name,bone:typeof entry.bone==='string'?entry.bone:null,declaredIn:typeof entry.declaredIn==='string'?entry.declaredIn:null,values});
163
+ }
164
+ if(!rows.length)return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: model declares no physics constraints'};
165
+ return {available:true,rows};
166
+ }
167
+
168
+ /** The reviewed model document, its bytes hash-checked against the reviewed identity first. */
169
+ export async function loadModelDocument(url:string,sha256:string,fetcher:typeof fetch=fetch):Promise<{ok:true;document:unknown}|{ok:false;reason:string}> {
170
+ const response=await fetcher(url);if(!response.ok)return {ok:false,reason:`model request failed (${response.status})`};
171
+ const bytes=await response.arrayBuffer();if(bytes.byteLength>128*1024*1024)return {ok:false,reason:'model exceeds 128 MiB'};
172
+ const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(v=>v.toString(16).padStart(2,'0')).join('');
173
+ if(hash!==sha256)return {ok:false,reason:'model bytes differ from reviewed SHA-256'};
174
+ try{return {ok:true,document:JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes))};}
175
+ catch(error){return {ok:false,reason:error instanceof Error?error.message:String(error)};}
176
+ }
177
+ /** Reads the reviewed model bytes (hash-checked against the reviewed identity) and lists its physics constraints. */
178
+ export async function loadPhysicsConstraints(url:string,sha256:string,fetcher:typeof fetch=fetch):Promise<PhysicsConstraints> {
179
+ const loaded=await loadModelDocument(url,sha256,fetcher);
180
+ return loaded.ok?physicsConstraints(loaded.document):{available:false,reason:`PHYSICS_CONSTRAINTS_UNAVAILABLE: ${loaded.reason}`};
181
+ }
182
+
183
+ /** What a feedback record keeps of the environment it was written under. */
184
+ export interface EnvironmentContext {
185
+ applied:boolean;
186
+ wind:{base:number;breeze:boolean;gust:{peak:number;duration:number;repeat:boolean;active:boolean;value:number};total:number};
187
+ gravity:number;
188
+ /** What the player reported standing when the note was written (x right, y down). */
189
+ standing:{wind:[number,number];gravity:[number,number]};
190
+ }
191
+ export function environmentContext(reading:WindReading,gravity:number,applied:boolean,standing:{wind:readonly number[];gravity:readonly number[]}):EnvironmentContext {
192
+ 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]!]}};
193
+ }
194
+
195
+ const fail=(field:string,expected:string):never=>{throw new Error(`feedback.context.environment${field}: expected ${expected}`);};
196
+ const exactKeys=(value:unknown,keys:readonly string[],field:string):Record<string,unknown>=>{
197
+ if(!value||typeof value!=='object'||Array.isArray(value))fail(field,'object');
198
+ const o=value as Record<string,unknown>;
199
+ for(const key of Object.keys(o))if(!keys.includes(key))fail(field,`only ${keys.join(', ')}`);
200
+ return o;
201
+ };
202
+ const finite=(value:unknown,field:string):number=>typeof value==='number'&&Number.isFinite(value)?value:fail(field,'finite number');
203
+ const flag=(value:unknown,field:string):boolean=>typeof value==='boolean'?value:fail(field,'boolean');
204
+ 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]');
205
+ /** Validates `context.environment`; null means no compiled model was drawn. Absent stays valid for older records. */
206
+ export function parseEnvironmentContext(value:unknown):EnvironmentContext|null {
207
+ if(value===null)return null;
208
+ const o=exactKeys(value,['applied','wind','gravity','standing'],'');
209
+ 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');
210
+ const result:EnvironmentContext={applied:flag(o.applied,'.applied'),
211
+ 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')},
212
+ gravity:finite(o.gravity,'.gravity'),standing:{wind:pair(standing.wind,'.standing.wind'),gravity:pair(standing.gravity,'.standing.gravity')}};
213
+ if(Math.abs(result.wind.base)>BASE_WIND_LIMIT)fail('.wind.base',`within ±${BASE_WIND_LIMIT}`);
214
+ if(result.wind.gust.peak<0||result.wind.gust.peak>MAX_GUST_PEAK)fail('.wind.gust.peak',`within [0, ${MAX_GUST_PEAK}]`);
215
+ 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}]`);
216
+ if(result.gravity<GRAVITY_MIN||result.gravity>GRAVITY_MAX)fail('.gravity',`within [${GRAVITY_MIN}, ${GRAVITY_MAX}]`);
217
+ return result;
218
+ }
219
+ /** One line for issue text: "under gust 48 / wind 30". */
220
+ export function environmentSummary(value:EnvironmentContext):string {
221
+ const s=value.standing,standing=`standing wind [${s.wind.join(', ')}] · gravity [${s.gravity.join(', ')}]`;
222
+ if(!value.applied)return `runtime start values · ${standing}`;
223
+ 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)`;
224
+ return `wind ${w.base}${w.breeze?' + breeze':''} · ${gust}${w.gust.repeat?' · repeat':''} · total ${fixed(w.total)} · gravity ${value.gravity} · ${standing}`;
225
+ }
226
+ export const environmentJson=(value:EnvironmentContext):Json=>JSON.parse(JSON.stringify(value)) as Json;
@@ -4,6 +4,9 @@ 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';
8
+ import { appliedSummary } from './applied-context.ts';
9
+ import { requirementsSummaryLine } from './requirements.ts';
7
10
  type Category = Feedback['category'];
8
11
  type Side = 'single' | 'a' | 'b';
9
12
  type ParsedFeedbackSession = FeedbackHistory<ReviewSession | CompareSession>;
@@ -155,6 +158,24 @@ function jsonBlock(value: unknown): string {
155
158
  return `${fence}json\n${json}\n${fence}`;
156
159
  }
157
160
 
161
+ /** "Under gust 48 / wind 30": a readable line when the record carries a valid environment; the raw context still follows. */
162
+ function environmentLine(context: Json | undefined): string[] {
163
+ if (context === undefined || context === null || typeof context !== 'object' || Array.isArray(context) || !('environment' in context)) return [];
164
+ try { const value = parseEnvironmentContext(context.environment); return [`- Environment: ${value === null ? 'no compiled model drawn' : feedbackMarkdownText(environmentSummary(value))}`]; }
165
+ catch { return []; }
166
+ }
167
+ /** "Applied: patch … · check PASS": the build the note was written on, when the record carries a valid applied state. */
168
+ function appliedLine(context: Json | undefined): string[] {
169
+ if (context === undefined || context === null || typeof context !== 'object' || Array.isArray(context) || !('applied' in context)) return [];
170
+ const text = appliedSummary(context.applied);
171
+ return text === null ? [] : [`- Applied: ${feedbackMarkdownText(text)}`];
172
+ }
173
+ /** "Requirements: 3 declared · requirements file sha256 … · requirements: …": the declared requirements the note carries (#113). */
174
+ function requirementsLine(context: Json | undefined): string[] {
175
+ if (context === undefined || context === null || typeof context !== 'object' || Array.isArray(context) || !('requirements' in context)) return [];
176
+ const text = requirementsSummaryLine(context.requirements);
177
+ return text === null ? [] : [`- Requirements: ${feedbackMarkdownText(text)}`];
178
+ }
158
179
  /** Local issue-ready text only. No issue creation, clock inference or source IO. */
159
180
  export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
160
181
  const model = groupFeedbackIssues(input), md = feedbackMarkdownText;
@@ -185,7 +206,10 @@ export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
185
206
  `- Target: ${md(item.target.kind)}: ${md(item.target.name)}`,
186
207
  `- Inclusive frames: ${item.frameStart}–${item.frameEnd}`,
187
208
  `- Side: ${item.side}`,
188
- `- Summary: ${md(item.summary)}`, '',
209
+ `- Summary: ${md(item.summary)}`,
210
+ ...environmentLine(item.context),
211
+ ...appliedLine(item.context),
212
+ ...requirementsLine(item.context), '',
189
213
  'Selected build identity:', '', jsonBlock(item.selectedSource));
190
214
  if (model.pairedBinding) lines.push('', 'Paired comparison binding:', '', jsonBlock(model.pairedBinding));
191
215
  if (item.warnings.length) lines.push('', 'Validation warnings (record retained):', '', jsonBlock(item.warnings));
package/src/front.ts CHANGED
@@ -55,6 +55,17 @@ export function frontRoute(mode:'single'|'compare',method:string,path:string):Ro
55
55
  const reads=mode==='single'?['/api/review','/review-reference']:['/api/compare','/api/compare/reference-frames','/compare-reference'];
56
56
  if(reads.includes(path))return read?get:null;
57
57
  if((mode==='single'?/^\/assets\/[^/]/:/^\/compare-assets\/(a|b)\/[^/]/).test(path))return read?get:null;
58
+ // Apply · Rebuild (single only): status, one typed request, a job's status and its served scratch build.
59
+ if(mode==='single'&&path==='/api/rebuild')return read?get:method==='POST'?{mutation:true,limit:64*1024,timeoutMs:30000,control:false}:null;
60
+ if(mode==='single'&&(/^\/api\/rebuild\/[a-f0-9]{64}$/.test(path)||/^\/rebuild\/[a-f0-9]{64}\/[^/]/.test(path)))return read?get:null;
61
+ // Bones check (#109, single only): one typed request {ops, configHash}, answered in the response.
62
+ if(mode==='single'&&path==='/api/bones-check')return method==='POST'?{mutation:true,limit:64*1024,timeoutMs:60000,control:false}:null;
63
+ // Readings (#108, single only): one typed request, then its job's status.
64
+ if(mode==='single'&&path==='/api/readings')return method==='POST'?{mutation:true,limit:16*1024,timeoutMs:30000,control:false}:null;
65
+ if(mode==='single'&&/^\/api\/readings\/[a-f0-9]{64}$/.test(path))return read?get:null;
66
+ // Declared requirements (#113, single only): the stage's catalog, one typed request, then its job's status.
67
+ if(mode==='single'&&path==='/api/requirements')return read?get:method==='POST'?{mutation:true,limit:64*1024,timeoutMs:30000,control:false}:null;
68
+ if(mode==='single'&&/^\/api\/requirements\/[a-f0-9]{64}$/.test(path))return read?get:null;
58
69
  return null;
59
70
  }
60
71
  async function boundedBody(req:Request,limit:number,timeoutMs=10000):Promise<ArrayBuffer>{