@floless/app 0.76.0 → 0.78.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/dist/floless-server.cjs +848 -565
- package/dist/schemas/steel.takeoff.v1.schema.json +3 -2
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +25 -0
- package/dist/web/app.css +73 -0
- package/dist/web/aware.js +1 -1
- package/dist/web/steel-editor.html +18 -1
- package/dist/web/workspaces.js +229 -2
- package/package.json +1 -1
|
@@ -251,7 +251,7 @@
|
|
|
251
251
|
"verified": { "type": "boolean", "description": "True = a human confirmed this joint." },
|
|
252
252
|
"name": { "type": "string", "description": "custom only: display label carried from the source IFC (e.g. \"COLUMN C102\")." },
|
|
253
253
|
"place": { "$ref": "#/$defs/point3", "description": "custom only: the world-mm point the connection's LOCAL `geometry` is translated by — its placement in the model." },
|
|
254
|
-
"geometry": { "type": "array", "items": { "$ref": "#/$defs/meshPart" }, "description": "custom only: imported mesh parts (one per plate/bolt/weld) in the connection's LOCAL mm frame. Read once from IFC by AWARE's connection-reader; opaque tessellated geometry (no per-dimension edit)." }
|
|
254
|
+
"geometry": { "type": "array", "items": { "$ref": "#/$defs/meshPart" }, "description": "custom only: imported mesh parts (one per plate/bolt/weld) in the connection's LOCAL mm frame (re-anchored at import from the reader's WORLD-mm output — the LOCAL frame is floless's own placement transform). Read once from IFC by AWARE's connection-reader `extract`, whose output is the source of truth for this mesh shape (see #/$defs/meshPart); opaque tessellated geometry (no per-dimension edit)." }
|
|
255
255
|
},
|
|
256
256
|
"allOf": [
|
|
257
257
|
{ "if": { "properties": { "kind": { "enum": ["base-plate", "shear-plate"] } } }, "then": { "required": ["main"] } },
|
|
@@ -262,7 +262,7 @@
|
|
|
262
262
|
"type": "object",
|
|
263
263
|
"required": ["positions", "indices"],
|
|
264
264
|
"additionalProperties": true,
|
|
265
|
-
"description": "One tessellated mesh part of a 'custom' connection (a plate / bolt / weld), in the connection's LOCAL mm frame: flat x,y,z position triples + 0-based triangle indices — AWARE's generic kind:\"mesh\" scene primitive.
|
|
265
|
+
"description": "One tessellated mesh part of a 'custom' connection (a plate / bolt / weld), in the connection's LOCAL mm frame: flat x,y,z position triples + 0-based triangle indices — AWARE's generic kind:\"mesh\" scene primitive. Shape is AWARE-owned: the source of truth is aware-aeco's connection-reader `extract` output (manifest.yaml + commands/extract.md). floless only consumes it — never hand-authored, never re-defined here.",
|
|
266
266
|
"properties": {
|
|
267
267
|
"id": { "type": "string", "description": "Source part id (the IFC element GlobalId)." },
|
|
268
268
|
"role": { "type": "string", "description": "Hardware role from the source IFC: plate | bolt | weld." },
|
|
@@ -412,6 +412,7 @@
|
|
|
412
412
|
"properties": {
|
|
413
413
|
"id": { "type": "string" },
|
|
414
414
|
"profile": { "type": "string", "description": "AISC designation (e.g. W16X26) or an unresolved mark (e.g. MF). Empty string allowed for an unprofiled member." },
|
|
415
|
+
"material": { "type": "string", "description": "ASTM material grade, e.g. A992 / A572-50 / A500. Absent = derive the default from the section family (W→A992, HSS→A500, plate/angle→A572/A36). Populated by the reader from the drawing's general-notes grade table (Slice 5b); the History semantic diff surfaces a change to it as the Material facet." },
|
|
415
416
|
"wp": {
|
|
416
417
|
"type": "array",
|
|
417
418
|
"items": { "$ref": "#/$defs/point2" },
|
|
@@ -307,6 +307,7 @@ Important optional fields:
|
|
|
307
307
|
{
|
|
308
308
|
"id": "m1",
|
|
309
309
|
"profile": "W16X26",
|
|
310
|
+
"material": "A992",
|
|
310
311
|
"wp": [[x0,y0],[x1,y1]],
|
|
311
312
|
"angle": "H",
|
|
312
313
|
"role": "beam",
|
|
@@ -348,6 +349,12 @@ Key field rules:
|
|
|
348
349
|
default.
|
|
349
350
|
- `rfi: true` means no AISC size resolved — excluded from the BOM. `mf: true` marks a
|
|
350
351
|
moment-frame member (persistent; survives AISC resolution).
|
|
352
|
+
- **`material` (grade) — capture it (Slice 5b).** Read the drawing's **general-notes grade table**
|
|
353
|
+
(the "MATERIAL" / "STRUCTURAL STEEL" notes) and stamp each member's `material` from its section
|
|
354
|
+
family: wide-flange / WT → `A992`, HSS → `A500` (Gr. B or C per the notes), pipe → `A53`,
|
|
355
|
+
angles / channels / plates → `A36` or `A572-50` per the notes. A per-member callout on the drawing
|
|
356
|
+
overrides the family default. Capturing grade is what lets the Workspaces **History → "What
|
|
357
|
+
changed"** compare surface a **Material** change when a revision re-grades a member.
|
|
351
358
|
- `raster_b64` is a base64 JPEG of the framing area (title block excluded). It is
|
|
352
359
|
**machine-local** and **never committed**.
|
|
353
360
|
- **Connection library (`connections[]`) + per-end refs.** A vendor-neutral top-level
|
|
@@ -501,6 +508,24 @@ contract carries the source path in `source.path` and an embedded raster in `ras
|
|
|
501
508
|
are machine-local (stored under `~/.floless/contracts/steel-model.json` by the server and
|
|
502
509
|
never committed). Clip title blocks from any embedded preview.
|
|
503
510
|
|
|
511
|
+
### 9. Revision re-read — preserve member identity (Slice 5)
|
|
512
|
+
|
|
513
|
+
When the trigger is a **`revision-read`** request (a REVISED drawing set attached to an *existing*
|
|
514
|
+
project), the Workspaces **History → "What changed"** compare matches members between versions by
|
|
515
|
+
their `id`, scoped per `sheet`. So the re-read must keep identity stable:
|
|
516
|
+
|
|
517
|
+
1. **Fetch the current contract first** — `GET /api/contract/steel-model?project=<projectId>` (the
|
|
518
|
+
project id is in the request body). That is the previous version's working tree.
|
|
519
|
+
2. **Carry ids + sheets forward** — for every member that still exists in the revised set, reuse its
|
|
520
|
+
existing `id` and keep it on the same `sheet` label (e.g. `S-201` stays `S-201`). Mint a NEW id
|
|
521
|
+
only for a genuinely new member; simply drop the id of a member the revision deleted.
|
|
522
|
+
3. Then compose the updated `steel.takeoff/v1` and `POST /api/projects/<projectId>/revision-read
|
|
523
|
+
{ contract, message, requestId }` (that POST clears the request — do **not** DELETE it).
|
|
524
|
+
|
|
525
|
+
If you renumber everything, the diff shows a misleading "all removed + all added" and the app warns
|
|
526
|
+
**"identity not preserved."** Carrying ids forward is what makes *"beam B12 was upsized"* come out
|
|
527
|
+
exact instead of a churn of the whole model.
|
|
528
|
+
|
|
504
529
|
---
|
|
505
530
|
|
|
506
531
|
## Guardrails
|
package/dist/web/app.css
CHANGED
|
@@ -3508,3 +3508,76 @@ table.hist tr.current td { background:var(--accent-soft); }
|
|
|
3508
3508
|
.hist-confirm .hc-cancel { background:none; border:1px solid var(--border-strong); color:var(--text-muted);
|
|
3509
3509
|
border-radius:6px; padding:5px 11px; font-size:11.5px; cursor:pointer; }
|
|
3510
3510
|
.hist-confirm .hc-cancel:hover { color:var(--text); border-color:var(--accent); }
|
|
3511
|
+
|
|
3512
|
+
/* ── Workspaces ▸ History ▸ "What changed" semantic diff (Slice 5) ──────────────
|
|
3513
|
+
A per-version disclosure row (colspan) under table.hist. Uses only History tokens; group
|
|
3514
|
+
headers follow the same mono-uppercase idiom as table.hist th. No new fonts/colors/widgets. */
|
|
3515
|
+
.hist .hd-cell { width:26px; padding-left:12px; padding-right:0; }
|
|
3516
|
+
.hd-toggle { background:none; border:none; padding:2px; margin:0; cursor:pointer; color:var(--text-dim);
|
|
3517
|
+
display:inline-flex; align-items:center; border-radius:4px; line-height:1; }
|
|
3518
|
+
.hd-toggle:hover, .hd-toggle[aria-expanded="true"] { color:var(--text); background:var(--surface-2); }
|
|
3519
|
+
.hd-caret { display:inline-block; font-size:10px; transition:transform .18s ease-out; }
|
|
3520
|
+
.hd-toggle[aria-expanded="true"] .hd-caret { transform:rotate(90deg); }
|
|
3521
|
+
table.hist tr.hd-open td { background:var(--surface-2); }
|
|
3522
|
+
/* disclosure row: flat --surface (a step below --surface-2) so it reads as nested content, not a ledger row */
|
|
3523
|
+
table.hist tr.hist-diff-row > td { background:var(--surface); padding:0; }
|
|
3524
|
+
table.hist tr.hist-diff-row:hover > td { background:var(--surface); }
|
|
3525
|
+
tr.hist-diff-row[hidden] { display:none; }
|
|
3526
|
+
.hd-panel { padding:12px 16px 14px; font-size:12px; color:var(--text-muted); }
|
|
3527
|
+
/* net-steel header — QUIET by design (no green/red value judgment); the arrow glyph carries direction. */
|
|
3528
|
+
.hd-net { display:flex; align-items:center; gap:7px; padding-bottom:10px; margin-bottom:10px;
|
|
3529
|
+
border-bottom:1px solid var(--border); font-size:13px; }
|
|
3530
|
+
.hd-net-label { color:var(--text-muted); font-weight:600; }
|
|
3531
|
+
.hd-net-arrow { color:var(--text-dim); font-size:11px; }
|
|
3532
|
+
.hd-net-val { font-family:var(--mono); color:var(--text); font-weight:600; }
|
|
3533
|
+
.hd-net.flat .hd-net-val { color:var(--text-dim); }
|
|
3534
|
+
/* change-group blocks — header text color-coded via existing semantic tokens; rows plain (no per-row pills). */
|
|
3535
|
+
.hd-group { margin-bottom:10px; padding-left:9px; border-left:2px solid var(--border-strong); }
|
|
3536
|
+
.hd-group:last-child { margin-bottom:0; }
|
|
3537
|
+
.hd-type { font-family:var(--mono); font-size:9px; text-transform:uppercase; letter-spacing:.16em;
|
|
3538
|
+
font-weight:600; color:var(--text-dim); margin:0 0 5px; }
|
|
3539
|
+
.hd-type .hd-count { opacity:.7; }
|
|
3540
|
+
.hd-group.added { border-left-color:var(--ok); } .hd-group.added .hd-type { color:var(--ok); }
|
|
3541
|
+
.hd-group.removed { border-left-color:var(--err); } .hd-group.removed .hd-type { color:var(--err); }
|
|
3542
|
+
.hd-group.resized { border-left-color:var(--accent); } .hd-group.resized .hd-type { color:var(--accent-bright); }
|
|
3543
|
+
.hd-group.moved { border-left-color:var(--accent); } .hd-group.moved .hd-type { color:var(--accent-bright); }
|
|
3544
|
+
.hd-group.material { border-left-color:var(--warn); } .hd-group.material .hd-type { color:var(--warn); }
|
|
3545
|
+
.hd-group.connections { border-left-color:var(--info); } .hd-group.connections .hd-type { color:var(--info); }
|
|
3546
|
+
.hd-item { line-height:1.5; padding:1px 0; color:var(--text-muted); }
|
|
3547
|
+
.hd-mark { font-family:var(--mono); color:var(--text); font-weight:600; }
|
|
3548
|
+
.hd-prof { font-family:var(--mono); color:var(--text-muted); margin-left:6px; }
|
|
3549
|
+
.hd-summary { color:var(--text-muted); margin-left:6px; }
|
|
3550
|
+
.hd-sheet { font-size:10px; color:var(--text-dim); margin-left:6px; }
|
|
3551
|
+
.hd-xform { margin-left:6px; }
|
|
3552
|
+
.hd-arrow { color:var(--text-dim); margin:0 5px; }
|
|
3553
|
+
.hd-qual { color:var(--text-dim); font-style:italic; margin-left:6px; }
|
|
3554
|
+
.hd-tail { color:var(--text-dim); font-size:11px; padding-top:8px; }
|
|
3555
|
+
/* caution banner — the .hist-working recipe in amber (warn); never dismissible (load-bearing context). */
|
|
3556
|
+
.hd-warn { margin:0 0 11px; padding:9px 12px; display:flex; gap:8px; align-items:flex-start;
|
|
3557
|
+
background:var(--surface-2); border:1px solid var(--border); border-left:3px solid var(--warn);
|
|
3558
|
+
border-radius:6px; font-size:11.5px; color:var(--text-muted); }
|
|
3559
|
+
.hd-warn b { color:var(--text); font-weight:600; }
|
|
3560
|
+
.hd-warn-ico { color:var(--warn); flex:none; }
|
|
3561
|
+
/* empty / loading / error states — never a blank panel */
|
|
3562
|
+
.hd-empty { color:var(--text-dim); font-style:italic; text-align:center; padding:14px 0; }
|
|
3563
|
+
.hd-loading { color:var(--text-dim); padding:10px 0; }
|
|
3564
|
+
.hd-err { color:var(--text-muted); padding:8px 0; }
|
|
3565
|
+
.hd-err button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
|
|
3566
|
+
.hd-err button:hover { color:var(--accent-bright); text-decoration:underline; }
|
|
3567
|
+
/* baseline picker — inline "Compared to vX ▾" + a themed anchored popover (NEVER a native <select>). */
|
|
3568
|
+
.hd-basewrap { display:flex; align-items:center; gap:7px; margin-bottom:11px; font-size:11px; color:var(--text-dim); }
|
|
3569
|
+
.hd-base { background:var(--surface-2); border:1px solid var(--border-strong); color:var(--text-muted);
|
|
3570
|
+
border-radius:4px; padding:3px 8px; font:inherit; font-size:11px; cursor:pointer; display:inline-flex; align-items:center; gap:5px; }
|
|
3571
|
+
.hd-base:hover { color:var(--text); border-color:var(--accent); }
|
|
3572
|
+
.hd-base-caret { color:var(--text-dim); font-size:9px; }
|
|
3573
|
+
.hd-base-menu { position:absolute; z-index:20; min-width:150px; max-height:220px; overflow-y:auto;
|
|
3574
|
+
background:var(--surface-3); border:1px solid var(--border-strong); border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4);
|
|
3575
|
+
padding:4px; scrollbar-width:thin; scrollbar-color:var(--border-strong) transparent; }
|
|
3576
|
+
.hd-base-menu::-webkit-scrollbar { width:8px; }
|
|
3577
|
+
.hd-base-menu::-webkit-scrollbar-thumb { background:var(--border-strong); border-radius:4px; }
|
|
3578
|
+
.hd-base-menu::-webkit-scrollbar-track { background:transparent; }
|
|
3579
|
+
.hd-base-opt { display:flex; justify-content:space-between; gap:12px; width:100%; background:none; border:none;
|
|
3580
|
+
color:var(--text-muted); font:inherit; font-size:11.5px; text-align:left; padding:5px 8px; border-radius:5px; cursor:pointer; }
|
|
3581
|
+
.hd-base-opt:hover { background:var(--surface-2); color:var(--text); }
|
|
3582
|
+
.hd-base-opt.sel { color:var(--accent-bright); }
|
|
3583
|
+
.hd-base-opt .hd-base-meta { font-family:var(--mono); font-size:10px; color:var(--text-dim); }
|
package/dist/web/aware.js
CHANGED
|
@@ -4660,7 +4660,7 @@
|
|
|
4660
4660
|
? `\nRevised drawing${req.snapshots.length > 1 ? 's' : ''} (read these): ${req.snapshots.join(', ')}`
|
|
4661
4661
|
: '';
|
|
4662
4662
|
const note = req.instruction ? `\nMy note: ${req.instruction}` : '';
|
|
4663
|
-
return `In floless project "${req.project}" (app "${req.appId}"), a REVISED drawing set was attached — re-read it into the SAME project as a new version (do NOT create a new project). Read the drawings per the floless-app-steel-takeoff skill, compose the updated steel.takeoff/v1 contract
|
|
4663
|
+
return `In floless project "${req.project}" (app "${req.appId}"), a REVISED drawing set was attached — re-read it into the SAME project as a new version (do NOT create a new project). Read the drawings per the floless-app-steel-takeoff skill, compose the updated steel.takeoff/v1 contract. First GET /api/contract/${req.appId}?project=${req.project} to read the CURRENT contract and PRESERVE each still-present member's id AND its sheet label (mint a NEW id only for a genuinely new member; drop the id of a member the revision deleted) so the version comparison matches members — do NOT renumber the whole model. Then POST /api/projects/${req.project}/revision-read with { contract, message: "<plain-English what changed>", requestId: "${req.id}" }. The server derives the base version + source provenance from this queued request, records a "revision-read" version, and clears the request — do NOT DELETE the request yourself. If you can't read the set, POST /api/projects/${req.project}/revision-requests/${req.id}/fail with { error }.${snaps}${note}`;
|
|
4664
4664
|
}
|
|
4665
4665
|
return '';
|
|
4666
4666
|
}
|
|
@@ -3054,6 +3054,23 @@ const view3dApi={
|
|
|
3054
3054
|
toast((had?'Base plate on '+col.id+' replaced with imported “'+(conn.name||'connection')+'”':'Base plate “'+(conn.name||'imported')+'” applied to '+col.id)+' — edit its parameters on the member');
|
|
3055
3055
|
return;
|
|
3056
3056
|
}
|
|
3057
|
+
// Slice E: a RECOGNIZED shear/fin plate dropped onto a BEAM → bake an EDITABLE shear-plate recipe joint on
|
|
3058
|
+
// the nearest end; expandShearPlate re-derives it there from the fitted params. Needs a beam at the pick.
|
|
3059
|
+
const beam=(rc&&rc.kind==='shear-plate'&&pick.anchorId)?P.members.find(m=>m&&m.id===pick.anchorId&&m.role==='beam'):null;
|
|
3060
|
+
if(beam){
|
|
3061
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3062
|
+
// Nearest end via the resolved 3D geometry: end0 = beam.from (wp[0]), end1 = beam.to (wp[1]).
|
|
3063
|
+
const g=partsById[beam.id],d3=(a,b)=>a&&b?Math.hypot(a[0]-b[0],a[1]-b[1],a[2]-b[2]):Infinity;
|
|
3064
|
+
const endIdx=(g&&g.from&&g.to&&d3(pick.point,g.to)<d3(pick.point,g.from))?1:0;
|
|
3065
|
+
const joint={id,kind:'shear-plate',main:beam.id,at:'end'+endIdx,params:Object.assign({},rc.params),source:'user'};
|
|
3066
|
+
pendingConnSel=id; // its parts only exist after the 3D rebuild → select the whole connection there
|
|
3067
|
+
// One shear plate per beam END: replace any existing joint on this end (else two overlap and "edit on
|
|
3068
|
+
// member" would target the older joint, not this import).
|
|
3069
|
+
const had=(C.joints||[]).some(x=>x&&x.kind==='shear-plate'&&x.main===beam.id&&x.at==='end'+endIdx);
|
|
3070
|
+
edit(()=>{C.joints=(Array.isArray(C.joints)?C.joints:[]).filter(x=>!(x&&x.kind==='shear-plate'&&x.main===beam.id&&x.at==='end'+endIdx));C.joints.push(joint);selIds=new Set();});
|
|
3071
|
+
toast((had?'Shear plate on '+beam.id+' '+(endIdx?'end':'start')+' replaced with imported “'+(conn.name||'connection')+'”':'Shear plate “'+(conn.name||'imported')+'” applied to '+beam.id+' '+(endIdx?'end':'start'))+' — edit its parameters on the member');
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3057
3074
|
// Slice B: opaque custom mesh — bake at the picked point (joint.place); expandCustom re-expands it into
|
|
3058
3075
|
// the scene as one selectable unit. Unrecognized imports, and a recognized base plate NOT dropped on a
|
|
3059
3076
|
// column, land here (still faithful geometry) with a hint toward the editable path.
|
|
@@ -3062,7 +3079,7 @@ const view3dApi={
|
|
|
3062
3079
|
const joint={id,kind:'custom',name:conn.name||'Imported connection',place:pick.point,geometry:conn.geometry,source:'user'};
|
|
3063
3080
|
if(pick.anchorId)joint.main=pick.anchorId; // snapped to a member face → record it for the inspector's "on member" line
|
|
3064
3081
|
edit(()=>{if(!Array.isArray(C.joints))C.joints=[];C.joints.push(joint);selIds=new Set(conn.geometry.map((g,i)=>id+':'+(g.id||'m'+i)));});
|
|
3065
|
-
toast(rc?('Imported “'+(conn.name||'connection')+'” as geometry — drop it on a column to apply it as an editable base plate')
|
|
3082
|
+
toast(rc?('Imported “'+(conn.name||'connection')+'” as geometry — drop it on a '+(rc.kind==='shear-plate'?'beam end to apply it as an editable shear plate':'column to apply it as an editable base plate'))
|
|
3066
3083
|
:('Connection “'+(conn.name||'imported')+'” placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — select it to move or replace'));
|
|
3067
3084
|
return;
|
|
3068
3085
|
}
|
package/dist/web/workspaces.js
CHANGED
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
const showHistory = s === 'history';
|
|
215
215
|
$wsHistory.hidden = !showHistory;
|
|
216
216
|
if (showHistory) renderHistory();
|
|
217
|
-
else closeRollbackConfirm(); // don't leave a
|
|
217
|
+
else { closeRollbackConfirm(); closeBaseMenu(); } // don't leave a popover open behind a step switch
|
|
218
218
|
}
|
|
219
219
|
$stepTabs.addEventListener('click', (e) => {
|
|
220
220
|
const b = e.target.closest('button[data-step]');
|
|
@@ -575,18 +575,31 @@
|
|
|
575
575
|
const action = v.current
|
|
576
576
|
? ''
|
|
577
577
|
: `<button type="button" class="btn-mini" data-rollback="${escapeAttr(String(v.n))}" data-tip="Restore this version as a new version">↺ Rollback</button>`;
|
|
578
|
-
|
|
578
|
+
// "What changed" disclosure — only for a version that HAS a predecessor (n>1). v1 is the baseline,
|
|
579
|
+
// so it gets NO toggle at all (an ABSENT control, never a disabled one).
|
|
580
|
+
const canDiff = Number(v.n) > 1;
|
|
581
|
+
const toggle = canDiff
|
|
582
|
+
? `<button type="button" class="hd-toggle" data-diff="${escapeAttr(String(v.n))}" aria-expanded="false" data-tip="What changed vs the previous version"><span class="hd-caret">▸</span></button>`
|
|
583
|
+
: '';
|
|
584
|
+
const verRow = `<tr class="${v.current ? 'current' : ''}" data-ver="${escapeAttr(String(v.n))}">` +
|
|
585
|
+
`<td class="hd-cell">${toggle}</td>` +
|
|
579
586
|
`<td><span class="ver${v.current ? ' cur' : ''}">v${escapeHtml(String(v.n))}</span></td>` +
|
|
580
587
|
`<td class="htime">${escapeHtml(histWhen(v.ts))}</td>` +
|
|
581
588
|
`<td><span class="hby">${avatarFor(v.author)}${escapeHtml(v.author)}</span></td>` +
|
|
582
589
|
`<td>${gateBadge(v.gate, v.kind)}</td>` +
|
|
583
590
|
`<td class="change">${escapeHtml(v.message)}</td>` +
|
|
584
591
|
`<td>${action}</td></tr>`;
|
|
592
|
+
// The disclosure row is always emitted (hidden) so toggling is a pure show/hide + lazy fetch.
|
|
593
|
+
const diffRow = canDiff
|
|
594
|
+
? `<tr class="hist-diff-row" data-diff-for="${escapeAttr(String(v.n))}" hidden><td colspan="7"><div class="hd-panel" data-panel="${escapeAttr(String(v.n))}"></div></td></tr>`
|
|
595
|
+
: '';
|
|
596
|
+
return verRow + diffRow;
|
|
585
597
|
}
|
|
586
598
|
|
|
587
599
|
async function renderHistory() {
|
|
588
600
|
if (!current) return;
|
|
589
601
|
const id = current.id;
|
|
602
|
+
closeBaseMenu(); // this re-render replaces the History HTML — drop any open base-picker's listener first
|
|
590
603
|
$wsHistory.innerHTML = '<div class="hist-note">Loading history…</div>';
|
|
591
604
|
let rows = [];
|
|
592
605
|
let approvedAt;
|
|
@@ -627,6 +640,7 @@
|
|
|
627
640
|
// Version/Gate/action are fixed-narrow; When/By auto-size to their nowrap content (fixing them
|
|
628
641
|
// wider than the content wastes room and needlessly wraps Change). Change flexes to the rest.
|
|
629
642
|
'<table class="hist"><thead><tr>' +
|
|
643
|
+
'<th style="width:26px"></th>' +
|
|
630
644
|
'<th style="width:44px">Version</th><th>When</th><th>By</th>' +
|
|
631
645
|
'<th style="width:80px">Gate</th><th style="width:99%">Change</th><th style="width:92px"></th>' +
|
|
632
646
|
'</tr></thead><tbody>' + rows.map(versionRowHtml).join('') + '</tbody></table>';
|
|
@@ -677,6 +691,211 @@
|
|
|
677
691
|
}
|
|
678
692
|
}
|
|
679
693
|
|
|
694
|
+
// ── "What changed" semantic diff (Slice 5) ─────────────────────────────────
|
|
695
|
+
// The panel is DOM-built with textContent for every AI/drawing-supplied string (marks, profiles,
|
|
696
|
+
// connection summaries) — never innerHTML, per the file's security contract.
|
|
697
|
+
const DIFF_GROUPS = [
|
|
698
|
+
{ key: 'added', label: 'Added' },
|
|
699
|
+
{ key: 'removed', label: 'Removed' },
|
|
700
|
+
{ key: 'resized', label: 'Resized' },
|
|
701
|
+
{ key: 'moved', label: 'Moved' },
|
|
702
|
+
{ key: 'material', label: 'Material' },
|
|
703
|
+
{ key: 'connections', label: 'Connections' },
|
|
704
|
+
];
|
|
705
|
+
const diffBase = new Map(); // "<projectId>\0<n>" → chosen baseline (undefined = compare to the previous version)
|
|
706
|
+
const baseKeyFor = (n) => (current && current.id ? current.id : '?') + '' + n; // project-scoped so a base pick can't bleed across projects sharing a version number
|
|
707
|
+
const el = (tag, cls, text) => {
|
|
708
|
+
const n = document.createElement(tag);
|
|
709
|
+
if (cls) n.className = cls;
|
|
710
|
+
if (text != null) n.textContent = text;
|
|
711
|
+
return n;
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
function diffMemberBasic(ref) {
|
|
715
|
+
const line = el('div', 'hd-item');
|
|
716
|
+
line.appendChild(el('span', 'hd-mark', ref.mark || ref.id));
|
|
717
|
+
if (ref.profile) line.appendChild(el('span', 'hd-prof', ref.profile));
|
|
718
|
+
if (ref.sheet) line.appendChild(el('span', 'hd-sheet', ref.sheet));
|
|
719
|
+
return line;
|
|
720
|
+
}
|
|
721
|
+
function diffMemberXform(ref, fromTxt, toTxt) {
|
|
722
|
+
const line = el('div', 'hd-item');
|
|
723
|
+
line.appendChild(el('span', 'hd-mark', ref.mark || ref.id));
|
|
724
|
+
const x = el('span', 'hd-xform');
|
|
725
|
+
x.appendChild(el('span', 'hd-prof', fromTxt || '—'));
|
|
726
|
+
x.appendChild(el('span', 'hd-arrow', '→'));
|
|
727
|
+
x.appendChild(el('span', 'hd-prof', toTxt || '—'));
|
|
728
|
+
line.appendChild(x);
|
|
729
|
+
if (ref.sheet) line.appendChild(el('span', 'hd-sheet', ref.sheet));
|
|
730
|
+
return line;
|
|
731
|
+
}
|
|
732
|
+
function diffMemberMoved(it) {
|
|
733
|
+
const line = el('div', 'hd-item');
|
|
734
|
+
line.appendChild(el('span', 'hd-mark', it.ref.mark || it.ref.id));
|
|
735
|
+
line.appendChild(el('span', 'hd-prof', `moved ~${it.feet} ft`));
|
|
736
|
+
if (it.scaleUnknown) line.appendChild(el('span', 'hd-qual', 'scale unknown'));
|
|
737
|
+
if (it.ref.sheet) line.appendChild(el('span', 'hd-sheet', it.ref.sheet));
|
|
738
|
+
return line;
|
|
739
|
+
}
|
|
740
|
+
function diffMemberConn(it) {
|
|
741
|
+
const line = el('div', 'hd-item');
|
|
742
|
+
line.appendChild(el('span', 'hd-mark', it.ref.mark || it.ref.id));
|
|
743
|
+
line.appendChild(el('span', 'hd-summary', it.summary || 'connection changed'));
|
|
744
|
+
if (it.ref.sheet) line.appendChild(el('span', 'hd-sheet', it.ref.sheet));
|
|
745
|
+
return line;
|
|
746
|
+
}
|
|
747
|
+
function diffRowFor(key, it) {
|
|
748
|
+
if (key === 'added' || key === 'removed') return diffMemberBasic(it);
|
|
749
|
+
if (key === 'resized' || key === 'material') return diffMemberXform(it.ref, it.from, it.to);
|
|
750
|
+
if (key === 'moved') return diffMemberMoved(it);
|
|
751
|
+
return diffMemberConn(it);
|
|
752
|
+
}
|
|
753
|
+
function diffGroup(g, items) {
|
|
754
|
+
if (!items || !items.length) return null;
|
|
755
|
+
const box = el('div', `hd-group ${g.key}`);
|
|
756
|
+
const head = el('div', 'hd-type');
|
|
757
|
+
head.appendChild(el('span', null, g.label.toUpperCase()));
|
|
758
|
+
head.appendChild(document.createTextNode(' '));
|
|
759
|
+
head.appendChild(el('span', 'hd-count', `(${items.length})`));
|
|
760
|
+
box.appendChild(head);
|
|
761
|
+
for (const it of items) box.appendChild(diffRowFor(g.key, it));
|
|
762
|
+
return box;
|
|
763
|
+
}
|
|
764
|
+
function diffNetHeader(t) {
|
|
765
|
+
const wrap = el('div', 'hd-net');
|
|
766
|
+
const net = t && typeof t.netTons === 'number' ? t.netTons : 0;
|
|
767
|
+
if (net === 0) wrap.classList.add('flat');
|
|
768
|
+
wrap.appendChild(el('span', 'hd-net-label', 'Net steel:'));
|
|
769
|
+
wrap.appendChild(el('span', 'hd-net-arrow', net > 0 ? '▲' : net < 0 ? '▼' : '•'));
|
|
770
|
+
wrap.appendChild(el('span', 'hd-net-val', `${net > 0 ? '+' : ''}${net} tons`));
|
|
771
|
+
return wrap;
|
|
772
|
+
}
|
|
773
|
+
// The engine's four honest signals — each names its CONSEQUENCE so a withheld member/connection is
|
|
774
|
+
// never silently absent from the panel. ALL render here (not just identity), above the numbers.
|
|
775
|
+
function diffCautions(w) {
|
|
776
|
+
if (!w) return [];
|
|
777
|
+
const mk = (strong, rest) => {
|
|
778
|
+
const box = el('div', 'hd-warn');
|
|
779
|
+
box.appendChild(el('span', 'hd-warn-ico', '⚠'));
|
|
780
|
+
const body = el('div');
|
|
781
|
+
if (strong) body.appendChild(el('b', null, strong));
|
|
782
|
+
if (rest) body.appendChild(document.createTextNode((strong ? ' ' : '') + rest));
|
|
783
|
+
box.appendChild(body);
|
|
784
|
+
return box;
|
|
785
|
+
};
|
|
786
|
+
const out = [];
|
|
787
|
+
if (w.identityNotPreserved) out.push(mk('These two reads don’t share member identity.', 'The comparison may be unreliable — the revised set looks re-read without carrying member labels forward.'));
|
|
788
|
+
if (w.duplicateKeys && w.duplicateKeys.length) out.push(mk(null, `${w.duplicateKeys.length} member${w.duplicateKeys.length > 1 ? 's were' : ' was'} left out — a duplicate id appears more than once on a sheet, so its changes can’t be attributed.`));
|
|
789
|
+
if (w.ambiguousJoints && w.ambiguousJoints.length) out.push(mk(null, 'Some connection changes couldn’t be attributed (a joint’s member id is shared across sheets) and were left out rather than guessed.'));
|
|
790
|
+
if (w.scaleUnknown && !w.identityNotPreserved) out.push(mk(null, 'A drawing’s scale is unknown, so the move distances shown are approximate.'));
|
|
791
|
+
return out;
|
|
792
|
+
}
|
|
793
|
+
function diffBasePicker(diff) {
|
|
794
|
+
const wrap = el('div', 'hd-basewrap');
|
|
795
|
+
wrap.appendChild(el('span', null, 'Compared to'));
|
|
796
|
+
const btn = el('button', 'hd-base');
|
|
797
|
+
btn.type = 'button';
|
|
798
|
+
btn.dataset.baseFor = String(diff.to);
|
|
799
|
+
btn.setAttribute('data-tip', 'Compare against an earlier version');
|
|
800
|
+
btn.appendChild(el('span', null, `v${diff.from}`));
|
|
801
|
+
btn.appendChild(el('span', 'hd-base-caret', '▾'));
|
|
802
|
+
wrap.appendChild(btn);
|
|
803
|
+
return wrap;
|
|
804
|
+
}
|
|
805
|
+
function renderDiffPanel(panel, diff) {
|
|
806
|
+
panel.textContent = '';
|
|
807
|
+
panel.appendChild(diffBasePicker(diff));
|
|
808
|
+
for (const c of diffCautions(diff.warnings)) panel.appendChild(c); // above the numbers — context for reading them
|
|
809
|
+
panel.appendChild(diffNetHeader(diff.tonnage));
|
|
810
|
+
let any = false;
|
|
811
|
+
for (const g of DIFF_GROUPS) {
|
|
812
|
+
const grp = diffGroup(g, diff[g.key]);
|
|
813
|
+
if (grp) { panel.appendChild(grp); any = true; }
|
|
814
|
+
}
|
|
815
|
+
if (!any) {
|
|
816
|
+
panel.appendChild(el('div', 'hd-empty', `No changes detected${diff.unchanged ? ` — ${diff.unchanged} members unchanged` : ''}.`));
|
|
817
|
+
} else if (diff.unchanged) {
|
|
818
|
+
panel.appendChild(el('div', 'hd-tail', `${diff.unchanged} members unchanged`));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
async function loadDiff(n, base) {
|
|
822
|
+
const proj = current;
|
|
823
|
+
if (!proj) return;
|
|
824
|
+
const panel = $wsHistory.querySelector(`.hd-panel[data-panel="${CSS.escape(String(n))}"]`);
|
|
825
|
+
if (!panel) return;
|
|
826
|
+
const stamp = `${proj.id}:${n}:${base ?? ''}`; // guard against a stale response painting the wrong project/version/base
|
|
827
|
+
panel.dataset.stamp = stamp;
|
|
828
|
+
panel.textContent = '';
|
|
829
|
+
panel.appendChild(el('div', 'hd-loading', 'Comparing…'));
|
|
830
|
+
let diff;
|
|
831
|
+
try {
|
|
832
|
+
const q = base != null ? `?base=${encodeURIComponent(base)}` : '';
|
|
833
|
+
diff = (await api(`/api/projects/${encodeURIComponent(proj.id)}/versions/${encodeURIComponent(n)}/diff${q}`)).diff;
|
|
834
|
+
} catch (err) {
|
|
835
|
+
if (current && current.id === proj.id && panel.dataset.stamp === stamp) {
|
|
836
|
+
panel.textContent = '';
|
|
837
|
+
const box = el('div', 'hd-err');
|
|
838
|
+
box.appendChild(document.createTextNode('Couldn’t load the comparison' + (err && err.message ? ` — ${err.message}` : '') + ' — '));
|
|
839
|
+
const retry = el('button', null, 'try again');
|
|
840
|
+
retry.type = 'button';
|
|
841
|
+
retry.dataset.diffRetry = String(n);
|
|
842
|
+
box.appendChild(retry);
|
|
843
|
+
panel.appendChild(box);
|
|
844
|
+
}
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (!current || current.id !== proj.id || panel.dataset.stamp !== stamp) return; // switched away / re-picked mid-await
|
|
848
|
+
renderDiffPanel(panel, diff);
|
|
849
|
+
}
|
|
850
|
+
function toggleDiff(btn) {
|
|
851
|
+
const n = Number(btn.dataset.diff);
|
|
852
|
+
const row = $wsHistory.querySelector(`tr.hist-diff-row[data-diff-for="${CSS.escape(String(n))}"]`);
|
|
853
|
+
if (!row) return;
|
|
854
|
+
const verRow = btn.closest('tr');
|
|
855
|
+
if (row.hasAttribute('hidden')) {
|
|
856
|
+
row.removeAttribute('hidden');
|
|
857
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
858
|
+
if (verRow) verRow.classList.add('hd-open');
|
|
859
|
+
const panel = row.querySelector('.hd-panel');
|
|
860
|
+
if (panel && !panel.dataset.stamp) loadDiff(n, diffBase.get(baseKeyFor(n))); // lazy first load
|
|
861
|
+
} else {
|
|
862
|
+
row.setAttribute('hidden', '');
|
|
863
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
864
|
+
if (verRow) verRow.classList.remove('hd-open');
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// Baseline picker popover (mirrors the rollback-confirm popover idiom; never a native <select>).
|
|
868
|
+
function onDocDownForBase(e) {
|
|
869
|
+
if (!e.target.closest('.hd-base-menu') && !e.target.closest('.hd-base')) closeBaseMenu();
|
|
870
|
+
}
|
|
871
|
+
function closeBaseMenu() {
|
|
872
|
+
const m = $wsHistory.querySelector('.hd-base-menu');
|
|
873
|
+
if (m) m.remove();
|
|
874
|
+
document.removeEventListener('mousedown', onDocDownForBase, true);
|
|
875
|
+
}
|
|
876
|
+
function showBaseMenu(btn) {
|
|
877
|
+
closeBaseMenu();
|
|
878
|
+
const to = Number(btn.dataset.baseFor);
|
|
879
|
+
const cur = diffBase.get(baseKeyFor(to)) ?? to - 1;
|
|
880
|
+
const menu = el('div', 'hd-base-menu');
|
|
881
|
+
for (const v of versions) { // module-level ledger (newest first); only earlier versions are valid bases
|
|
882
|
+
if (Number(v.n) >= to) continue;
|
|
883
|
+
const opt = el('button', 'hd-base-opt' + (Number(v.n) === cur ? ' sel' : ''));
|
|
884
|
+
opt.type = 'button';
|
|
885
|
+
opt.dataset.baseOpt = String(v.n);
|
|
886
|
+
opt.dataset.baseTo = String(to);
|
|
887
|
+
opt.appendChild(el('span', null, `v${v.n}`));
|
|
888
|
+
opt.appendChild(el('span', 'hd-base-meta', histWhen(v.ts)));
|
|
889
|
+
menu.appendChild(opt);
|
|
890
|
+
}
|
|
891
|
+
$wsHistory.appendChild(menu);
|
|
892
|
+
const pr = $wsHistory.getBoundingClientRect();
|
|
893
|
+
const br = btn.getBoundingClientRect();
|
|
894
|
+
menu.style.top = br.bottom - pr.top + $wsHistory.scrollTop + 4 + 'px';
|
|
895
|
+
menu.style.left = Math.max(8, Math.min(br.left - pr.left, $wsHistory.clientWidth - menu.offsetWidth - 8)) + 'px';
|
|
896
|
+
document.addEventListener('mousedown', onDocDownForBase, true);
|
|
897
|
+
}
|
|
898
|
+
|
|
680
899
|
$wsHistory.addEventListener('click', (e) => {
|
|
681
900
|
if (e.target.closest('#hist-approve')) { approveProject(); return; }
|
|
682
901
|
if (e.target.closest('.hc-cancel')) { closeRollbackConfirm(); return; }
|
|
@@ -684,6 +903,14 @@
|
|
|
684
903
|
if (go) { rollbackTo(Number(go.dataset.hcGo)); return; }
|
|
685
904
|
const roll = e.target.closest('[data-rollback]');
|
|
686
905
|
if (roll) { showRollbackConfirm(roll, Number(roll.dataset.rollback)); return; }
|
|
906
|
+
const tog = e.target.closest('.hd-toggle');
|
|
907
|
+
if (tog) { toggleDiff(tog); return; }
|
|
908
|
+
const opt = e.target.closest('[data-base-opt]');
|
|
909
|
+
if (opt) { const to = Number(opt.dataset.baseTo), b = Number(opt.dataset.baseOpt); diffBase.set(baseKeyFor(to), b); closeBaseMenu(); loadDiff(to, b); return; }
|
|
910
|
+
const baseBtn = e.target.closest('.hd-base');
|
|
911
|
+
if (baseBtn) { showBaseMenu(baseBtn); return; }
|
|
912
|
+
const dr = e.target.closest('[data-diff-retry]');
|
|
913
|
+
if (dr) { const n = Number(dr.dataset.diffRetry); loadDiff(n, diffBase.get(baseKeyFor(n))); return; }
|
|
687
914
|
});
|
|
688
915
|
|
|
689
916
|
// ── project picker + lifecycle (full set lives HERE; ≡ carries nothing) ────
|