@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.
- package/README.md +15 -8
- package/docs/DESIGN.md +1 -1
- package/docs/REVIEW_PACKAGE.md +22 -8
- package/package.json +1 -1
- package/src/applied-blocks.ts +48 -0
- package/src/applied-context.ts +4 -3
- package/src/baseline-reach.ts +177 -0
- package/src/baseline.ts +14 -9
- package/src/cli.ts +2 -2
- package/src/density-strip.ts +201 -0
- package/src/front.ts +7 -0
- package/src/handoff.ts +59 -7
- package/src/manifest.ts +13 -3
- package/src/open.ts +12 -3
- package/src/rebuild.ts +354 -42
- package/src/retune-check.ts +48 -28
- package/src/server.ts +30 -3
- package/web/cloth-panel.ts +10 -2
- package/web/cuts-panel.ts +15 -4
- package/web/density-strip-panel.ts +89 -0
- package/web/index.html +1 -1
- package/web/main.ts +171 -13
- package/web/rebuild-client.ts +38 -8
- package/web/styles.css +2 -0
package/src/retune-check.ts
CHANGED
|
@@ -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`
|
|
4
|
-
* and
|
|
5
|
-
*
|
|
6
|
-
* check
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
13
|
-
* older producer) is not refused: the caller falls back to running check. Pure but for the
|
|
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(
|
|
40
|
-
const
|
|
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(`${
|
|
46
|
-
if (!isObject(side.status) || !Object.values(side.status).every(s => typeof s === 'string' && STATUSES.has(s))) throw new Error(`${
|
|
47
|
-
if (side.status.gate !== 'PASS' && side.status.gate !== 'FAIL') throw new Error(`${
|
|
48
|
-
if (typeof side.pass !== 'boolean') throw new Error(`${
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
68
|
-
const rows = isObject(
|
|
69
|
-
if (!Array.isArray(rows) || rows.length === 0) return `${
|
|
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 `${
|
|
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
|
|
84
|
-
* `RETUNE_CHECK_STALE: <file> sha256 <in retune.json> … <on disk>` (`
|
|
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,
|
|
87
|
-
const rows = revisionRows(
|
|
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
|
|
113
|
+
if (actual !== row.sha256) return stale(row.path, row.sha256, actual);
|
|
94
114
|
}
|
|
95
|
-
for (const path of onDisk) if (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
|
|
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());
|
package/web/cloth-panel.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|