@rig-c/review 0.4.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 +87 -12
- package/docs/DESIGN.md +18 -8
- package/docs/REVIEW_PACKAGE.md +2 -0
- package/package.json +1 -1
- package/src/chest-regions.ts +306 -0
- package/src/environment.ts +213 -0
- package/src/feedback-export.ts +9 -1
- package/src/mesh-edit.ts +287 -0
- package/src/session.ts +3 -0
- package/web/chest-editor.ts +179 -0
- package/web/chest-regions-tool.ts +56 -0
- package/web/compare.css +1 -0
- package/web/index.html +3 -6
- package/web/main.ts +210 -42
- package/web/mesh-editor.ts +268 -0
- package/web/part-selection.ts +13 -5
- package/web/part-thumbnails.ts +76 -0
- package/web/rig-overlays.ts +74 -3
- package/web/styles.css +104 -23
- package/web/view-controls.ts +11 -5
- package/web/workspace-layout.ts +2 -1
- package/web/workspace-preferences.ts +17 -12
- package/web/workspace-ui.ts +26 -12
|
@@ -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;
|
package/src/feedback-export.ts
CHANGED
|
@@ -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));
|
package/src/mesh-edit.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesh edit (Setup): a typed delta over the actual loaded mesh attachment of one slot. Pure — no DOM, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* The source is the attachment the hash-verified model declares, with its vertices taken from the last drawn pose of
|
|
5
|
+
* that slot and mapped to crop pixels (x right, y down) through the same crop mapping as the chest regions. Ops are
|
|
6
|
+
* recorded in order; nothing is retriangulated here — the preview draws the delta over the original triangulation and
|
|
7
|
+
* the producer (#45) owns the mesher. Vertex ids are stable: the source vertices keep their indices, an added vertex
|
|
8
|
+
* takes the next id after every vertex that existed before it, and a removed vertex keeps its id (it is only marked).
|
|
9
|
+
*/
|
|
10
|
+
export type Vec = [number, number];
|
|
11
|
+
export type MeshOp =
|
|
12
|
+
| { op: 'move'; v: number; from: Vec; to: Vec }
|
|
13
|
+
| { op: 'add'; at: Vec; onEdge?: [number, number] }
|
|
14
|
+
| { op: 'remove'; v: number }
|
|
15
|
+
| { op: 'edge'; a: number; b: number }
|
|
16
|
+
| { op: 'unedge'; a: number; b: number };
|
|
17
|
+
export type EdgeKind = 'hull' | 'interior' | 'constraint';
|
|
18
|
+
export interface MeshEdge { a: number; b: number; kind: EdgeKind }
|
|
19
|
+
export interface MeshSource {
|
|
20
|
+
slot: string; skin: string; attachment: string;
|
|
21
|
+
/** Crop px, x right, y down, one per source vertex. */
|
|
22
|
+
vertices: readonly Vec[];
|
|
23
|
+
triangles: readonly number[];
|
|
24
|
+
/** The model's `hull`: the first `hull` vertices, in order, are the hull. */
|
|
25
|
+
hull: number;
|
|
26
|
+
/** SHA-256 of the attachment's entry in the hash-verified model (see `attachmentHashText`). */
|
|
27
|
+
hash: string;
|
|
28
|
+
}
|
|
29
|
+
export interface MeshView {
|
|
30
|
+
/** Current position per vertex id; null when removed. */
|
|
31
|
+
vertices: (Vec | null)[];
|
|
32
|
+
edges: MeshEdge[];
|
|
33
|
+
/** Edges the delta removed (explicitly, or with a removed vertex), for the preview. */
|
|
34
|
+
removedEdges: MeshEdge[];
|
|
35
|
+
added: Set<number>;
|
|
36
|
+
/** Moved vertex id → its source (or creation) position. */
|
|
37
|
+
moved: Map<number, Vec>;
|
|
38
|
+
removed: Set<number>;
|
|
39
|
+
}
|
|
40
|
+
export type MeshEditResult = { ok: true; changed: boolean; code?: string } | { ok: false; code: string };
|
|
41
|
+
|
|
42
|
+
export const MESH_EDIT_SCHEMA = 'rig-review-mesh-edit/1';
|
|
43
|
+
export const MESH_EDIT_BINDING_SCHEMA = 'rig-review-mesh-edit-binding/1';
|
|
44
|
+
export const MESH_EDIT_DRAFT_SCHEMA = 'rig-review-mesh-edit-draft/1';
|
|
45
|
+
export const MESH_EDIT_SELECT_SLOT = 'MESH_EDIT_SELECT_SLOT';
|
|
46
|
+
export const MESH_EDIT_NO_MESH = 'MESH_EDIT_NO_MESH';
|
|
47
|
+
export const MESH_EDIT_HULL_EDGE = 'MESH_EDIT_HULL_EDGE';
|
|
48
|
+
export const MESH_EDIT_EDGE_EXISTS = 'MESH_EDIT_EDGE_EXISTS';
|
|
49
|
+
export const MESH_EDIT_TRIANGULATION_EDGE = 'MESH_EDIT_TRIANGULATION_EDGE';
|
|
50
|
+
export const MESH_EDIT_INVALID_OP = 'MESH_EDIT_INVALID_OP';
|
|
51
|
+
export const MESH_EDIT_OPS_LIMIT = 'MESH_EDIT_OPS_LIMIT';
|
|
52
|
+
/** The draft rides in one form field (≤ 32768 characters), so the delta is bounded. */
|
|
53
|
+
export const MESH_EDIT_MAX_OPS = 300;
|
|
54
|
+
export const MESH_EDIT_PREVIEW_NOTE = 'preview only — the producer retriangulates';
|
|
55
|
+
/** Screen-space hit radii in CSS px. */
|
|
56
|
+
export const MESH_HIT = Object.freeze({ vertex: 6, vertexTouch: 8, edge: 4 });
|
|
57
|
+
|
|
58
|
+
const round = (value: number) => Math.round(value * 100) / 100;
|
|
59
|
+
export const roundVec = (p: { x: number; y: number } | Vec): Vec => Array.isArray(p) ? [round(p[0]), round(p[1])] : [round(p.x), round(p.y)];
|
|
60
|
+
export const edgeKey = (a: number, b: number) => (a < b ? `${a}:${b}` : `${b}:${a}`);
|
|
61
|
+
const isHullPair = (a: number, b: number, hull: number) => a < hull && b < hull && (Math.abs(a - b) === 1 || Math.abs(a - b) === hull - 1) && hull > 2;
|
|
62
|
+
|
|
63
|
+
/** The original triangulation's unique edges; consecutive hull vertices (and the closing pair) are hull edges. */
|
|
64
|
+
export function sourceEdges(source: Pick<MeshSource, 'triangles' | 'hull' | 'vertices'>): MeshEdge[] {
|
|
65
|
+
const seen = new Map<string, MeshEdge>(), n = source.vertices.length;
|
|
66
|
+
const add = (a: number, b: number) => { if (a === b || a < 0 || b < 0 || a >= n || b >= n) return; const key = edgeKey(a, b); if (!seen.has(key)) seen.set(key, { a: Math.min(a, b), b: Math.max(a, b), kind: isHullPair(a, b, source.hull) ? 'hull' : 'interior' }); };
|
|
67
|
+
for (let i = 0; i + 2 < source.triangles.length; i += 3) { const t = source.triangles; add(t[i]!, t[i + 1]!); add(t[i + 1]!, t[i + 2]!); add(t[i + 2]!, t[i]!); }
|
|
68
|
+
for (let i = 0; i < source.hull && source.hull > 2; i++) add(i, (i + 1) % source.hull);
|
|
69
|
+
return [...seen.values()];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const finiteVec = (v: unknown): v is Vec => Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number' && Number.isFinite(n));
|
|
73
|
+
const index = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
74
|
+
|
|
75
|
+
/** Replays ops over the source. Throws `MESH_EDIT_INVALID_OP` naming the first op that does not apply. */
|
|
76
|
+
export function applyMeshOps(source: MeshSource, ops: readonly MeshOp[]): MeshView {
|
|
77
|
+
const view: MeshView = { vertices: source.vertices.map(v => [v[0], v[1]] as Vec), edges: [], removedEdges: [], added: new Set(), moved: new Map(), removed: new Set() };
|
|
78
|
+
const edges = new Map(sourceEdges(source).map(e => [edgeKey(e.a, e.b), { ...e }]));
|
|
79
|
+
const fail = (i: number, why: string): never => { throw new Error(`${MESH_EDIT_INVALID_OP}: op ${i} ${why}`); };
|
|
80
|
+
const live = (v: unknown, i: number): number => { if (!index(v) || v >= view.vertices.length || !view.vertices[v]) fail(i, `names no live vertex ${String(v)}`); return v as number; };
|
|
81
|
+
const removeEdge = (key: string) => { const edge = edges.get(key); if (edge) { edges.delete(key); view.removedEdges.push(edge); } };
|
|
82
|
+
ops.forEach((op, i) => {
|
|
83
|
+
switch (op?.op) {
|
|
84
|
+
case 'move': {
|
|
85
|
+
const v = live(op.v, i); if (!finiteVec(op.from) || !finiteVec(op.to)) fail(i, 'needs finite from/to');
|
|
86
|
+
if (!view.moved.has(v)) view.moved.set(v, [...view.vertices[v]!] as Vec);
|
|
87
|
+
view.vertices[v] = [op.to[0], op.to[1]]; break;
|
|
88
|
+
}
|
|
89
|
+
case 'add': {
|
|
90
|
+
if (!finiteVec(op.at)) fail(i, 'needs a finite at');
|
|
91
|
+
const id = view.vertices.length;
|
|
92
|
+
if (op.onEdge !== undefined) {
|
|
93
|
+
if (!Array.isArray(op.onEdge) || op.onEdge.length !== 2) fail(i, 'onEdge must be a pair');
|
|
94
|
+
const [a, b] = [live(op.onEdge![0], i), live(op.onEdge![1], i)], key = edgeKey(a, b), edge = edges.get(key);
|
|
95
|
+
if (!edge) fail(i, `splits a missing edge ${a}-${b}`);
|
|
96
|
+
edges.delete(key); view.vertices.push([op.at[0], op.at[1]]);
|
|
97
|
+
edges.set(edgeKey(a, id), { a: Math.min(a, id), b: Math.max(a, id), kind: edge!.kind });
|
|
98
|
+
edges.set(edgeKey(id, b), { a: Math.min(b, id), b: Math.max(b, id), kind: edge!.kind });
|
|
99
|
+
} else view.vertices.push([op.at[0], op.at[1]]);
|
|
100
|
+
view.added.add(id); break;
|
|
101
|
+
}
|
|
102
|
+
case 'remove': {
|
|
103
|
+
const v = live(op.v, i); view.vertices[v] = null; view.removed.add(v); view.moved.delete(v);
|
|
104
|
+
for (const [key, edge] of [...edges]) if (edge.a === v || edge.b === v) removeEdge(key);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case 'edge': {
|
|
108
|
+
const a = live(op.a, i), b = live(op.b, i); if (a === b) fail(i, 'joins a vertex to itself');
|
|
109
|
+
if (edges.has(edgeKey(a, b))) fail(i, `duplicates edge ${a}-${b}`);
|
|
110
|
+
edges.set(edgeKey(a, b), { a: Math.min(a, b), b: Math.max(a, b), kind: 'constraint' }); break;
|
|
111
|
+
}
|
|
112
|
+
case 'unedge': {
|
|
113
|
+
const a = live(op.a, i), b = live(op.b, i), edge = edges.get(edgeKey(a, b));
|
|
114
|
+
if (!edge) fail(i, `removes a missing edge ${a}-${b}`); if (edge!.kind === 'hull') fail(i, `removes hull edge ${a}-${b}`);
|
|
115
|
+
removeEdge(edgeKey(a, b)); break;
|
|
116
|
+
}
|
|
117
|
+
default: fail(i, 'has an unknown op');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
view.edges = [...edges.values()];
|
|
121
|
+
return view;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The ordered delta with per-action undo/redo. One user action (a drag of an edge or of a multi-selection) is one step of one or more ops. */
|
|
125
|
+
export class MeshEditSession {
|
|
126
|
+
private list: MeshOp[] = [];
|
|
127
|
+
private steps: number[] = [];
|
|
128
|
+
private redoSteps: MeshOp[][] = [];
|
|
129
|
+
private current: MeshView;
|
|
130
|
+
constructor(readonly source: MeshSource, ops: readonly MeshOp[] = []) {
|
|
131
|
+
this.current = applyMeshOps(source, ops); this.list = ops.map(op => structuredClone(op)); this.steps = this.list.map(() => 1);
|
|
132
|
+
}
|
|
133
|
+
ops(): MeshOp[] { return this.list.map(op => structuredClone(op)); }
|
|
134
|
+
view(): MeshView { return this.current; }
|
|
135
|
+
canUndo(): boolean { return this.steps.length > 0; }
|
|
136
|
+
canRedo(): boolean { return this.redoSteps.length > 0; }
|
|
137
|
+
/** Records one step; refuses the whole step when any op does not apply. */
|
|
138
|
+
record(ops: readonly MeshOp[]): MeshEditResult {
|
|
139
|
+
if (!ops.length) return { ok: true, changed: false };
|
|
140
|
+
if (this.list.length + ops.length > MESH_EDIT_MAX_OPS) return { ok: false, code: `${MESH_EDIT_OPS_LIMIT}: at most ${MESH_EDIT_MAX_OPS} ops per attachment — download and reset to continue` };
|
|
141
|
+
let next: MeshView;
|
|
142
|
+
try { next = applyMeshOps(this.source, [...this.list, ...ops]); } catch (error) { return { ok: false, code: error instanceof Error ? error.message : String(error) }; }
|
|
143
|
+
this.list.push(...ops.map(op => structuredClone(op))); this.steps.push(ops.length); this.redoSteps = []; this.current = next;
|
|
144
|
+
return { ok: true, changed: true };
|
|
145
|
+
}
|
|
146
|
+
undo(): boolean {
|
|
147
|
+
const count = this.steps.pop(); if (count === undefined) return false;
|
|
148
|
+
this.redoSteps.push(this.list.splice(this.list.length - count, count));
|
|
149
|
+
this.current = applyMeshOps(this.source, this.list); return true;
|
|
150
|
+
}
|
|
151
|
+
redo(): boolean {
|
|
152
|
+
const ops = this.redoSteps.pop(); if (!ops) return false;
|
|
153
|
+
this.list.push(...ops); this.steps.push(ops.length); this.current = applyMeshOps(this.source, this.list); return true;
|
|
154
|
+
}
|
|
155
|
+
reset(): void { this.list = []; this.steps = []; this.redoSteps = []; this.current = applyMeshOps(this.source, []); }
|
|
156
|
+
|
|
157
|
+
/** Moves vertices by a crop-px offset from their positions at `base` (the drag's start view): one move op per vertex. */
|
|
158
|
+
moveOps(ids: readonly number[], dx: number, dy: number, base: MeshView = this.current): MeshOp[] {
|
|
159
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy) || (dx === 0 && dy === 0)) return [];
|
|
160
|
+
return [...new Set(ids)].flatMap(v => { const from = base.vertices[v]; if (!from) return []; const to = roundVec([from[0] + dx, from[1] + dy]); return to[0] === from[0] && to[1] === from[1] ? [] : [{ op: 'move' as const, v, from: [from[0], from[1]] as Vec, to }]; });
|
|
161
|
+
}
|
|
162
|
+
/** Create: a vertex on empty space, or a split of an edge at the click point. */
|
|
163
|
+
addVertex(at: Vec, onEdge?: [number, number]): MeshEditResult { return this.record([{ op: 'add', at: roundVec(at), ...(onEdge ? { onEdge: [onEdge[0], onEdge[1]] as [number, number] } : {}) }]); }
|
|
164
|
+
/** Delete on a vertex: the vertex and its edges. */
|
|
165
|
+
removeVertex(v: number): MeshEditResult { return this.record([{ op: 'remove', v }]); }
|
|
166
|
+
/** Delete on an edge: any edge but a hull edge. */
|
|
167
|
+
deleteEdge(a: number, b: number): MeshEditResult {
|
|
168
|
+
const edge = this.findEdge(a, b); if (!edge) return { ok: false, code: `${MESH_EDIT_INVALID_OP}: no edge ${a}-${b}` };
|
|
169
|
+
if (edge.kind === 'hull') return { ok: false, code: `${MESH_EDIT_HULL_EDGE}: ${a}-${b} is a hull edge and cannot be removed` };
|
|
170
|
+
return this.record([{ op: 'unedge', a, b }]);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Edges mode on a pair: a new pair adds a constraint edge; an existing constraint edge is removed; a hull edge is
|
|
174
|
+
* refused; any other existing edge is a duplicate and records nothing.
|
|
175
|
+
*/
|
|
176
|
+
toggleConstraint(a: number, b: number): MeshEditResult {
|
|
177
|
+
if (a === b) return { ok: true, changed: false };
|
|
178
|
+
const edge = this.findEdge(a, b);
|
|
179
|
+
if (!edge) return this.record([{ op: 'edge', a, b }]);
|
|
180
|
+
if (edge.kind === 'hull') return { ok: false, code: `${MESH_EDIT_HULL_EDGE}: ${a}-${b} is a hull edge and cannot be removed` };
|
|
181
|
+
if (edge.kind === 'constraint') return this.record([{ op: 'unedge', a, b }]);
|
|
182
|
+
return { ok: true, changed: false, code: `${MESH_EDIT_EDGE_EXISTS}: ${a}-${b} is already a triangulation edge` };
|
|
183
|
+
}
|
|
184
|
+
findEdge(a: number, b: number): MeshEdge | undefined { const key = edgeKey(a, b); return this.current.edges.find(e => edgeKey(e.a, e.b) === key); }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface ScreenPoint { x: number; y: number }
|
|
188
|
+
export type MeshHit = { kind: 'vertex'; v: number } | { kind: 'edge'; a: number; b: number; t: number; at: ScreenPoint };
|
|
189
|
+
/** Screen-space hit test: the nearest vertex within the radius wins over any edge; then the nearest edge within 4 px. */
|
|
190
|
+
export function hitMesh(points: readonly (ScreenPoint | null)[], edges: readonly Pick<MeshEdge, 'a' | 'b'>[], p: ScreenPoint, touch = false): MeshHit | null {
|
|
191
|
+
const radius = touch ? MESH_HIT.vertexTouch : MESH_HIT.vertex;
|
|
192
|
+
let best: MeshHit | null = null, bestDistance = Infinity;
|
|
193
|
+
points.forEach((q, v) => { if (!q) return; const d = Math.hypot(p.x - q.x, p.y - q.y); if (d <= radius && d < bestDistance) { best = { kind: 'vertex', v }; bestDistance = d; } });
|
|
194
|
+
if (best) return best;
|
|
195
|
+
for (const edge of edges) {
|
|
196
|
+
const a = points[edge.a], b = points[edge.b]; if (!a || !b) continue;
|
|
197
|
+
const dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy; if (length === 0) continue;
|
|
198
|
+
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length)), at = { x: a.x + dx * t, y: a.y + dy * t }, d = Math.hypot(p.x - at.x, p.y - at.y);
|
|
199
|
+
if (d <= MESH_HIT.edge && d < bestDistance) { best = { kind: 'edge', a: edge.a, b: edge.b, t, at }; bestDistance = d; }
|
|
200
|
+
}
|
|
201
|
+
return best;
|
|
202
|
+
}
|
|
203
|
+
/** Vertex ids inside a screen rectangle (any corner order). */
|
|
204
|
+
export function verticesInBox(points: readonly (ScreenPoint | null)[], from: ScreenPoint, to: ScreenPoint): number[] {
|
|
205
|
+
const [x0, x1] = [Math.min(from.x, to.x), Math.max(from.x, to.x)], [y0, y1] = [Math.min(from.y, to.y), Math.max(from.y, to.y)];
|
|
206
|
+
return points.flatMap((p, v) => p && p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 ? [v] : []);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A 2D affine map x' = a x + c y + e, y' = b x + d y + f. */
|
|
210
|
+
export interface Affine { a: number; b: number; c: number; d: number; e: number; f: number }
|
|
211
|
+
/** Samples an affine function at three points. */
|
|
212
|
+
export function affineOf(fn: (x: number, y: number) => ScreenPoint): Affine {
|
|
213
|
+
const o = fn(0, 0), x = fn(1, 0), y = fn(0, 1);
|
|
214
|
+
return { a: x.x - o.x, b: x.y - o.y, c: y.x - o.x, d: y.y - o.y, e: o.x, f: o.y };
|
|
215
|
+
}
|
|
216
|
+
export function invertAffine(m: Affine): Affine | null {
|
|
217
|
+
const det = m.a * m.d - m.b * m.c; if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null;
|
|
218
|
+
return { a: m.d / det, b: -m.b / det, c: -m.c / det, d: m.a / det, e: (m.c * m.f - m.d * m.e) / det, f: (m.b * m.e - m.a * m.f) / det };
|
|
219
|
+
}
|
|
220
|
+
export const applyAffine = (m: Affine, x: number, y: number): ScreenPoint => ({ x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f });
|
|
221
|
+
/** A screen-px offset as a crop-px offset through the inverse of crop → screen. */
|
|
222
|
+
export function screenDeltaToCrop(cropToScreen: Affine, dx: number, dy: number): Vec | null {
|
|
223
|
+
const inverse = invertAffine(cropToScreen); if (!inverse) return null;
|
|
224
|
+
return [inverse.a * dx + inverse.c * dy, inverse.b * dx + inverse.d * dy];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The text hashed for `source.hash`: the attachment's entry from the hash-verified model, as `JSON.stringify` writes it. */
|
|
228
|
+
export const attachmentHashText = (entry: unknown) => JSON.stringify(entry);
|
|
229
|
+
|
|
230
|
+
export interface MeshEditDocument {
|
|
231
|
+
schema: typeof MESH_EDIT_SCHEMA;
|
|
232
|
+
slot: string; skin: string; attachment: string;
|
|
233
|
+
source: { vertexCount: number; triangleCount: number; hullLength: number; hash: string };
|
|
234
|
+
units: string; basis: string; preview: string;
|
|
235
|
+
ops: MeshOp[];
|
|
236
|
+
}
|
|
237
|
+
export function meshEditDocument(session: MeshEditSession, basis: string): MeshEditDocument {
|
|
238
|
+
const s = session.source;
|
|
239
|
+
return {
|
|
240
|
+
schema: MESH_EDIT_SCHEMA, slot: s.slot, skin: s.skin, attachment: s.attachment,
|
|
241
|
+
source: { vertexCount: s.vertices.length, triangleCount: Math.floor(s.triangles.length / 3), hullLength: s.hull, hash: s.hash },
|
|
242
|
+
units: 'crop px, x right, y down; vertex ids are source indices, added vertices take the next id in op order, removed ids are not reused',
|
|
243
|
+
basis, preview: `${MESH_EDIT_PREVIEW_NOTE}; the ops are a request over the original triangulation, not a mesh`,
|
|
244
|
+
ops: session.ops(),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
export function meshEditBinding(input: { fileSha256: string; sourceHash: string; reviewedSource: { viewerVersion: string; runtimeVersion: string; rig: { sha256: string } | null; motion: { sha256: string } | null }; exportedAt: string; mapping: string; attachment: { slot: string; skin: string; name: string; sha256: string } }) {
|
|
248
|
+
return {
|
|
249
|
+
schema: MESH_EDIT_BINDING_SCHEMA,
|
|
250
|
+
file: { name: 'mesh-edit.json', sha256: input.fileSha256 },
|
|
251
|
+
package: input.sourceHash,
|
|
252
|
+
model: input.reviewedSource.rig?.sha256 ?? null,
|
|
253
|
+
motion: input.reviewedSource.motion?.sha256 ?? null,
|
|
254
|
+
attachment: { ...input.attachment },
|
|
255
|
+
exportedAt: input.exportedAt,
|
|
256
|
+
tools: { viewerVersion: input.reviewedSource.viewerVersion, runtimeVersion: input.reviewedSource.runtimeVersion },
|
|
257
|
+
mapping: input.mapping,
|
|
258
|
+
evidence: 'Mesh edit is review evidence, not an approval. Nothing was written into the package; retriangulation and regeneration are the producer path.',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Draft: per attachment, the ops bound to the attachment hash. A draft for another hash is not restored. */
|
|
263
|
+
export interface MeshEditDraft { schema: typeof MESH_EDIT_DRAFT_SCHEMA; entries: Record<string, { hash: string; ops: MeshOp[] }> }
|
|
264
|
+
export const draftKey = (s: Pick<MeshSource, 'slot' | 'skin' | 'attachment'>) => `${s.skin}/${s.slot}/${s.attachment}`;
|
|
265
|
+
export function parseMeshDraft(text: string): MeshEditDraft {
|
|
266
|
+
const empty: MeshEditDraft = { schema: MESH_EDIT_DRAFT_SCHEMA, entries: {} };
|
|
267
|
+
if (!text.trim()) return empty;
|
|
268
|
+
let value: unknown; try { value = JSON.parse(text); } catch { return empty; }
|
|
269
|
+
const o = value as Partial<MeshEditDraft> | null;
|
|
270
|
+
if (!o || typeof o !== 'object' || o.schema !== MESH_EDIT_DRAFT_SCHEMA || !o.entries || typeof o.entries !== 'object' || Array.isArray(o.entries)) return empty;
|
|
271
|
+
const entries: MeshEditDraft['entries'] = {};
|
|
272
|
+
for (const [key, entry] of Object.entries(o.entries)) {
|
|
273
|
+
if (['__proto__', 'constructor', 'prototype'].includes(key) || !entry || typeof entry.hash !== 'string' || !/^[a-f0-9]{64}$/.test(entry.hash) || !Array.isArray(entry.ops) || entry.ops.length > MESH_EDIT_MAX_OPS) continue;
|
|
274
|
+
entries[key] = { hash: entry.hash, ops: entry.ops as MeshOp[] };
|
|
275
|
+
}
|
|
276
|
+
return { schema: MESH_EDIT_DRAFT_SCHEMA, entries };
|
|
277
|
+
}
|
|
278
|
+
/** Ops a draft holds for this source, or null when the draft has none or names another hash / an op that does not apply. */
|
|
279
|
+
export function draftOps(draft: MeshEditDraft, source: MeshSource): MeshOp[] | null {
|
|
280
|
+
const entry = draft.entries[draftKey(source)]; if (!entry || entry.hash !== source.hash) return null;
|
|
281
|
+
try { applyMeshOps(source, entry.ops); return entry.ops; } catch { return null; }
|
|
282
|
+
}
|
|
283
|
+
export function writeMeshDraft(draft: MeshEditDraft, source: MeshSource, ops: readonly MeshOp[]): string {
|
|
284
|
+
const entries = { ...draft.entries }; const key = draftKey(source);
|
|
285
|
+
if (ops.length) entries[key] = { hash: source.hash, ops: ops.map(op => structuredClone(op)) }; else delete entries[key];
|
|
286
|
+
return Object.keys(entries).length ? JSON.stringify({ schema: MESH_EDIT_DRAFT_SCHEMA, entries }) : '';
|
|
287
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import {referenceBinding,type ReferenceBinding} from './reference.ts';
|
|
7
7
|
import { validateFeedbackSelection } from './feedback-validation.ts';
|
|
8
|
+
import { parseEnvironmentContext } from './environment.ts';
|
|
8
9
|
import { choice, object, string, type Json, type ReviewPackage, type ReviewedSource } from './manifest.ts';
|
|
9
10
|
export interface Feedback {id:string;frameStart:number;frameEnd:number;target:{kind:'bone'|'slot'|'rig';name:string};category:'defect'|'quality';summary:string;context?:Json}
|
|
10
11
|
export function parseFeedback(item:unknown,strict=true):Feedback {
|
|
@@ -25,6 +26,8 @@ export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animat
|
|
|
25
26
|
if(context.interval!==undefined&&!isDeepStrictEqual(context.interval,interval))throw new Error('feedback.context.interval: must match chosen inclusive frameStart/frameEnd');
|
|
26
27
|
if(context.animation!==undefined&&context.animation!==null&&typeof context.animation!=='string')throw new Error('feedback.context.animation: expected animation name or null');
|
|
27
28
|
if(animation!==undefined&&context.animation!==undefined&&context.animation!==null&&context.animation!==animation)throw new Error('feedback.context.animation: does not match selected comparison animation');
|
|
29
|
+
// Older records without an environment stay valid; a present one must have the recorded shape.
|
|
30
|
+
if(context.environment!==undefined)parseEnvironmentContext(context.environment);
|
|
28
31
|
const selected=animation??(typeof context.animation==='string'?context.animation:null);
|
|
29
32
|
validateFeedbackSelection(p.feedbackCatalog,{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,target:feedback.target,animation:selected});
|
|
30
33
|
return {...feedback,context:{...context,...(animation===undefined?{}:{animation}),interval} as Json};
|