@rig-c/review 0.8.1 → 0.10.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.
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * #139: a retune Apply reads the check retune already ran on its revision instead of running `rig-parts check` a second
3
- * time. `rig-parts retune` gates the revision and checks it (`checkStage` over the staged revision, the same `--parts`
4
- * and pack flags review passes) before it writes `retune.json`; from `@rig-c/parts` 1.49.0 (parts#541, its CHANGELOG:
5
- * "remesh,retune: the provenance carries check's final line and every block's status, as check itself reports") each
6
- * check side also carries `line` — check's own last line, composed by the one function check prints it from — and
7
- * `status` — every block's status as that tree's `check.json` records it, in the file's order. The writer
8
- * (`src/retune.ts`): `revision: hashRows(revision)` and `check: { ...sides, scope }` with
3
+ * time; #174: a remesh Apply reads remesh's check the same way. `rig-parts retune` and `rig-parts remesh` gate the revision
4
+ * and check it (`checkStage` over the staged revision, the same `--parts` and pack flags review passes) before they
5
+ * write their provenance; from `@rig-c/parts` 1.49.0 (parts#541, its CHANGELOG: "remesh,retune: the provenance carries
6
+ * check's final line and every block's status, as check itself reports") each check side also carries `line` — check's
7
+ * own last line, composed by the one function check prints it from — and `status` — every block's status as that
8
+ * tree's `check.json` records it, in the file's order. The writers (`src/retune.ts`, `src/remesh.ts`):
9
+ * `revision: hashRows(revision)` and `check: { ...sides, scope }` with
9
10
  * `sides = { input: checkSide(inCheck), revision: checkSide(revCheck) }`.
10
11
  *
11
12
  * The carried side is bound to the files by the `revision` hash rows: every row's file in the scratch revision must
12
- * hash to its row, none missing and none extra (`RETUNE_CHECK_STALE`). A `retune.json` without `line` and `status` (an
13
- * older producer) is not refused: the caller falls back to running check. Pure but for the binding's reads.
13
+ * hash to its row, none missing and none extra (`RETUNE_CHECK_STALE` / `REMESH_CHECK_STALE`). A provenance without
14
+ * `line` and `status` (an older producer) is not refused: the caller falls back to running check. Pure but for the
15
+ * binding's reads.
14
16
  */
15
17
  import { createHash } from 'node:crypto';
16
18
  import { readdir, readFile } from 'node:fs/promises';
@@ -19,9 +21,21 @@ import { CHECK_SUMMARY_SCHEMA, type CheckSummary } from './applied-context.ts';
19
21
 
20
22
  /** The first `@rig-c/parts` release whose `retune.json` check sides carry `line` and `status` (CHANGELOG 1.49.0, parts#541). */
21
23
  export const RETUNE_CHECK_SINCE = '1.49.0';
24
+ /** The same release for `remesh.json` (one change carried both, parts#541). */
25
+ export const REMESH_CHECK_SINCE = '1.49.0';
22
26
  /** retune's provenance file, at the revision's root (parts `RETUNE_FILE`). */
23
27
  export const RETUNE_FILE = 'retune.json';
28
+ /** remesh's provenance file, at the revision's root (parts `PROVENANCE_FILE`). */
29
+ export const REMESH_FILE = 'remesh.json';
24
30
  export const RETUNE_CHECK_STALE = 'RETUNE_CHECK_STALE';
31
+ export const REMESH_CHECK_STALE = 'REMESH_CHECK_STALE';
32
+
33
+ /** The producer ops whose provenance carries the check they ran on their revision. */
34
+ export type CarryingOp = 'retune' | 'remesh';
35
+ const FILE: Record<CarryingOp, string> = { retune: RETUNE_FILE, remesh: REMESH_FILE };
36
+ const STALE: Record<CarryingOp, string> = { retune: RETUNE_CHECK_STALE, remesh: REMESH_CHECK_STALE };
37
+ /** The op's provenance file name. */
38
+ export const provenanceFile = (op: CarryingOp): string => FILE[op];
25
39
 
26
40
  const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
27
41
  /** check.json's bar keys as review's `checkSummary` reads them: an upper-case name. */
@@ -36,16 +50,17 @@ export interface CarriedCheck { pass: boolean; line: string; status: Record<stri
36
50
  * {@link RETUNE_CHECK_SINCE}). A side with one of them and not the other, or with a malformed one, throws: that is not
37
51
  * an older producer and not a check review can record.
38
52
  */
39
- export function carriedCheck(retune: unknown): CarriedCheck | null {
40
- const side = isObject(retune) && isObject(retune.check) ? retune.check.revision : undefined;
53
+ export function carriedCheck(provenance: unknown, op: CarryingOp = 'retune'): CarriedCheck | null {
54
+ const file = FILE[op];
55
+ const side = isObject(provenance) && isObject(provenance.check) ? provenance.check.revision : undefined;
41
56
  // No side at all carries neither field either: check runs, as for an older producer.
42
57
  if (!isObject(side)) return null;
43
58
  const hasLine = Object.hasOwn(side, 'line'), hasStatus = Object.hasOwn(side, 'status');
44
59
  if (!hasLine && !hasStatus) return null;
45
- if (typeof side.line !== 'string' || !/^check: (PASS|FAIL)/.test(side.line)) throw new Error(`${RETUNE_FILE}: check.revision.line is not check's last line`);
46
- if (!isObject(side.status) || !Object.values(side.status).every(s => typeof s === 'string' && STATUSES.has(s))) throw new Error(`${RETUNE_FILE}: check.revision.status is not {block: status}`);
47
- if (side.status.gate !== 'PASS' && side.status.gate !== 'FAIL') throw new Error(`${RETUNE_FILE}: check.revision.status.gate is not PASS or FAIL`);
48
- if (typeof side.pass !== 'boolean') throw new Error(`${RETUNE_FILE}: check.revision.pass is not a boolean`);
60
+ if (typeof side.line !== 'string' || !/^check: (PASS|FAIL)/.test(side.line)) throw new Error(`${file}: check.revision.line is not check's last line`);
61
+ if (!isObject(side.status) || !Object.values(side.status).every(s => typeof s === 'string' && STATUSES.has(s))) throw new Error(`${file}: check.revision.status is not {block: status}`);
62
+ if (side.status.gate !== 'PASS' && side.status.gate !== 'FAIL') throw new Error(`${file}: check.revision.status.gate is not PASS or FAIL`);
63
+ if (typeof side.pass !== 'boolean') throw new Error(`${file}: check.revision.pass is not a boolean`);
49
64
  return { pass: side.pass, line: side.line, status: side.status as Record<string, string> };
50
65
  }
51
66
 
@@ -55,19 +70,22 @@ export function carriedCheck(retune: unknown): CarriedCheck | null {
55
70
  * check` exits `r.figures.PASS ? EXIT_OK : EXIT_REFUSED`); `line` ← `line` (check's last line, byte for byte);
56
71
  * `bars` ← every upper-case entry of `status` (check.json's top-level blocks with a `status`, in the file's order; the
57
72
  * figure-read `gate`, `loop` and `seam` are lower-case and are not bars in check.json); `gate` ← `status.gate` is
58
- * `PASS` (`gate_spine_html_green === true`, the one way `barsNotMet` leaves `gate` out). `source` says where it came from.
73
+ * `PASS` (`gate_spine_html_green === true`, the one way `barsNotMet` leaves `gate` out). `source` says which op's check it is.
59
74
  */
60
- export function retuneCheckSummary(carried: CarriedCheck): CheckSummary {
75
+ export function carriedCheckSummary(carried: CarriedCheck, source: CarryingOp): CheckSummary {
61
76
  const bars: Record<string, string> = {};
62
77
  for (const [name, status] of Object.entries(carried.status)) if (BAR.test(name)) bars[name] = status;
63
- return { schema: CHECK_SUMMARY_SCHEMA, pass: carried.pass, exitCode: carried.pass ? 0 : 1, line: carried.line, bars, gate: carried.status.gate === 'PASS', source: 'retune' };
78
+ return { schema: CHECK_SUMMARY_SCHEMA, pass: carried.pass, exitCode: carried.pass ? 0 : 1, line: carried.line, bars, gate: carried.status.gate === 'PASS', source };
64
79
  }
65
80
 
81
+ /** #139's name for {@link carriedCheckSummary} of a retune. */
82
+ export const retuneCheckSummary = (carried: CarriedCheck): CheckSummary => carriedCheckSummary(carried, 'retune');
83
+
66
84
  /** The `revision` hash rows, or a refusal when they are not rows. */
67
- function revisionRows(retune: unknown): RetuneRevisionRow[] | string {
68
- const rows = isObject(retune) ? retune.revision : undefined;
69
- if (!Array.isArray(rows) || rows.length === 0) return `${RETUNE_CHECK_STALE}: ${RETUNE_FILE} names no revision hash rows; its check is bound to no files`;
70
- for (const r of rows) if (!isObject(r) || typeof r.path !== 'string' || !r.path || typeof r.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(r.sha256)) return `${RETUNE_CHECK_STALE}: ${RETUNE_FILE} has a revision row that is not {path, bytes, sha256}`;
85
+ function revisionRows(provenance: unknown, op: CarryingOp): RetuneRevisionRow[] | string {
86
+ const rows = isObject(provenance) ? provenance.revision : undefined;
87
+ if (!Array.isArray(rows) || rows.length === 0) return `${STALE[op]}: ${FILE[op]} names no revision hash rows; its check is bound to no files`;
88
+ for (const r of rows) if (!isObject(r) || typeof r.path !== 'string' || !r.path || typeof r.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(r.sha256)) return `${STALE[op]}: ${FILE[op]} has a revision row that is not {path, bytes, sha256}`;
71
89
  return rows as RetuneRevisionRow[];
72
90
  }
73
91
 
@@ -80,21 +98,23 @@ async function files(root: string): Promise<string[]> {
80
98
 
81
99
  /**
82
100
  * The binding: every `revision` row's file under `dir` hashes to its row, none is missing, and `dir` holds no file
83
- * but those rows and `retune.json`. Null when bound; otherwise the first mismatch as
84
- * `RETUNE_CHECK_STALE: <file> sha256 <in retune.json> … <on disk>` (`absent` on the side that has no such file).
101
+ * but those rows and the op's provenance file. Null when bound; otherwise the first mismatch as
102
+ * `RETUNE_CHECK_STALE: <file> sha256 <in retune.json> … <on disk>` (`REMESH_CHECK_STALE` … `remesh.json` for a remesh;
103
+ * `absent` on the side that has no such file).
85
104
  */
86
- export async function revisionBinding(dir: string, retune: unknown): Promise<string | null> {
87
- const rows = revisionRows(retune);
105
+ export async function revisionBinding(dir: string, provenance: unknown, op: CarryingOp = 'retune'): Promise<string | null> {
106
+ const rows = revisionRows(provenance, op), file = FILE[op];
107
+ const stale = (path: string, recorded: string, actual: string) => `${STALE[op]}: ${path} sha256 ${recorded} in ${file} … ${actual} on disk; ${op}'s check is not this revision's, so no build is recorded`;
88
108
  if (typeof rows === 'string') return rows;
89
109
  const onDisk = await files(dir), named = new Set(rows.map(r => r.path));
90
110
  for (const row of rows) {
91
111
  let actual = 'absent';
92
112
  if (onDisk.includes(row.path)) actual = createHash('sha256').update(await readFile(join(dir, ...row.path.split('/')))).digest('hex');
93
- if (actual !== row.sha256) return `${RETUNE_CHECK_STALE}: ${row.path} sha256 ${row.sha256} in ${RETUNE_FILE} … ${actual} on disk; retune's check is not this revision's, so no build is recorded`;
113
+ if (actual !== row.sha256) return stale(row.path, row.sha256, actual);
94
114
  }
95
- for (const path of onDisk) if (path !== RETUNE_FILE && !named.has(path)) {
115
+ for (const path of onDisk) if (path !== file && !named.has(path)) {
96
116
  const actual = createHash('sha256').update(await readFile(join(dir, ...path.split('/')))).digest('hex');
97
- return `${RETUNE_CHECK_STALE}: ${path} sha256 absent in ${RETUNE_FILE} … ${actual} on disk; retune's check is not this revision's, so no build is recorded`;
117
+ return stale(path, 'absent', actual);
98
118
  }
99
119
  return null;
100
120
  }
package/src/server.ts CHANGED
@@ -7,6 +7,7 @@ import {assertReviewUnchanged,referenceBinding,selectedReference,safeSelectedRef
7
7
  import {openGate,staleOpenStatus,accessGate,openNonceGate,stampOpen,type OpenIdentity} from './open-protocol.ts';
8
8
  import { readSession, saveSession } from './session.ts';
9
9
  import { createRebuilds, RebuildError } from './rebuild.ts';
10
+ import { DENSITY_STRIP_LIMIT } from './density-strip.ts';
10
11
  import { createReadings } from './readings-run.ts';
11
12
  import { ReadingsError } from './readings.ts';
12
13
  import { BONES_CHECK_LIMIT, createBonesCheck } from './bones-check.ts';
@@ -17,7 +18,7 @@ import { homedir } from 'node:os';
17
18
  import { join } from 'node:path';
18
19
  const headers={ 'X-Content-Type-Options':'nosniff','Cache-Control':'no-store','Content-Security-Policy':"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self'; connect-src 'self'; font-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",'Referrer-Policy':'no-referrer'};
19
20
  /** Request shapes the page or an edit card got wrong: 400. Everything else a refusal names is an outcome (200). */
20
- const MALFORMED=new Set(['REBUILD_REQUEST_INVALID','EDIT_PATCH_INVALID','EDIT_PATCH_OP_UNKNOWN','EDIT_PATCH_POINTER_INVALID','EDIT_PATCH_CMD_UNKNOWN','EDIT_PATCH_ARG_UNKNOWN','BONES_OP_OUT_OF_SCOPE','RETARGET_REQUEST_INVALID']);
21
+ const MALFORMED=new Set(['REBUILD_REQUEST_INVALID','DENSITY_STRIP_REQUEST_INVALID','DENSITY_STRIP_FIELD','MESH_PATCH_SCOPE','EDIT_PATCH_INVALID','EDIT_PATCH_OP_UNKNOWN','EDIT_PATCH_POINTER_INVALID','EDIT_PATCH_CMD_UNKNOWN','EDIT_PATCH_ARG_UNKNOWN','BONES_OP_OUT_OF_SCOPE','RETARGET_REQUEST_INVALID']);
21
22
  function json(data:unknown,status=200){return Response.json(data,{status,headers});}
22
23
  async function bodyJson(req:Request,limit=1024*1024,what='Session payload exceeds 1 MiB'):Promise<unknown>{
23
24
  if(!/^application\/json(?:\s*;|$)/i.test(req.headers.get('content-type')??''))throw new Error('Expected application/json');
@@ -28,11 +29,11 @@ async function bodyJson(req:Request,limit=1024*1024,what='Session payload exceed
28
29
  }
29
30
  /** `capability` (open workers and foreground CLI serving) gates every non-GET request; tests may omit it.
30
31
  * `cwd` is where the producer is looked for after the package's own directories (review's working directory by default). */
31
- export async function startServer(p:ReviewPackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;initialAnimation?:string;activity?:()=>void;control?:ControlIdentity;capability?:string;cwd?:string}={}){
32
+ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;initialAnimation?:string;activity?:()=>void;control?:ControlIdentity;capability?:string;cwd?:string;stripConcurrency?:number}={}){
32
33
  const bundle=await buildWeb();
33
34
  const unchanged=()=>assertReviewUnchanged(p);
34
35
  const stateDir=options.stateDir??process.env.RIG_REVIEW_STATE_DIR??join(homedir(),'.local','state','rig-c-review'),starts=[p.root,options.cwd??process.cwd()];
35
- const rebuilds=createRebuilds(p,{stateDir,starts,unchanged,bones:p.feedbackCatalog.bones});
36
+ const rebuilds=createRebuilds(p,{stateDir,starts,unchanged,bones:p.feedbackCatalog.bones,...(options.stripConcurrency?{stripConcurrency:options.stripConcurrency}:{})});
36
37
  // Readings (#108): rigc render's readings on the inspected build and on the latest scratch build, into scratch.
37
38
  const readings=createReadings(p,{stateDir,starts,unchanged,latest:rebuilds.latest});
38
39
  // Bones check (#109): propose --from-config and compare on a scratch copy of the patched handoff config.
@@ -59,6 +60,23 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
59
60
  // A refusal is an outcome the page shows verbatim (200), so the managed front passes its text through; only a malformed request is a 400.
60
61
  catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
61
62
  }
63
+ // #166: undo the last Apply — {from: <the build id on the stage>}; nothing is read from the client but that id.
64
+ if(req.method==='POST'&&url.pathname==='/api/rebuild/undo'){
65
+ if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
66
+ try{const result=await rebuilds.undo(await bodyJson(req,1024,'REBUILD_BODY_LIMIT: maximum 1 KiB'));return json(result.body,result.status);}
67
+ catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
68
+ }
69
+ // #173: a density strip — 2–5 Mesh-panel patches built side by side, none applied; and closing it (its scratch removed).
70
+ if(req.method==='POST'&&url.pathname==='/api/density-strip'){
71
+ if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
72
+ try{const result=await rebuilds.startStrip(await bodyJson(req,DENSITY_STRIP_LIMIT,'DENSITY_STRIP_BODY_LIMIT: maximum 320 KiB'));return json(result.body,result.status);}
73
+ catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
74
+ }
75
+ if(req.method==='POST'&&url.pathname==='/api/density-strip/close'){
76
+ if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
77
+ try{const result=await rebuilds.closeStrip(await bodyJson(req,1024,'DENSITY_STRIP_BODY_LIMIT: maximum 1 KiB'));return json(result.body,result.status);}
78
+ catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
79
+ }
62
80
  if(req.method==='POST'&&url.pathname==='/api/bones-check'){
63
81
  if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
64
82
  try{return json(await bonesCheck.run(await bodyJson(req,BONES_CHECK_LIMIT,'BONES_CHECK_BODY_LIMIT: maximum 64 KiB')));}
@@ -88,6 +106,15 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
88
106
  if(url.pathname==='/api/review'){await unchanged();return json({manifest:p.manifest,initialAnimation:options.initialAnimation??null,sourceHash:p.sourceHash,reviewedSource:p.reviewedSource,...(referenceBinding(p)?{referenceBinding:referenceBinding(p),explicitReference:{...referenceBinding(p)!.reference,url:'/review-reference'}}:{}),report:p.report,feedbackCatalog:p.feedbackCatalog,assetsBase:'/assets/',capabilities:{playback:!!p.manifest.model,modeling:false,motion:false,feedback:true,audio:true}});}
89
107
  if(url.pathname==='/api/session'){await unchanged();return json(await readSession(p,options.stateDir));}
90
108
  if(url.pathname==='/api/rebuild')return json(await rebuilds.describe());
109
+ if(url.pathname==='/api/rebuild/history')return json(await rebuilds.history());
110
+ if(url.pathname==='/api/density-strip')return json({strip:rebuilds.readStrip()});
111
+ const stripStatus=/^\/api\/density-strip\/([a-f0-9]{64})(?:\/([a-f0-9]{64}))?$/.exec(url.pathname);
112
+ if(stripStatus){try{return json(stripStatus[2]?rebuilds.stripJob(stripStatus[1]!,stripStatus[2]):rebuilds.readStrip(stripStatus[1]!));}catch(e){if(e instanceof RebuildError)return json({error:e.message},404);throw e;}}
113
+ const stripFile=/^\/density-strip\/([a-f0-9]{64})\/([a-f0-9]{64})\/(.+)$/.exec(url.pathname);
114
+ if(stripFile){
115
+ let full:string;try{full=await rebuilds.stripFile(stripFile[1]!,stripFile[2]!,decodeURIComponent(stripFile[3]!));}catch(e){return json({error:e instanceof Error?e.message:'Not found'},404);}
116
+ const file=Bun.file(full);return new Response(req.method==='HEAD'?null:file,{headers:{...headers,'Content-Security-Policy':"default-src 'none'; script-src 'none'; sandbox",'Content-Type':file.type||'application/octet-stream','Content-Length':String(file.size)}});
117
+ }
91
118
  const rebuildStatus=/^\/api\/rebuild\/([a-f0-9]{64})$/.exec(url.pathname);
92
119
  if(rebuildStatus){try{return json(rebuilds.read(rebuildStatus[1]!));}catch(e){if(e instanceof RebuildError)return json({error:e.message},404);throw e;}}
93
120
  if(url.pathname==='/api/requirements')return json(await requirements.describe());
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import {ClothFieldError,fieldText,recipeFromFields,recipeOps,RECIPE_FIELDS,type ClothTagView,type RecipeField} from '../src/cloth-recipe.ts';
14
14
  import type {PatchOp} from '../src/config-patch.ts';
15
+ import {itemApplied} from '../src/applied-blocks.ts';
15
16
  import type {CropWorldMapping} from '../src/chest-regions.ts';
16
17
  import type {OverlayPoint,OverlayProject,OverlayRegion} from './rig-overlays.ts';
17
18
  import type {ClothStatusView,PatchJobView,RebuildRefusal} from './rebuild-client.ts';
@@ -33,6 +34,10 @@ export interface ClothPanel {
33
34
  links():{tag:string;inspected:ClothTagView|null;drafted:ClothTagView|null};
34
35
  apply():Promise<PatchJobView|RebuildRefusal|null>;
35
36
  shown(job:PatchJobView|RebuildRefusal|null):void;
37
+ /** #172: the ops of the stage's applied record this block owns (`appliedBlockOps`); a pending op among them is marked applied. */
38
+ applied(ops:readonly PatchOp[]|null):void;
39
+ /** #172: the pending ops marked applied, by their op line. */
40
+ appliedItems():string[];
36
41
  status(text:string,error:boolean):void;busy(value:boolean):void;
37
42
  restore():void;clear():void;sync():void;dispose():void;
38
43
  }
@@ -43,7 +48,7 @@ const isObject=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'
43
48
  export function bindClothPanel(deps:ClothPanelDeps):ClothPanel {
44
49
  const state=node('cloth-panel-state'),toggle=node<HTMLButtonElement>('cloth-links'),picker=node<HTMLSelectElement>('cloth-tag'),declared=node<HTMLInputElement>('cloth-declared'),fields=node('cloth-fields'),pending=node('cloth-pending-list');
45
50
  const applyButton=node<HTMLButtonElement>('cloth-apply'),resetButton=node<HTMLButtonElement>('cloth-reset'),statusOut=node('cloth-status'),readout=node('cloth-readout'),draft=node<HTMLTextAreaElement>('cloth-pending');
46
- let status:ClothStatusView|null=null,reason:string|null=null,selected='',active=false,busy=false;
51
+ let status:ClothStatusView|null=null,reason:string|null=null,selected='',active=false,busy=false,appliedOps:PatchOp[]=[];
47
52
  /** Per touched tag: every form field's text, and whether the tag's recipe is declared. */
48
53
  let edits=new Map<string,{declared:boolean;values:Record<string,string>}>();
49
54
  let drafted:ClothTagView[]=[];
@@ -78,7 +83,8 @@ export function bindClothPanel(deps:ClothPanelDeps):ClothPanel {
78
83
  function renderPending(){
79
84
  let ops:PatchOp[]=[],error:string|null=null;
80
85
  try{ops=allOps();}catch(err){if(err instanceof ClothFieldError)error=err.message;else throw err;}
81
- const items=ops.map(o=>make('li',{},make('code',{textContent:`${o.op} ${o.path}`}),o.op==='remove'?'':` → ${JSON.stringify(o.value)}`));
86
+ // #172: an op is marked applied only while the stage's applied record holds it.
87
+ const items=ops.map(o=>{const on=itemApplied([o],appliedOps);return make('li',{className:on?'applied':''},make('code',{textContent:`${o.op} ${o.path}`}),`${o.op==='remove'?'':` → ${JSON.stringify(o.value)}`}${on?' · applied':''}`);});
82
88
  const revert=[...edits.keys()].filter(touched).map(tag=>{const b=make('button',{type:'button',textContent:`Revert ${tag}`,disabled:busy});b.onclick=()=>{edits.delete(tag);change();};return make('li',{},b);});
83
89
  pending.replaceChildren(...(error?[make('li',{className:'error',textContent:error})]:[]),...(items.length?items:[make('li',{className:'hint',textContent:'No pending ops.'})]),...revert);
84
90
  applyButton.disabled=busy||!items.length||!!error||!!reason||!!status?.reason;resetButton.disabled=busy;
@@ -156,6 +162,8 @@ export function bindClothPanel(deps:ClothPanelDeps):ClothPanel {
156
162
  drafted=job.status==='built'&&c?c.links:[];
157
163
  readout.replaceChildren(...lines);deps.changed();
158
164
  },
165
+ applied(ops){appliedOps=(ops??[]).map(o=>({...o}));renderPending();},
166
+ appliedItems(){return [...pending.querySelectorAll('li.applied code')].map(c=>c.textContent??'');},
159
167
  status:panelStatus,
160
168
  busy(value){busy=value;render();},
161
169
  restore(){restore();render();deps.changed();},
package/web/cuts-panel.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import {anchorAt,boneAnchors,configBones,setAnchor,type BoneAnchor,type BoneEdit} from '../src/bone-anchors.ts';
13
13
  import type {PatchOp} from '../src/config-patch.ts';
14
+ import {itemApplied} from '../src/applied-blocks.ts';
14
15
  import type {CropWorldMapping} from '../src/chest-regions.ts';
15
16
  import {
16
17
  boxAt,CutEditError,cutOps,cutsDraft,cutsDraftText,cutsSource,dragPoint,rangeArc,rangeAt,setBox,setRange,setVertex,vertexAt,
@@ -42,6 +43,10 @@ export interface CutsPanel {
42
43
  overlayRegions():OverlayRegion[];overlayMarkers():OverlayMarker[];
43
44
  apply():Promise<PatchJobView|RebuildRefusal|null>;
44
45
  shown(job:PatchJobView|RebuildRefusal|null):void;
46
+ /** #172: the ops of the stage's applied record this block owns (`appliedBlockOps`); a pending op among them is marked applied. */
47
+ applied(ops:readonly PatchOp[]|null):void;
48
+ /** #172: the pending items marked applied, by their op line. */
49
+ appliedItems():string[];
45
50
  status(text:string,error:boolean):void;busy(value:boolean):void;
46
51
  restore():void;clear():void;sync():void;dispose():void;
47
52
  }
@@ -62,7 +67,7 @@ export function bindCutsPanel(deps:CutsPanelDeps):CutsPanel {
62
67
  const addForm=node('cuts-add');
63
68
  let config:unknown=null,source:CutsSource={cuts:[],patches:[],segments:[],plan:[],skipped:[]},anchors:BoneAnchor[]=[],buildReason:string|null=null,loaded=false;
64
69
  let edits=new Map<string,CutEdit>(),joints=new Map<string,BoneEdit>(),adds:CutAdd[]=[];
65
- let selected='',active=false,busy=false,space=false,hover:string|null=null;
70
+ let selected='',active=false,busy=false,space=false,hover:string|null=null,appliedOps:PatchOp[]=[];
66
71
  let drag:{id:string;pointer:number;start:RigPoint;from:RigPoint}|null=null;
67
72
  let placing:{kind:'cut'|'patch';points:RigPoint[]}|null=null;
68
73
 
@@ -121,12 +126,16 @@ export function bindCutsPanel(deps:CutsPanelDeps):CutsPanel {
121
126
  }
122
127
  function renderPending(){
123
128
  const items:HTMLElement[]=[];
129
+ // #172: an item is marked applied only while the stage's applied record holds every op it sends.
130
+ const opsOf=(build:()=>PatchOp[])=>{try{return build();}catch(error){if(error instanceof CutEditError)return [];throw error;}};
131
+ const item=(sent:PatchOp[],code:HTMLElement,text:string,revert:HTMLElement)=>{const on=itemApplied(sent,appliedOps);return make('li',{className:on?'applied':''},code,text+(on?' · applied':''),revert);};
124
132
  for(const e of edits.values()){const revert=make('button',{type:'button',textContent:'Revert',disabled:busy});revert.onclick=()=>{edits.delete(e.path);change();};
125
- items.push(make('li',{},make('code',{textContent:`${e.kind==='range'&&e.value===null?'remove':e.kind==='range'&&e.from===null?'add':'replace'} ${e.path}`}),` ${e.from?fmt(e.from):'—'} → ${e.value?fmt(e.value):'—'} · ${e.name}`,revert));}
133
+ items.push(item(opsOf(()=>cutOps(new Map([[e.path,e]]),[],config)),make('code',{textContent:`${e.kind==='range'&&e.value===null?'remove':e.kind==='range'&&e.from===null?'add':'replace'} ${e.path}`}),` ${e.from?fmt(e.from):'—'} → ${e.value?fmt(e.value):'—'} · ${e.name}`,revert));}
126
134
  for(const e of joints.values()){const revert=make('button',{type:'button',textContent:'Revert',disabled:busy});revert.onclick=()=>{joints.delete(e.path);change();};
127
- items.push(make('li',{},make('code',{textContent:`replace ${e.path}`}),` ${fmt(e.from)} → ${fmt(e.value)} · ${e.name} (a joint is its bone's origin)`,revert));}
135
+ items.push(item([{op:'replace',path:e.path,value:[...e.value],name:e.name}],make('code',{textContent:`replace ${e.path}`}),` ${fmt(e.from)} → ${fmt(e.value)} · ${e.name} (a joint is its bone's origin)`,revert));}
128
136
  adds.forEach((a,i)=>{const revert=make('button',{type:'button',textContent:'Revert',disabled:busy});revert.onclick=()=>{adds.splice(i,1);change();};
129
- items.push(make('li',{},make('code',{textContent:`add /assemble/${a.kind==='cut'?'cuts':'patches'}/-`}),` ${a.kind==='cut'?`cut ${a.into} ← ${a.from}, ${a.polygon.length} point(s)`:`patch ${a.name} ${fmt(a.box)}`} · regions → ${a.bone}`,revert));});
137
+ const sent=opsOf(()=>{const before=cutOps(new Map(),adds.slice(0,i),config).length;return cutOps(new Map(),adds.slice(0,i+1),config).slice(before);});
138
+ items.push(item(sent,make('code',{textContent:`add /assemble/${a.kind==='cut'?'cuts':'patches'}/-`}),` ${a.kind==='cut'?`cut ${a.into} ← ${a.from}, ${a.polygon.length} point(s)`:`patch ${a.name} ${fmt(a.box)}`} · regions → ${a.bone}`,revert));});
130
139
  pending.replaceChildren(...(items.length?items:[make('li',{className:'hint',textContent:'No pending ops.'})]));
131
140
  applyButton.disabled=busy||!items.length||!!buildReason;resetButton.disabled=busy;
132
141
  toggle.disabled=!loaded;toggle.setAttribute('aria-pressed',String(active));
@@ -254,6 +263,8 @@ export function bindCutsPanel(deps:CutsPanelDeps):CutsPanel {
254
263
  const map=a?.recomposite.errorMap;
255
264
  readout.replaceChildren(...lines,...(map&&job.status==='built'?[make('figure',{className:'cuts-error-map'},make('img',{src:deps.route(map.url),alt:'Recomposite error map of the scratch build: red is painting no part holds, blue a part more than 40 off'}),make('figcaption',{textContent:`recomposite_error_rig.png · sha256 ${map.sha256.slice(0,12)}…`}))]:[]));
256
265
  },
266
+ applied(ops){appliedOps=(ops??[]).map(o=>({...o}));renderPending();},
267
+ appliedItems(){return [...pending.querySelectorAll('li.applied code')].map(c=>c.textContent??'');},
257
268
  status:panelStatus,
258
269
  busy(value){busy=value;render();},
259
270
  restore(){restore();render();deps.changed();},
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Mesh panel · density strip (#173), page side: "N values from A to B" next to the selected part's density field
3
+ * (`grid` on a lattice entry, `contour.spacing` on a contour entry), Build strip, and the table — one row per
4
+ * candidate with `remesh.json`'s counts, the folds count, the part's TEXTURE_STRETCH severity and worst frame, every bar
5
+ * the part is measured on as a ratio against its floor or ceiling, and the verdict (PASS, or the producer's refusal
6
+ * verbatim). The two labels name rows; the table has no Apply. Load puts a built row on the stage as pending (the
7
+ * Mesh panel's Apply then applies it, reusing its build); Refine between builds the next strip inside two rows.
8
+ */
9
+ import {barText,densityField,densityPath,refineValues,stripValues,type DensityField,type StripRow} from '../src/density-strip.ts';
10
+ import {entryValue,type MeshEntry} from '../src/mesh-entry.ts';
11
+
12
+ export interface DensityStripViewLike {
13
+ id:string;status:'running'|'done';part:string;field:DensityField;concurrency:number;wallMs:number|null;rows:StripRow[];labels:{fewest:number|null;widest:number|null};
14
+ }
15
+ export interface DensityStripHost {
16
+ part():string|null;
17
+ entry():MeshEntry|null;
18
+ build(values:number[]):Promise<void>;
19
+ load(index:number):Promise<void>;
20
+ back():Promise<void>;
21
+ close():Promise<void>;
22
+ }
23
+ export interface DensityStripPanel {
24
+ /** The selected part or its pending entry changed: the field line and the default range follow it. */
25
+ changed():void;
26
+ show(view:DensityStripViewLike|null,pending:number|null,busy:boolean):void;
27
+ status(text:string,error:boolean):void;
28
+ }
29
+ const node=<T extends HTMLElement>(id:string)=>{const found=document.getElementById(id);if(!found)throw new Error(`Missing UI element: ${id}`);return found as T;};
30
+ const make=<K extends keyof HTMLElementTagNameMap>(tag:K,props:Record<string,unknown>={},...children:(Node|string)[]):HTMLElementTagNameMap[K]=>{const item=document.createElement(tag);Object.assign(item,props);item.append(...children);return item;};
31
+ const fieldLabel=(f:DensityField)=>f==='grid'?'grid':'contour.spacing';
32
+
33
+ export function bindDensityStrip(host:DensityStripHost):DensityStripPanel {
34
+ const fieldLine=node('density-strip-field'),n=node<HTMLInputElement>('density-strip-n'),from=node<HTMLInputElement>('density-strip-from'),to=node<HTMLInputElement>('density-strip-to');
35
+ const buildButton=node<HTMLButtonElement>('density-strip-build'),backButton=node<HTMLButtonElement>('density-strip-back'),closeButton=node<HTMLButtonElement>('density-strip-close');
36
+ const statusOut=node('density-strip-status'),table=node('density-strip-table');
37
+ let view:DensityStripViewLike|null=null,pending:number|null=null,busy=false,lastPart:string|null=null;
38
+ const status=(text:string,error:boolean)=>{statusOut.textContent=text;statusOut.title=text;statusOut.classList.toggle('error',error);};
39
+ /** The selected part's density field and its pending value, or the reason a strip cannot run on it. */
40
+ const current=():{part:string;field:DensityField;value:number}|{error:string}=>{
41
+ const part=host.part(),entry=host.entry();
42
+ if(!part||!entry)return {error:'Select a mesh part (grid or contour entry) to build a density strip.'};
43
+ try{const field=densityField(entry,part),value=entryValue(entry,densityPath(field));return typeof value==='number'?{part,field,value}:{error:`meshes.${part}.${fieldLabel(field)} is not a number`};}
44
+ catch(error){return {error:error instanceof Error?error.message:String(error)};}
45
+ };
46
+ function changed(){
47
+ const c=current();
48
+ if('error' in c){fieldLine.textContent=c.error;buildButton.disabled=true;lastPart=null;return;}
49
+ fieldLine.textContent=`${c.part} · ${fieldLabel(c.field)} (pending ${c.value}) · each value runs as its own replace /meshes/${c.part} patch through remesh, at most ${view?.concurrency??'cores/2'} at once`;
50
+ if(lastPart!==c.part){from.value=String(c.value);to.value=String(c.field==='grid'?Math.round(c.value*3):Math.round(c.value*2*100)/100);lastPart=c.part;}
51
+ buildButton.disabled=busy||view?.status==='running';
52
+ }
53
+ buildButton.onclick=()=>{
54
+ const c=current();if('error' in c){status(c.error,true);return;}
55
+ try{void host.build(stripValues(Number(from.value),Number(to.value),Number(n.value),c.field==='grid'));}catch(error){status(error instanceof Error?error.message:String(error),true);}
56
+ };
57
+ backButton.onclick=()=>{void host.back();};
58
+ closeButton.onclick=()=>{void host.close();};
59
+ const cell=(text:string,title=text,cls='')=>make('td',{textContent:text,title,className:cls});
60
+ function show(next:DensityStripViewLike|null,pendingIndex:number|null,isBusy:boolean){
61
+ view=next;pending=pendingIndex;busy=isBusy;changed();
62
+ backButton.disabled=pending===null||busy;closeButton.disabled=!view||view.status==='running'||busy;
63
+ if(!view){table.replaceChildren();return;}
64
+ const v=view,integer=v.field==='grid',head=make('tr',{},...[fieldLabel(v.field),'v / t (remesh.json)','folds','TEXTURE_STRETCH','bars (ratio vs floor / ceiling)','verdict',''].map(h=>make('th',{textContent:h,scope:'col'})));
65
+ const rows=v.rows.map((r,i)=>{
66
+ const labels:string[]=[];if(v.labels.fewest===i)labels.push('fewest triangles passing');if(v.labels.widest===i)labels.push('widest margin among ties');
67
+ const valueCell=make('td',{className:'density-strip-value'},make('strong',{textContent:String(r.value)}),...labels.map(l=>make('span',{className:'density-strip-label',textContent:l})));
68
+ const counts=r.counts?`v ${r.counts.vertices} · t ${r.counts.triangles}`:'—';
69
+ const stretch=r.stretch?`${r.stretch.severity}${r.stretch.frame?` @ ${r.stretch.frame}`:''}`:'—';
70
+ const bars=r.bars.length?make('td',{className:'density-strip-bars'},...r.bars.map(b=>make('div',{textContent:barText(b),title:`${b.bar} ${b.status}`,className:b.margin<1?'error':''}))):cell('—');
71
+ const verdictCell=cell(r.verdict,r.verdict,`density-strip-verdict${r.verdict==='PASS'?' pass':r.status==='running'?'':' error'}`);
72
+ const load=make('button',{type:'button',textContent:pending===i?'On stage':r.applied?'Applied':'Load',disabled:busy||!r.modelUrl||pending===i||v.status==='running'});
73
+ load.title=r.modelUrl?'Load this revision on the stage as pending — nothing is applied; the Mesh panel\'s Apply applies it':r.status==='running'?'Still building':r.applied?'Applied: it is an applied record now':'Refused: there is no revision to load';
74
+ load.setAttribute('aria-label',`Load ${fieldLabel(v.field)} ${r.value}`);load.onclick=()=>{void host.load(i);};
75
+ const actions=make('td',{className:'density-strip-actions'},load);
76
+ if(i<v.rows.length-1){
77
+ const refine=make('button',{type:'button',textContent:'Refine ↓',disabled:busy||v.status==='running'});
78
+ refine.title=`Build the next strip between ${r.value} and ${v.rows[i+1]!.value}`;refine.setAttribute('aria-label',`Refine between ${r.value} and ${v.rows[i+1]!.value}`);
79
+ refine.onclick=()=>{try{void host.build(refineValues(r.value,v.rows[i+1]!.value,Number(n.value)||5,integer));}catch(error){status(error instanceof Error?error.message:String(error),true);}};
80
+ actions.append(refine);
81
+ }
82
+ const tr=make('tr',{className:[pending===i?'pending':'',r.status==='refused'||r.status==='failed'?'refused':''].filter(Boolean).join(' ')},valueCell,cell(counts),cell(r.folds===null?'—':String(r.folds)),cell(stretch),bars,verdictCell,actions);
83
+ tr.dataset.candidate=r.id;return tr;
84
+ });
85
+ table.replaceChildren(make('table',{className:'density-strip-rows'},make('thead',{},head),make('tbody',{},...rows)));
86
+ }
87
+ changed();
88
+ return {changed,show,status};
89
+ }