@floless/app 0.75.0 → 0.77.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 +1200 -768
- 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 +94 -2
- package/dist/web/app.js +13 -1
- package/dist/web/aware.js +16 -3
- package/dist/web/index.html +9 -0
- package/dist/web/steel-editor.html +76 -19
- package/dist/web/workspaces.js +425 -11
- 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
|
@@ -3344,6 +3344,8 @@ body {
|
|
|
3344
3344
|
.app.mode-workspaces .notes-strip, .app.mode-workspaces .find-overlay,
|
|
3345
3345
|
/* the ws surfaces carry their own headings — hide the canvas's "Canvas · transparency layer" label */
|
|
3346
3346
|
.app.mode-workspaces .canvas > .panel-label { display:none !important; }
|
|
3347
|
+
/* …but Find in model reuses that overlay — un-hide it while open (Ctrl+F searches editor members). */
|
|
3348
|
+
.app.mode-workspaces .find-overlay.show { display: flex !important; }
|
|
3347
3349
|
/* the editor owns center+right in a project — collapse the inspect column entirely */
|
|
3348
3350
|
.app.mode-workspaces { --right-width:0px !important; }
|
|
3349
3351
|
.app.mode-workspaces .inspect { display:none; }
|
|
@@ -3473,9 +3475,26 @@ table.hist tr.current td { background:var(--accent-soft); }
|
|
|
3473
3475
|
.hist .gate-badge { display:inline-block; padding:2px 7px; border-radius:4px; font-size:9.5px; font-weight:600;
|
|
3474
3476
|
text-transform:uppercase; letter-spacing:.05em; background:transparent; border:1px solid var(--border-strong); color:var(--text-dim); }
|
|
3475
3477
|
.hist .gate-badge.model { background:var(--accent-soft); border-color:var(--accent-dim); color:var(--accent-bright); }
|
|
3476
|
-
.hist .btn-mini { background:none; border:1px solid var(--border-strong); color:var(--text-muted); border-radius:6px;
|
|
3478
|
+
.hist .btn-mini, .ws-drawings-bar .btn-mini, .ws-revision-card .btn-mini { background:none; border:1px solid var(--border-strong); color:var(--text-muted); border-radius:6px;
|
|
3477
3479
|
padding:4px 10px; font-size:11px; cursor:pointer; white-space:nowrap; }
|
|
3478
|
-
.hist .btn-mini:hover { color:var(--text); border-color:var(--accent); }
|
|
3480
|
+
.hist .btn-mini:hover, .ws-drawings-bar .btn-mini:hover, .ws-revision-card .btn-mini:hover { color:var(--text); border-color:var(--accent); }
|
|
3481
|
+
|
|
3482
|
+
/* ── Workspaces: revision re-read (Slice 4b) — attach band + pending/failed card ── */
|
|
3483
|
+
.ws-drawings-bar { flex:none; display:flex; align-items:center; gap:10px; padding:8px 14px; border-bottom:1px solid var(--border); background:var(--surface); }
|
|
3484
|
+
.ws-drawings-bar[hidden] { display:none !important; }
|
|
3485
|
+
.ws-drawings-bar-label { font-size:10px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.1em; }
|
|
3486
|
+
.ws-drawings-bar-sep { flex:1; }
|
|
3487
|
+
.ws-revision-card { flex:none; display:flex; align-items:center; gap:10px; padding:9px 14px; border-bottom:1px solid var(--border); background:var(--surface-2); font-size:12px; color:var(--text-muted); }
|
|
3488
|
+
.ws-revision-card[hidden] { display:none !important; }
|
|
3489
|
+
.ws-revision-card b { color:var(--text); font-weight:600; }
|
|
3490
|
+
.ws-revision-card .wrc-text { flex:1; min-width:0; }
|
|
3491
|
+
.ws-revision-card .wrc-spin { flex:none; width:14px; height:14px; border:2px solid var(--border-strong); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; }
|
|
3492
|
+
.ws-revision-card.state-failed { border-left:3px solid var(--err); }
|
|
3493
|
+
.ws-revision-card.state-failed .wrc-err-ico { color:var(--err); flex:none; }
|
|
3494
|
+
.ws-revision-card.state-failed .wrc-err-detail { color:var(--text-dim); }
|
|
3495
|
+
.ws-revision-card .wrc-actions { display:flex; gap:6px; flex:none; }
|
|
3496
|
+
@media (prefers-reduced-motion: reduce) { .ws-revision-card .wrc-spin { animation:none; } }
|
|
3497
|
+
.hist .gate-badge.ai-read { background:var(--accent-soft); border-color:var(--accent-dim); color:var(--accent-bright); }
|
|
3479
3498
|
|
|
3480
3499
|
/* Rollback confirm — a styled anchored popover (NEVER a native dialog); names the Exports re-lock. */
|
|
3481
3500
|
.hist-confirm { position:absolute; z-index:20; width:264px; padding:12px 13px; background:var(--surface-3);
|
|
@@ -3489,3 +3508,76 @@ table.hist tr.current td { background:var(--accent-soft); }
|
|
|
3489
3508
|
.hist-confirm .hc-cancel { background:none; border:1px solid var(--border-strong); color:var(--text-muted);
|
|
3490
3509
|
border-radius:6px; padding:5px 11px; font-size:11.5px; cursor:pointer; }
|
|
3491
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/app.js
CHANGED
|
@@ -1094,6 +1094,10 @@ const $findInput = document.getElementById('find-input');
|
|
|
1094
1094
|
const $findCount = document.getElementById('find-count');
|
|
1095
1095
|
|
|
1096
1096
|
function openFind() {
|
|
1097
|
+
const ws = window.flolessWorkspaces && window.flolessWorkspaces.findActive();
|
|
1098
|
+
// In Workspaces mode there's nothing to find off the Model step — don't pop a dead overlay.
|
|
1099
|
+
if (document.getElementById('app').classList.contains('mode-workspaces') && !ws) return;
|
|
1100
|
+
$findInput.placeholder = ws ? 'Find in model…' : 'Find agent…';
|
|
1097
1101
|
$findOverlay.classList.add('show');
|
|
1098
1102
|
$findInput.value = '';
|
|
1099
1103
|
$findCount.textContent = '';
|
|
@@ -1101,13 +1105,21 @@ function openFind() {
|
|
|
1101
1105
|
}
|
|
1102
1106
|
function closeFind() {
|
|
1103
1107
|
$findOverlay.classList.remove('show');
|
|
1108
|
+
if (window.flolessWorkspaces && window.flolessWorkspaces.clearFind) window.flolessWorkspaces.clearFind();
|
|
1104
1109
|
document.querySelectorAll('.agent-card').forEach(c => {
|
|
1105
1110
|
c.classList.remove('find-dim', 'find-match');
|
|
1106
1111
|
});
|
|
1107
1112
|
}
|
|
1108
1113
|
|
|
1109
1114
|
$findInput.addEventListener('input', () => {
|
|
1110
|
-
const
|
|
1115
|
+
const raw = $findInput.value;
|
|
1116
|
+
const q = raw.toLowerCase().trim();
|
|
1117
|
+
// Workspaces + Model step: search members in the embedded editor, not the canvas.
|
|
1118
|
+
if (window.flolessWorkspaces && window.flolessWorkspaces.findActive()) {
|
|
1119
|
+
const r = window.flolessWorkspaces.find(raw) || { count: 0 };
|
|
1120
|
+
$findCount.textContent = q ? `${r.count} match${r.count === 1 ? '' : 'es'}` : '';
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1111
1123
|
let matches = 0;
|
|
1112
1124
|
document.querySelectorAll('.agent-card').forEach(card => {
|
|
1113
1125
|
if (!q) {
|
package/dist/web/aware.js
CHANGED
|
@@ -4655,6 +4655,13 @@
|
|
|
4655
4655
|
if (req.type === 'new-workflow') {
|
|
4656
4656
|
return `Help me build a brand-new floless workflow from scratch, step by step, with your floless-app-new-workflow skill. Ask me what I want it to do, then before writing any node confirm the agents it needs are installed (offer to install a missing one from the catalogue, or to report a not-yet-existing agent as an idea). Author the .flo node by node, verifying each step is correct (plain-English descriptions, validate, compile, a real run), and finish by installing → Compile → ▶ Run so I can see it work and approve.`;
|
|
4657
4657
|
}
|
|
4658
|
+
if (req.type === 'revision-read') {
|
|
4659
|
+
const snaps = req.snapshots && req.snapshots.length
|
|
4660
|
+
? `\nRevised drawing${req.snapshots.length > 1 ? 's' : ''} (read these): ${req.snapshots.join(', ')}`
|
|
4661
|
+
: '';
|
|
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. 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
|
+
}
|
|
4658
4665
|
return '';
|
|
4659
4666
|
}
|
|
4660
4667
|
|
|
@@ -4665,6 +4672,7 @@
|
|
|
4665
4672
|
tweak: 'floless-app-workflows',
|
|
4666
4673
|
'ui-customize': 'floless-app-ui',
|
|
4667
4674
|
rebake: 'floless-app-rebake',
|
|
4675
|
+
'revision-read': 'floless-app-steel-takeoff',
|
|
4668
4676
|
// The guided-tour ask is picked up by onboarding; the instruction body names the
|
|
4669
4677
|
// per-app floless-app-<appId> skill for depth when one exists.
|
|
4670
4678
|
guide: 'floless-app-onboarding',
|
|
@@ -4682,10 +4690,15 @@
|
|
|
4682
4690
|
if (!body) return '';
|
|
4683
4691
|
const skill = REQUEST_SKILL[req.type] || 'floless-app-workflows';
|
|
4684
4692
|
const base = (typeof location !== 'undefined' && location.origin) ? location.origin : 'http://127.0.0.1:4317';
|
|
4693
|
+
// A revision-read is cleared by its own /revision-read POST — a separate DELETE would 404. Every
|
|
4694
|
+
// other type is applied-then-DELETEd by the terminal AI.
|
|
4695
|
+
const clears = req.type === 'revision-read'
|
|
4696
|
+
? `apply it via the route in the line below (that POST clears this request — do NOT DELETE it separately).`
|
|
4697
|
+
: `apply that request, then DELETE ${base}/api/requests/${req.id}.`;
|
|
4685
4698
|
const marker =
|
|
4686
4699
|
`[floless-request type=${req.type} id=${req.id}] — queued from the FloLess Dashboard. ` +
|
|
4687
4700
|
`Apply it with your ${skill} skill: fetch the authoritative spec from GET ${base}/api/requests, ` +
|
|
4688
|
-
|
|
4701
|
+
`${clears} Don't run the line below verbatim.`;
|
|
4689
4702
|
return `${marker}\n${body}`;
|
|
4690
4703
|
}
|
|
4691
4704
|
|
|
@@ -4764,8 +4777,8 @@
|
|
|
4764
4777
|
return;
|
|
4765
4778
|
}
|
|
4766
4779
|
$list.innerHTML = pendingRequests.map((r) => {
|
|
4767
|
-
const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : r.type === 'guide' ? 'guide' : r.type === 'new-workflow' ? 'new workflow' : 'tweak';
|
|
4768
|
-
const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' ? 'req-type req-type-tweak' : 'req-type';
|
|
4780
|
+
const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : r.type === 'revision-read' ? 'revision' : r.type === 'guide' ? 'guide' : r.type === 'new-workflow' ? 'new workflow' : 'tweak';
|
|
4781
|
+
const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' || r.type === 'revision-read' ? 'req-type req-type-tweak' : 'req-type';
|
|
4769
4782
|
const target = r.type === 'tweak' && r.nodeId
|
|
4770
4783
|
? ` · node <code>${escapeHtml(r.nodeId)}</code>`
|
|
4771
4784
|
: r.type === 'rebake' && r.inputName
|
package/dist/web/index.html
CHANGED
|
@@ -170,6 +170,15 @@
|
|
|
170
170
|
<button type="button" data-step="exports" role="tab" aria-selected="false">Exports</button>
|
|
171
171
|
<button type="button" data-step="history" role="tab" aria-selected="false">History</button>
|
|
172
172
|
</div>
|
|
173
|
+
<!-- Drawings step: a shell toolbar band + revision-read status card ABOVE the filter iframe
|
|
174
|
+
(chrome around it, never inside — the filter owns its own layout). Shown only on Drawings. -->
|
|
175
|
+
<div class="ws-drawings-bar" id="ws-drawings-bar" hidden>
|
|
176
|
+
<span class="ws-drawings-bar-label">Drawing set</span>
|
|
177
|
+
<span class="ws-drawings-bar-sep"></span>
|
|
178
|
+
<button type="button" id="ws-attach-revision" class="btn-mini" data-tip="Attach a revised drawing set — your terminal AI reads it into a new version">⤒ Attach revised drawings…</button>
|
|
179
|
+
<input type="file" id="ws-revision-file" accept=".pdf,image/*" multiple hidden>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="ws-revision-card" id="ws-revision-card" hidden></div>
|
|
173
182
|
<!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
|
|
174
183
|
Same-origin like #contract-editor-frame (they call /api/contract directly). -->
|
|
175
184
|
<iframe id="ws-frame-model" class="ws-frame" title="Project model editor" hidden></iframe>
|
|
@@ -851,6 +851,7 @@ let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimen
|
|
|
851
851
|
let dimSplitMode=false; // "add split point" mode on a selected dim — each click inserts a point and splits the dim segment under it into two
|
|
852
852
|
let sel3dDimIds=new Set(),dim3dAnchor=null; // selected 3D dimension ids (multi-select like parts; 3D view highlights them, Delete removes them)
|
|
853
853
|
let selIds=new Set();
|
|
854
|
+
let findHits=new Set(); // Find-in-model highlight (Workspaces Ctrl+F) — kept SEPARATE from selIds so a search never clobbers/drops the user's real selection
|
|
854
855
|
let undo=[], redo=[];
|
|
855
856
|
const byId=id=>P.members.find(m=>m.id===id);
|
|
856
857
|
const selArr=()=>P.members.filter(m=>selIds.has(m.id));
|
|
@@ -1420,8 +1421,8 @@ function render(){
|
|
|
1420
1421
|
let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
|
|
1421
1422
|
s+=gridSvg(); // structural grid under the linework (members/dims stay on top)
|
|
1422
1423
|
for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
|
|
1423
|
-
for(const m of P.members){const c=colorFor(m.profile);const
|
|
1424
|
-
s+=`<line class="member${m.rfi?' rfi':''}${
|
|
1424
|
+
for(const m of P.members){const c=colorFor(m.profile);const sel=selIds.has(m.id);const fh=findHits.has(m.id);const on=sel||fh;const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
|
|
1425
|
+
s+=`<line class="member${m.rfi?' rfi':''}${sel?' sel':''}${fh?' find-hit':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
|
|
1425
1426
|
{const hsel=selArr();if(hsel.length>=1){const HR=epR();for(const sm of hsel)for(let i=0;i<2;i++) s+=`<circle class="handle ${i===0?'ep-start':'ep-end'}" data-mid="${sm.id}" data-h="${i}" cx="${sm.wp[i][0]}" cy="${sm.wp[i][1]}" r="${HR}"/>`;}} // end 1 (start) yellow, end 2 (end) magenta · shown for every selected member · radius grows with zoom (epR) so it stays visible against the thick line
|
|
1426
1427
|
if((mode==='add'||(picking&&pickKind==='profile'))&&P.labels) for(const lb of P.labels){const w=Math.max(40,lb.text.length*11);
|
|
1427
1428
|
s+=`<rect class=lblhot data-prof="${esc(lb.text)}" x="${lb.disp[0]-w/2}" y="${lb.disp[1]-10}" width="${w}" height="20" rx="3"><title>${esc(lb.text)}</title></rect>`;}
|
|
@@ -3036,16 +3037,36 @@ const view3dApi={
|
|
|
3036
3037
|
onClipModeChange:(m)=>{const b=document.getElementById('m3dClip');if(b){b.classList.toggle('on',!!m);b.textContent=m?'Clip ✕':'Clip ▾';}}, // armed → button fills brand-blue + becomes a cancel target (✕ = cancel)
|
|
3037
3038
|
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';}}, // armed → cancel target
|
|
3038
3039
|
onInsertPlace:(pick,pending)=>{
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
const
|
|
3045
|
-
if(
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3040
|
+
if(pending&&pending.kind==='connection'&&pending.connection){
|
|
3041
|
+
const conn=pending.connection;const rc=conn.recipe;
|
|
3042
|
+
// Slice C: a RECOGNIZED base plate dropped onto a COLUMN → bake an EDITABLE base-plate recipe joint;
|
|
3043
|
+
// expandBasePlate re-derives it on that column from the fitted params (frame-independent scalars), so
|
|
3044
|
+
// it becomes a first-class parametric connection, not opaque mesh. Needs a column at the pick.
|
|
3045
|
+
const col=(rc&&rc.kind==='base-plate'&&pick.anchorId)?P.members.find(m=>m&&m.id===pick.anchorId&&m.role==='column'):null;
|
|
3046
|
+
if(col){
|
|
3047
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3048
|
+
const joint={id,kind:'base-plate',main:col.id,at:'base',params:Object.assign({},rc.params),source:'user'};
|
|
3049
|
+
pendingConnSel=id; // its parts only exist after the 3D rebuild → select the whole connection there
|
|
3050
|
+
// Applying a base plate to a column REPLACES any base plate already on it (a column has one base
|
|
3051
|
+
// plate) — else the two overlap and "edit on member" would target the older joint, not this import.
|
|
3052
|
+
const had=(C.joints||[]).some(x=>x&&x.kind==='base-plate'&&x.main===col.id);
|
|
3053
|
+
edit(()=>{C.joints=(Array.isArray(C.joints)?C.joints:[]).filter(x=>!(x&&x.kind==='base-plate'&&x.main===col.id));C.joints.push(joint);selIds=new Set();});
|
|
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
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
// Slice B: opaque custom mesh — bake at the picked point (joint.place); expandCustom re-expands it into
|
|
3058
|
+
// the scene as one selectable unit. Unrecognized imports, and a recognized base plate NOT dropped on a
|
|
3059
|
+
// column, land here (still faithful geometry) with a hint toward the editable path.
|
|
3060
|
+
if(Array.isArray(conn.geometry)&&conn.geometry.length){
|
|
3061
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3062
|
+
const joint={id,kind:'custom',name:conn.name||'Imported connection',place:pick.point,geometry:conn.geometry,source:'user'};
|
|
3063
|
+
if(pick.anchorId)joint.main=pick.anchorId; // snapped to a member face → record it for the inspector's "on member" line
|
|
3064
|
+
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')
|
|
3066
|
+
:('Connection “'+(conn.name||'imported')+'” placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — select it to move or replace'));
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
toast('That connection has no geometry to place');return;
|
|
3049
3070
|
}
|
|
3050
3071
|
if(!pending||!pending.name){toast('Pick a detail to insert first');return;} // Slice 4: place the queued detail at the pick, select it, record the create intent
|
|
3051
3072
|
const id='det'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);const sheet=(P&&P.sheet)||'';
|
|
@@ -3058,7 +3079,11 @@ const view3dApi={
|
|
|
3058
3079
|
};
|
|
3059
3080
|
// Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
|
|
3060
3081
|
// changes go through render()'s setSelection — only geometry mutations need a rebuild.
|
|
3061
|
-
|
|
3082
|
+
let pendingConnSel=null; // a just-baked connection whose parts exist only AFTER the next rebuild → select the whole thing there (a recognized base-plate import, whose part ids aren't known ahead of expansion)
|
|
3083
|
+
function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{
|
|
3084
|
+
if(pendingConnSel&&window.Steel3DView.selectWholeConn){const c=pendingConnSel;pendingConnSel=null;window.Steel3DView.selectWholeConn(c);} // whole-connection select (envelope + "Parametric — editable" inspector), same as a 3D click
|
|
3085
|
+
else window.Steel3DView.setSelection(selIds);
|
|
3086
|
+
build3DLegend();panel();}).catch(()=>{pendingConnSel=null;});}} // rebuild also refreshes the legend (an edit may add/remove a profile) + re-renders the inspector so a selection of just-created parts (e.g. a placed custom connection) resolves against the freshly-fetched partsById, not the pre-rebuild stale copy
|
|
3062
3087
|
// Insert-a-detail helpers (Slice 4). armInsert queues a detail image + arms the 3D placement pick;
|
|
3063
3088
|
// detailRequest records a create/modify request on the SAME tweak-contract channel, adding intent+target
|
|
3064
3089
|
// so the terminal AI knows whether to build a new detail or update a placed one, and where.
|
|
@@ -3066,12 +3091,17 @@ function armInsert(name){if(!name)return;const raw=(C.custom_details||{})[name];
|
|
|
3066
3091
|
if(!view3d){toast('Switch to the 3D view to place a detail');return;}
|
|
3067
3092
|
window.Steel3DView.setInsertMode(true,{name});
|
|
3068
3093
|
toast('Click a beam or the model to place “'+name+'” — Esc to cancel');}
|
|
3069
|
-
// Slice B: arm the crosshair to place an IMPORTED connection (its LOCAL mesh geometry
|
|
3070
|
-
// pending object; onInsertPlace bakes
|
|
3094
|
+
// Slice B/C: arm the crosshair to place an IMPORTED connection (its LOCAL mesh geometry + optional
|
|
3095
|
+
// recognized recipe ride on the pending object; onInsertPlace bakes a base-plate recipe on a column, or
|
|
3096
|
+
// the custom mesh where the user clicks). 3D view only.
|
|
3071
3097
|
function armConnectionInsert(connection){if(!connection||!Array.isArray(connection.geometry)||!connection.geometry.length){toast('That connection has no geometry to place');return;}
|
|
3072
3098
|
if(!view3d){toast('Switch to the 3D view to place a connection');return;}
|
|
3073
3099
|
window.Steel3DView.setInsertMode(true,{kind:'connection',connection});
|
|
3074
|
-
|
|
3100
|
+
const nm=connection.name||'connection';
|
|
3101
|
+
// Recognized base plate → tell the user to target a column (the editable path); else the generic place hint.
|
|
3102
|
+
const recognized=connection.recipe&&connection.recipe.kind==='base-plate';
|
|
3103
|
+
toast(recognized?('Click a column to place “'+nm+'” as an editable base plate — Esc to cancel')
|
|
3104
|
+
:('Click in the model to place “'+nm+'” — Esc to cancel'));}
|
|
3075
3105
|
async function detailRequest(intent,place,note){
|
|
3076
3106
|
// flushContract PUTs C to the server so the terminal AI reads the latest contract — but it clears the
|
|
3077
3107
|
// debounced autosave (saveT) WITHOUT writing localStorage, which would drop the just-placed detail from
|
|
@@ -3767,13 +3797,40 @@ function rfiReason(m){if(!m.profile)return 'No profile assigned';
|
|
|
3767
3797
|
function zoomToMember(m){const a=m.wp[0],b=m.wp[1],w=Math.abs(a[0]-b[0])||40,h=Math.abs(a[1]-b[1])||40;
|
|
3768
3798
|
applyZoom(Math.max(.3,Math.min(2.5,Math.min(stage.clientWidth/(w*2.2),stage.clientHeight/(h*2.2)))));
|
|
3769
3799
|
const cx=(a[0]+b[0])/2,cy=(a[1]+b[1])/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
|
|
3770
|
-
//
|
|
3771
|
-
|
|
3800
|
+
// ── Find in model (Workspaces Ctrl+F) ──────────────────────────────────────────
|
|
3801
|
+
// Match a member's id/mark + profile (case-insensitive substring) across ALL plans, switch to the
|
|
3802
|
+
// first plan with a hit, HIGHLIGHT those hits (via findHits — a set kept SEPARATE from selIds so a
|
|
3803
|
+
// same-plan search never touches the user's real selection; a cross-plan hit switches sheets via
|
|
3804
|
+
// setPlan, which resets selection like any plan change), and frame them. Returns {count,shown}
|
|
3805
|
+
// for the shell's find overlay. Called cross-iframe by workspaces.js via the same-origin
|
|
3806
|
+
// contentWindow, like flushContract(). clearFind() drops the find highlight only (leaves selection).
|
|
3807
|
+
function _findMatch(m, q){ return [m.id, m.mark, m.profile].filter(Boolean).join(' ').toLowerCase().includes(q); }
|
|
3808
|
+
function findMember(query){
|
|
3809
|
+
const q = String(query == null ? '' : query).toLowerCase().trim();
|
|
3810
|
+
if(!q){ clearFind(); return { count: 0, shown: 0 }; }
|
|
3811
|
+
let total = 0, firstPlan = -1, firstHits = [];
|
|
3812
|
+
(C.plans || []).forEach((pl, pi) => {
|
|
3813
|
+
const ids = (pl.members || []).filter((m) => _findMatch(m, q)).map((m) => m.id);
|
|
3814
|
+
total += ids.length;
|
|
3815
|
+
if(ids.length && firstPlan < 0){ firstPlan = pi; firstHits = ids; }
|
|
3816
|
+
});
|
|
3817
|
+
if(firstPlan < 0){ findHits = new Set(); render(); return { count: 0, shown: 0 }; }
|
|
3818
|
+
if(firstPlan !== C.active) setPlan(firstPlan);
|
|
3819
|
+
findHits = new Set(firstHits);
|
|
3820
|
+
render(); zoomToMembers(P.members.filter((m) => findHits.has(m.id)));
|
|
3821
|
+
return { count: total, shown: firstHits.length };
|
|
3822
|
+
}
|
|
3823
|
+
function clearFind(){ if(findHits.size){ findHits = new Set(); render(); } }
|
|
3824
|
+
window.findMember = findMember; window.clearFind = clearFind; // expose to the shell, like window.flushContract (top-level fns aren't reliably on window here)
|
|
3825
|
+
// Frame an arbitrary set of members (bbox → fit); falls back to fit-all when empty. Shared by
|
|
3826
|
+
// zoomToSelection (Tekla "Zoom selected") and Find-in-model — mirrors the 3D view's frameSelection().
|
|
3827
|
+
function zoomToMembers(arr){if(!arr.length){fitToWindow();return;}
|
|
3772
3828
|
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
|
|
3773
3829
|
for(const m of arr)for(const p of m.wp){if(p[0]<x0)x0=p[0];if(p[0]>x1)x1=p[0];if(p[1]<y0)y0=p[1];if(p[1]>y1)y1=p[1];}
|
|
3774
3830
|
const w=Math.max(x1-x0,40),h=Math.max(y1-y0,40);
|
|
3775
3831
|
applyZoom(Math.min(stage.clientWidth/(w*1.35),stage.clientHeight/(h*1.35)));
|
|
3776
3832
|
const cx=(x0+x1)/2,cy=(y0+y1)/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
|
|
3833
|
+
function zoomToSelection(){zoomToMembers(selArr());}
|
|
3777
3834
|
function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
|
|
3778
3835
|
g.innerHTML=list.length?('<div class=hint style="margin-bottom:10px">'+list.length+' member'+(list.length>1?'s':'')+' have no resolved AISC size, so their weight is excluded from the BOM. Assign a profile to clear it — for <b>MF/BF</b> marks use the <b>Frames</b> schedule. Edits here update the contract.</div><table class=ftab><thead><tr><th>#</th><th>Profile / mark</th><th>Role</th><th>Length</th><th>Why it’s flagged</th><th></th></tr></thead><tbody>'+
|
|
3779
3836
|
list.map((m,i)=>{ensureMeta(m);const L=_lenFt(m).toFixed(1);
|
|
@@ -4037,7 +4094,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
|
|
|
4037
4094
|
scheduleSave();setTimeout(()=>toast('Auto-removed '+red.size+' duplicate member'+(red.size>1?'s':'')),60);}}
|
|
4038
4095
|
profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
|
|
4039
4096
|
undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
|
|
4040
|
-
selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
|
|
4097
|
+
selIds=new Set();findHits=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null; // findHits resets per plan like selIds — a stale Find highlight must not bleed onto another sheet via a colliding member id
|
|
4041
4098
|
dimMode=false;dimChain=false;dimSplitMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain + split); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
|
|
4042
4099
|
if(gridMode||gridPick){gridMode=false;gridPick=false;document.body.classList.remove('gridpick');} // grid panel/pick-origin disarm per plan like the other takeover tools (a leaked pick would set the origin in the WRONG sheet's display space)
|
|
4043
4100
|
csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
|