@floless/app 0.73.1 → 0.75.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 +686 -254
- package/dist/schemas/steel.takeoff.v1.schema.json +26 -7
- package/dist/web/app.css +62 -1
- package/dist/web/index.html +5 -0
- package/dist/web/steel-3d-view.js +17 -1
- package/dist/web/steel-editor.html +185 -9
- package/dist/web/workspaces.js +139 -0
- package/package.json +1 -1
|
@@ -236,19 +236,38 @@
|
|
|
236
236
|
},
|
|
237
237
|
"joint": {
|
|
238
238
|
"type": "object",
|
|
239
|
-
"required": ["id", "kind"
|
|
239
|
+
"required": ["id", "kind"],
|
|
240
240
|
"additionalProperties": true,
|
|
241
|
-
"description": "A placed connection
|
|
241
|
+
"description": "A placed connection the connection engine expands into real 3D parts. A parametric RECIPE kind (base-plate, shear-plate) derives all geometry from its `params` + the live member positions, so it moves/re-solves with its members. A 'custom' kind is an imported connection (read from IFC via AWARE's connection-reader) that carries its own opaque tessellated `geometry` and a `place` point — move / replace only, no per-dimension edit. Distinct from the `connections[]` library (vendor-neutral type/detail/targets identities a joint can cite). v1 kinds: base-plate, shear-plate, custom.",
|
|
242
242
|
"properties": {
|
|
243
|
-
"id": { "type": "string", "description": "Stable joint id, e.g. j-bp-1 / sp-b3-e0
|
|
244
|
-
"kind": { "type": "string", "description": "Connection kind
|
|
245
|
-
"main": { "type": "string", "description": "Id of the host member this joint hangs off. For a base-plate this is the column; for a shear-plate this is the SUPPORTED BEAM (the joint sits at one of the beam's ends — see `at`)." },
|
|
243
|
+
"id": { "type": "string", "description": "Stable joint id, e.g. j-bp-1 / sp-b3-e0 / cx-<id>." },
|
|
244
|
+
"kind": { "type": "string", "description": "Connection kind: base-plate, shear-plate (parametric recipes the engine expands), or custom (imported IFC geometry). end-plate / cap-plate / … follow." },
|
|
245
|
+
"main": { "type": "string", "description": "Id of the host member this joint hangs off. For a base-plate this is the column; for a shear-plate this is the SUPPORTED BEAM (the joint sits at one of the beam's ends — see `at`). Required for the recipe kinds; optional for 'custom' (an imported connection carries its own geometry rather than deriving it from a member)." },
|
|
246
246
|
"secondaries": { "type": "array", "items": { "type": "string" }, "description": "Ids of the other members this joint binds — e.g. the SUPPORT a shear-plate frames into. Optional and inferred by geometry; the shear-plate engine does not require it (the fin plate is placed off the beam end + beam axis). Empty for a base-plate." },
|
|
247
247
|
"at": { "type": "string", "description": "Where on the main member the joint sits (kind-dependent): base-plate = base; shear-plate = end0 | end1 — the beam end framing into the support (end0 ↔ ends[0]/start, end1 ↔ ends[1]/end)." },
|
|
248
248
|
"conn": { "type": "string", "description": "Optional `connections[]` row id this joint cites for its type/detail identity (keeps the connection library + confidence scoring in play)." },
|
|
249
|
-
"params": { "type": "object", "additionalProperties": true, "description": "Geometry settings (mm) with engine defaults; absent = all defaults. base-plate: plateWidth/plateDepth/thickness, boltCols/boltRows/boltDia/edgeDist, weldLeg, anchor kit, levelingNut. shear-plate: plateThickness, plateWidth (along the beam), plateHeight (vertical), boltCols/boltRows/boltDia/boltPitch/edgeDist, weldLeg, clearance (the erection gap so the beam clears the support, ~½–¾\"; pulls the beam box back), webSide (+1/-1 — which face of the beam web the plate laps), boltGrade (A325 | A490 — the AISC bolt length auto-sizes from the grip), stiffener (bool — a full-height stiffener on the far side of a girder web)." },
|
|
249
|
+
"params": { "type": "object", "additionalProperties": true, "description": "Geometry settings (mm) with engine defaults; absent = all defaults. base-plate: plateWidth/plateDepth/thickness, boltCols/boltRows/boltDia/edgeDist, weldLeg, anchor kit, levelingNut. shear-plate: plateThickness, plateWidth (along the beam), plateHeight (vertical), boltCols/boltRows/boltDia/boltPitch/edgeDist, weldLeg, clearance (the erection gap so the beam clears the support, ~½–¾\"; pulls the beam box back), webSide (+1/-1 — which face of the beam web the plate laps), boltGrade (A325 | A490 — the AISC bolt length auto-sizes from the grip), stiffener (bool — a full-height stiffener on the far side of a girder web). Not used by 'custom'." },
|
|
250
250
|
"source": { "enum": ["auto", "user"], "description": "auto = proposed by auto-detail from the takeoff; user = placed/edited by hand." },
|
|
251
|
-
"verified": { "type": "boolean", "description": "True = a human confirmed this joint." }
|
|
251
|
+
"verified": { "type": "boolean", "description": "True = a human confirmed this joint." },
|
|
252
|
+
"name": { "type": "string", "description": "custom only: display label carried from the source IFC (e.g. \"COLUMN C102\")." },
|
|
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)." }
|
|
255
|
+
},
|
|
256
|
+
"allOf": [
|
|
257
|
+
{ "if": { "properties": { "kind": { "enum": ["base-plate", "shear-plate"] } } }, "then": { "required": ["main"] } },
|
|
258
|
+
{ "if": { "properties": { "kind": { "const": "custom" } } }, "then": { "required": ["geometry"] } }
|
|
259
|
+
]
|
|
260
|
+
},
|
|
261
|
+
"meshPart": {
|
|
262
|
+
"type": "object",
|
|
263
|
+
"required": ["positions", "indices"],
|
|
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. Read from IFC by the connection-reader; never hand-authored.",
|
|
266
|
+
"properties": {
|
|
267
|
+
"id": { "type": "string", "description": "Source part id (the IFC element GlobalId)." },
|
|
268
|
+
"role": { "type": "string", "description": "Hardware role from the source IFC: plate | bolt | weld." },
|
|
269
|
+
"positions": { "type": "array", "items": { "type": "number" }, "description": "Flat x,y,z vertex coordinates (mm), local to the connection; length is a multiple of 3." },
|
|
270
|
+
"indices": { "type": "array", "items": { "type": "number" }, "description": "0-based triangle vertex indices (triples) into positions." }
|
|
252
271
|
}
|
|
253
272
|
},
|
|
254
273
|
"point2": {
|
package/dist/web/app.css
CHANGED
|
@@ -3312,7 +3312,7 @@ body {
|
|
|
3312
3312
|
/* ── Workspaces (mode shell) — see docs/superpowers/mockups/2026-07-03-workspaces-mockup.html ── */
|
|
3313
3313
|
/* The display rules below (display:flex etc.) would otherwise override the `hidden` attribute's
|
|
3314
3314
|
UA display:none — these guards keep hidden winning so nothing leaks across modes. */
|
|
3315
|
-
.ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden], .ws-exports[hidden] { display:none !important; }
|
|
3315
|
+
.ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden], .ws-exports[hidden], .ws-history[hidden] { display:none !important; }
|
|
3316
3316
|
.mode-switch { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px;
|
|
3317
3317
|
overflow:hidden; background:var(--surface-2); flex:none; }
|
|
3318
3318
|
.mode-switch button { background:transparent; border:none; border-radius:0; color:var(--text-muted);
|
|
@@ -3428,3 +3428,64 @@ body {
|
|
|
3428
3428
|
background:var(--surface-2); border:1px solid var(--border-strong); color:var(--text-muted); cursor:pointer; transition:all .15s; }
|
|
3429
3429
|
.ecard .e-act button:hover:not(:disabled) { color:var(--text); border-color:var(--accent-dim); background:var(--surface); }
|
|
3430
3430
|
.ecard .e-act button:disabled { opacity:0.6; cursor:not-allowed; }
|
|
3431
|
+
|
|
3432
|
+
/* ── Workspaces ▸ History step (shell-rendered pane; the project's version ledger). Ports the
|
|
3433
|
+
mockup's table.hist into app tokens. `position:relative` anchors the rollback confirm popover. */
|
|
3434
|
+
.ws-history { flex:1; min-height:0; position:relative; overflow-y:auto; padding:16px 20px;
|
|
3435
|
+
scrollbar-width:thin; scrollbar-color:var(--border-strong) transparent; }
|
|
3436
|
+
.ws-history::-webkit-scrollbar { width:10px; }
|
|
3437
|
+
.ws-history::-webkit-scrollbar-thumb { background:var(--border-strong); border-radius:5px; }
|
|
3438
|
+
.ws-history::-webkit-scrollbar-track { background:transparent; }
|
|
3439
|
+
.ws-history .hist-note { font-size:12px; color:var(--text-muted); margin:0 0 14px; }
|
|
3440
|
+
.ws-history .hist-note b { color:var(--text); font-weight:600; }
|
|
3441
|
+
/* Working-copy banner — sibling of .ws-exports-gate; NEUTRAL (accent-dim rail, never a danger
|
|
3442
|
+
color): "unapproved" is an absence-of-signature, not an error. Names the Exports-lock consequence. */
|
|
3443
|
+
.hist-working { margin:0 0 14px; padding:9px 13px; display:flex; align-items:center; gap:6px; flex-wrap:wrap;
|
|
3444
|
+
background:var(--surface-2); border:1px solid var(--border); border-left:3px solid var(--accent-dim);
|
|
3445
|
+
border-radius:6px; font-size:12px; color:var(--text-muted); }
|
|
3446
|
+
.hist-working b { color:var(--text); font-weight:600; }
|
|
3447
|
+
.hist-working button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
|
|
3448
|
+
.hist-working button:hover { color:var(--accent-bright); text-decoration:underline; }
|
|
3449
|
+
/* Empty state — an inline-action banner (never a bare header row, which reads as broken/loading). */
|
|
3450
|
+
.hist-empty { margin:6px 0 0; padding:11px 14px; background:var(--surface-2); border:1px solid var(--border);
|
|
3451
|
+
border-left:3px solid var(--accent-dim); border-radius:6px; font-size:12.5px; color:var(--text-muted); }
|
|
3452
|
+
.hist-empty b { color:var(--text); }
|
|
3453
|
+
.hist-empty button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
|
|
3454
|
+
.hist-empty button:hover { color:var(--accent-bright); text-decoration:underline; }
|
|
3455
|
+
|
|
3456
|
+
table.hist { width:100%; border-collapse:collapse; font-size:12.5px; }
|
|
3457
|
+
table.hist th { text-align:left; font-size:10px; text-transform:uppercase; letter-spacing:.1em; color:var(--text-dim);
|
|
3458
|
+
font-weight:600; padding:0 12px 8px; border-bottom:1px solid var(--border); white-space:nowrap; }
|
|
3459
|
+
table.hist td { padding:11px 12px; border-bottom:1px solid var(--border); color:var(--text); vertical-align:middle; }
|
|
3460
|
+
table.hist td.change { line-height:1.45; }
|
|
3461
|
+
table.hist tr:hover td { background:var(--surface-2); }
|
|
3462
|
+
table.hist tr.current td { background:var(--accent-soft); }
|
|
3463
|
+
.hist .ver { font-family:var(--mono); font-size:11.5px; color:var(--text-dim); }
|
|
3464
|
+
.hist .ver.cur { color:var(--accent-bright); }
|
|
3465
|
+
.hist .htime { color:var(--text-muted); white-space:nowrap; }
|
|
3466
|
+
.hist .hby { display:inline-flex; align-items:center; gap:7px; white-space:nowrap; }
|
|
3467
|
+
.hist .avatar { width:20px; height:20px; border-radius:50%; flex:none; display:inline-flex; align-items:center;
|
|
3468
|
+
justify-content:center; font-size:8.5px; font-weight:700; letter-spacing:.02em; }
|
|
3469
|
+
.hist .avatar.you { background:var(--surface-3); border:1px solid var(--border-strong); color:var(--text); }
|
|
3470
|
+
.hist .avatar.ai { background:var(--accent-dim); color:var(--text); }
|
|
3471
|
+
/* Gate badge — the signed sign-off, its own column so a long change message never buries it.
|
|
3472
|
+
"Model" (accent, signed) vs "unsigned" (dim/bordered — an absence of signature, not a warning). */
|
|
3473
|
+
.hist .gate-badge { display:inline-block; padding:2px 7px; border-radius:4px; font-size:9.5px; font-weight:600;
|
|
3474
|
+
text-transform:uppercase; letter-spacing:.05em; background:transparent; border:1px solid var(--border-strong); color:var(--text-dim); }
|
|
3475
|
+
.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;
|
|
3477
|
+
padding:4px 10px; font-size:11px; cursor:pointer; white-space:nowrap; }
|
|
3478
|
+
.hist .btn-mini:hover { color:var(--text); border-color:var(--accent); }
|
|
3479
|
+
|
|
3480
|
+
/* Rollback confirm — a styled anchored popover (NEVER a native dialog); names the Exports re-lock. */
|
|
3481
|
+
.hist-confirm { position:absolute; z-index:20; width:264px; padding:12px 13px; background:var(--surface-3);
|
|
3482
|
+
border:1px solid var(--border-strong); border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); }
|
|
3483
|
+
.hist-confirm .hc-title { font-size:12.5px; font-weight:600; color:var(--text); margin-bottom:5px; }
|
|
3484
|
+
.hist-confirm .hc-body { font-size:11.5px; color:var(--text-muted); line-height:1.45; margin-bottom:11px; }
|
|
3485
|
+
.hist-confirm .hc-act { display:flex; gap:8px; justify-content:flex-end; }
|
|
3486
|
+
.hist-confirm .hc-go { background:var(--accent); border:1px solid var(--accent); color:white; font-weight:600;
|
|
3487
|
+
border-radius:6px; padding:5px 11px; font-size:11.5px; cursor:pointer; }
|
|
3488
|
+
.hist-confirm .hc-go:hover { background:var(--accent-bright); border-color:var(--accent-bright); box-shadow:0 0 14px var(--accent-glow); }
|
|
3489
|
+
.hist-confirm .hc-cancel { background:none; border:1px solid var(--border-strong); color:var(--text-muted);
|
|
3490
|
+
border-radius:6px; padding:5px 11px; font-size:11.5px; cursor:pointer; }
|
|
3491
|
+
.hist-confirm .hc-cancel:hover { color:var(--text); border-color:var(--accent); }
|
package/dist/web/index.html
CHANGED
|
@@ -168,6 +168,7 @@
|
|
|
168
168
|
<button type="button" data-step="drawings" role="tab" aria-selected="false">Drawings</button>
|
|
169
169
|
<button type="button" data-step="model" class="active" role="tab" aria-selected="true">Model</button>
|
|
170
170
|
<button type="button" data-step="exports" role="tab" aria-selected="false">Exports</button>
|
|
171
|
+
<button type="button" data-step="history" role="tab" aria-selected="false">History</button>
|
|
171
172
|
</div>
|
|
172
173
|
<!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
|
|
173
174
|
Same-origin like #contract-editor-frame (they call /api/contract directly). -->
|
|
@@ -176,6 +177,10 @@
|
|
|
176
177
|
<!-- Exports = a SHELL-rendered pane (not an iframe): export cards over the project's own
|
|
177
178
|
contract. workspaces.js fills it on step-switch (renderExports). -->
|
|
178
179
|
<div class="ws-exports" id="ws-exports" hidden></div>
|
|
180
|
+
<!-- History = a SHELL-rendered pane (not an iframe): the project's version ledger.
|
|
181
|
+
workspaces.js fills it on step-switch (renderHistory). Rollback is per-row (a header
|
|
182
|
+
↺ Rollback verb was cut in design review — no selection context; per-row is unambiguous). -->
|
|
183
|
+
<div class="ws-history" id="ws-history" hidden></div>
|
|
179
184
|
</div>
|
|
180
185
|
<div class="hint" id="canvas-hint">Click any node to inspect. Star ★ a node to save it as a reusable Template. Drag the background to pan — or press Home to fit.</div>
|
|
181
186
|
<div class="fav-bar" id="fav-bar">
|
|
@@ -422,6 +422,18 @@ function placeWasher(mesh, el) {
|
|
|
422
422
|
mesh.position.copy(A); // extrudes from `from` toward `to`
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
+
// An imported connection part (slice B): AWARE's generic kind:"mesh" primitive — flat world-mm
|
|
426
|
+
// positions + 0-based triangle indices straight from the connection-reader. Absolute coordinates, so
|
|
427
|
+
// the mesh sits at the root origin like a member box (no local transform). Winding order isn't trusted
|
|
428
|
+
// (its material is double-sided) — computeVertexNormals so it still shades.
|
|
429
|
+
function placeMesh(mesh, el) {
|
|
430
|
+
const g = new THREE.BufferGeometry();
|
|
431
|
+
g.setAttribute('position', new THREE.Float32BufferAttribute(el.positions || [], 3));
|
|
432
|
+
if (Array.isArray(el.indices) && el.indices.length) g.setIndex(el.indices);
|
|
433
|
+
g.computeVertexNormals();
|
|
434
|
+
mesh.geometry = g;
|
|
435
|
+
}
|
|
436
|
+
|
|
425
437
|
// Build a mesh for one scene element by kind: a member box (default) or a connection part. Returns
|
|
426
438
|
// false for an unknown kind so the caller skips it (forward-compatible with later part kinds).
|
|
427
439
|
function placeElement(mesh, el) {
|
|
@@ -434,6 +446,7 @@ function placeElement(mesh, el) {
|
|
|
434
446
|
case 'nut': placeNut(mesh, el); return true;
|
|
435
447
|
case 'washer': placeWasher(mesh, el); return true;
|
|
436
448
|
case 'weld': placeWeld(mesh, el); return true;
|
|
449
|
+
case 'mesh': placeMesh(mesh, el); return true;
|
|
437
450
|
default: return false;
|
|
438
451
|
}
|
|
439
452
|
}
|
|
@@ -444,7 +457,10 @@ function materialFor(groupKey) {
|
|
|
444
457
|
// Fasteners (anchor rods / bolts / nuts / washers) read more metallic (zinc-plated); plates/welds/members stay matte.
|
|
445
458
|
const metallic = groupKey === 'anchor' || groupKey === 'bolt' || groupKey === 'nut' || groupKey === 'washer';
|
|
446
459
|
const metal = metallic ? 0.5 : 0.1, rough = metallic ? 0.4 : 0.75;
|
|
447
|
-
|
|
460
|
+
// Imported (custom) connection meshes are tessellated with untrusted winding order → render both
|
|
461
|
+
// faces so no triangle drops out; matte steel like a plate.
|
|
462
|
+
const dbl = groupKey === 'custom';
|
|
463
|
+
baseMat.set(groupKey, new THREE.MeshStandardMaterial({ color: col, metalness: metal, roughness: rough, side: dbl ? THREE.DoubleSide : THREE.FrontSide }));
|
|
448
464
|
}
|
|
449
465
|
return baseMat.get(groupKey);
|
|
450
466
|
}
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
g.cohot:hover circle.cobub{opacity:1;stroke-width:2;filter:drop-shadow(0 0 4px currentColor)}
|
|
95
95
|
text.cotx{fill:#e2e8f0;font:bold 11px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none;opacity:.6}
|
|
96
96
|
g.cohot:hover text.cotx{opacity:1}
|
|
97
|
-
#detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal,#connModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
|
|
97
|
+
#detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal,#connModal,#connImportModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
|
|
98
98
|
#askAiDrop:hover{border-color:var(--brand);color:var(--text)} #askAiDrop.has{border-style:solid;border-color:var(--brand)}
|
|
99
99
|
.aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
|
|
100
100
|
.aithumb button{position:absolute;top:-5px;right:-5px;width:16px;height:16px;padding:0;border-radius:8px;font-size:10px;line-height:16px;text-align:center;background:#7f1d1d;border-color:#991b1b;color:#fecaca}
|
|
@@ -363,6 +363,30 @@
|
|
|
363
363
|
#propPop .pprow.node{font-weight:500}
|
|
364
364
|
#propPop .pprow .cnt{color:var(--mut);font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;flex:none}
|
|
365
365
|
#propPop .pprow.selrow{border-left:2px solid var(--brand);background:rgba(59,130,246,.16)}
|
|
366
|
+
/* Import-a-connection-from-IFC modal (slice B): dropzone → parse progress → candidate pick-list →
|
|
367
|
+
extract progress → (closes, arms the crosshair place). Built from the same tokens as .mpanel /
|
|
368
|
+
#propPop / #dnDrop — no new vocabulary. Themed inner scroll comes from the global * scrollbar rule. */
|
|
369
|
+
#connImportModal .mpanel{width:min(560px,92vw)}
|
|
370
|
+
#connImportModal.pick .mpanel{width:min(720px,92vw)}
|
|
371
|
+
#ciBody{padding:16px;display:flex;flex-direction:column;gap:10px;overflow:auto;max-height:76vh}
|
|
372
|
+
#ciDrop{border:1px dashed #475569;border-radius:8px;background:#0b1220;min-height:104px;display:flex;align-items:center;justify-content:center;text-align:center;color:var(--mut);cursor:pointer;padding:16px;font-size:12px}
|
|
373
|
+
#ciDrop:hover{border-color:var(--brand);color:var(--text)}
|
|
374
|
+
#ciDrop.drag{border-style:solid;border-color:var(--brand);color:var(--text);background:#0d1526}
|
|
375
|
+
.ci-prog{display:flex;align-items:center;gap:10px;padding:22px 4px;color:var(--text);font-size:13px}
|
|
376
|
+
.ci-spin{width:18px;height:18px;border-radius:50%;flex:none;border:2px solid var(--line);border-top-color:var(--brand);animation:ci-spin .8s linear infinite}
|
|
377
|
+
@keyframes ci-spin{to{transform:rotate(360deg)}}
|
|
378
|
+
#ciSearch{width:100%;height:28px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:0 10px;font:12px system-ui;box-sizing:border-box}
|
|
379
|
+
#ciSearch:focus{outline:none;border-color:var(--brand)}
|
|
380
|
+
#ciCount{color:var(--mut);font-size:11px}
|
|
381
|
+
#ciList{overflow:auto;max-height:min(52vh,360px);border:1px solid var(--line);border-radius:8px}
|
|
382
|
+
.ci-row{display:flex;align-items:center;gap:10px;min-height:34px;padding:6px 12px;cursor:pointer;border:0;border-bottom:1px solid #0f1a2e;background:transparent;text-align:left;width:100%;box-sizing:border-box}
|
|
383
|
+
.ci-row:last-child{border-bottom:0}
|
|
384
|
+
.ci-row:hover{background:#1e293b}
|
|
385
|
+
.ci-row.sel{background:rgba(59,130,246,.16);border-left:2px solid var(--brand);padding-left:10px}
|
|
386
|
+
.ci-row .pn{flex:1;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px}
|
|
387
|
+
.ci-row .pv{color:var(--mut);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap;flex:none}
|
|
388
|
+
.ci-empty{color:var(--mut);font-size:12px;padding:16px;text-align:center}
|
|
389
|
+
#ciFoot{display:flex;justify-content:flex-end;gap:8px;margin-top:2px}
|
|
366
390
|
/* thin cluster divider in the 3D toolbar */
|
|
367
391
|
#m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
|
|
368
392
|
/* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
|
|
@@ -486,7 +510,7 @@
|
|
|
486
510
|
<button id=detailsBtn>Details</button>
|
|
487
511
|
<button id=connBtn data-tip="A lookup table of connection types (moment, shear, pinned, …). Each maps to a design detail and, per platform, a component ID; reference a row from each member end.">Connections</button>
|
|
488
512
|
<div class="m3dwrap ins-in-menu" id=insWrap>
|
|
489
|
-
<button id=m3dInsert data-tip="Insert a
|
|
513
|
+
<button id=m3dInsert data-tip="Insert a detail image or an imported steel connection into the 3D scene (3D view only)">Insert…</button>
|
|
490
514
|
<div id=m3dInsertMenu class=m3dmenu role=menu></div>
|
|
491
515
|
<input id=insFile type=file accept="image/*" style="display:none">
|
|
492
516
|
</div>
|
|
@@ -637,6 +661,30 @@
|
|
|
637
661
|
<div id=rfiModal><div class=mbackdrop id=rfiBackdrop></div>
|
|
638
662
|
<div class=mpanel><div class=mhead><b>Unresolved members — RFI</b><button id=rfiClose>✕</button></div>
|
|
639
663
|
<div id=rfiGrid style="padding:14px;overflow:auto"></div></div></div>
|
|
664
|
+
<div id=connImportModal><div class=mbackdrop id=ciBackdrop></div>
|
|
665
|
+
<div class=mpanel><div class=mhead><b id=ciTitle>Import a connection</b><button id=ciClose data-tip="Close">✕</button></div>
|
|
666
|
+
<div id=ciBody>
|
|
667
|
+
<!-- stage: dropzone -->
|
|
668
|
+
<div id=ciDropStage>
|
|
669
|
+
<div id=ciDrop data-tip="Click to browse, or drag an IFC file here"><span id=ciDropTxt>Drop an IFC file here, or click to browse</span></div>
|
|
670
|
+
<input id=ciFile type=file accept=".ifc,.ifczip" hidden>
|
|
671
|
+
<div class=hint style="margin-top:10px">Design a connection in IDEA StatiCa, Tekla or any tool, export IFC, and drop it here to place it in your model. The file stays on your machine.</div>
|
|
672
|
+
<div class=gerr id=ciDropErr style="display:none"></div>
|
|
673
|
+
</div>
|
|
674
|
+
<!-- stage: progress (parse / extract) -->
|
|
675
|
+
<div id=ciProgStage style="display:none">
|
|
676
|
+
<div class=ci-prog><span class=ci-spin aria-hidden=true></span><span id=ciProgTxt>Parsing IFC… reading connections</span></div>
|
|
677
|
+
<div style="display:flex;justify-content:flex-end;margin-top:8px"><button id=ciCancel class=ghost>Cancel</button></div>
|
|
678
|
+
</div>
|
|
679
|
+
<!-- stage: candidate pick-list -->
|
|
680
|
+
<div id=ciPickStage style="display:none">
|
|
681
|
+
<div style="margin-bottom:8px"><input id=ciSearch placeholder="Filter by name…" autocomplete=off></div>
|
|
682
|
+
<div id=ciCount style="margin-bottom:6px">—</div>
|
|
683
|
+
<div id=ciList></div>
|
|
684
|
+
<div id=ciFoot><button id=ciImport disabled data-tip="Place the selected connection in the model">Import</button></div>
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
</div></div>
|
|
640
688
|
<div id=confModal><div class=mbackdrop id=confBackdrop></div>
|
|
641
689
|
<div class=mpanel><div class=mhead><b>Confidence report</b><label class=conf-tgt data-tip="Confidence target for this read (%). Overrides the workflow default; saved with the contract.">Target <input id=confTarget type=number min=0 max=100 step=1> %</label><button id=confClose>✕</button></div>
|
|
642
690
|
<div id=confCats class=conf-cats></div>
|
|
@@ -1520,10 +1568,12 @@ async function connModifyRequest(j){
|
|
|
1520
1568
|
if(!j) return;
|
|
1521
1569
|
try{await window.flushContract();}catch(_){}
|
|
1522
1570
|
try{persist();}catch(_){}
|
|
1523
|
-
const kindName=j.kind==='base-plate'?'base plate':j.kind==='shear-plate'?'shear plate':'connection';
|
|
1524
|
-
const
|
|
1571
|
+
const kindName=j.kind==='base-plate'?'base plate':j.kind==='shear-plate'?'shear plate':j.kind==='custom'?'imported':'connection';
|
|
1572
|
+
const onMember=j.main?' on member '+j.main:'';
|
|
1573
|
+
const label=j.kind==='custom'&&j.name?' ("'+j.name+'")':'';
|
|
1574
|
+
const instruction='Modify the '+kindName+' connection "'+j.id+'"'+label+onMember+' (sheet '+((P&&P.sheet)||'?')+') — adjust, replace or move it per my request.';
|
|
1525
1575
|
try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
|
|
1526
|
-
body:JSON.stringify({appId:APP_ID,instruction,intent:'modify',target:{sheet:(P&&P.sheet)||undefined,ids:[j.id,j.main]}})});
|
|
1576
|
+
body:JSON.stringify({appId:APP_ID,instruction,intent:'modify',target:{sheet:(P&&P.sheet)||undefined,ids:[j.id,j.main].filter(Boolean)}})});
|
|
1527
1577
|
toast(res.ok?'Change queued for your terminal AI session':'Could not queue the request');
|
|
1528
1578
|
}catch(_){toast('Could not queue the request');}
|
|
1529
1579
|
}
|
|
@@ -1614,7 +1664,27 @@ function panel(){
|
|
|
1614
1664
|
// Modify (relay) / Edit-on-member. Precedes the single-part branch below (which handles the DRILLED case).
|
|
1615
1665
|
{const cs=connSelInfo();
|
|
1616
1666
|
if(cs&&cs.whole){
|
|
1617
|
-
const j=cs.joint
|
|
1667
|
+
const j=cs.joint;
|
|
1668
|
+
// An imported (custom) connection — opaque IFC geometry, move / replace only. NEUTRAL chip (a normal
|
|
1669
|
+
// state, not a fault); NO param block (replaced by a short note, never a disabled empty form).
|
|
1670
|
+
if(j.kind==='custom'){
|
|
1671
|
+
const nm=j.name?esc(j.name):'Imported connection';
|
|
1672
|
+
const onLine=j.main?`On <button class=pilllink id=cmpMember data-tip="Select ${esc(j.main)}">${esc(j.main)}</button> · `:'';
|
|
1673
|
+
p.innerHTML=`<span class=badge>Custom connection</span>
|
|
1674
|
+
<div class=row style="margin:0 0 6px"><span class=chip style="border-color:var(--line);color:var(--mut)">Imported geometry — move / replace only</span></div>
|
|
1675
|
+
<div class="row hint" style="margin:0 0 2px">${onLine}${cs.childIds.length} parts · ${nm}</div>
|
|
1676
|
+
<div class="row hint" style="margin:0 0 6px;font-size:11px">Read from IFC — opaque tessellated geometry (no per-dimension edit). <b>Esc</b> steps back.</div>
|
|
1677
|
+
<div class=divrow><hr></div>
|
|
1678
|
+
<div class="row f" style="gap:6px;flex-wrap:wrap">
|
|
1679
|
+
<button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to move / replace this connection">Modify connection…</button>
|
|
1680
|
+
<button class=danger id=cmpDel data-tip="Remove this whole imported connection">Delete connection</button>
|
|
1681
|
+
</div>`;
|
|
1682
|
+
{const b=document.getElementById('cmpMember');if(b)b.onclick=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};}
|
|
1683
|
+
{const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
|
|
1684
|
+
{const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
const isBP=j.kind==='base-plate',pp=j.params||{};
|
|
1618
1688
|
const plate=(partsById||{})[cs.conn+':plate']||null;
|
|
1619
1689
|
const dim=(n)=>(n==null?'<span style="color:var(--mut)">auto</span>':esc(fmtFtIn(Number(n)/25.4)));
|
|
1620
1690
|
const kv=(l,val)=>`<div style="display:flex;justify-content:space-between;gap:8px;font-size:12px;margin:3px 0"><span style="color:var(--mut)">${esc(l)}</span><span style="font-variant-numeric:tabular-nums">${val}</span></div>`;
|
|
@@ -2964,8 +3034,20 @@ const view3dApi={
|
|
|
2964
3034
|
onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
|
|
2965
3035
|
beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
|
|
2966
3036
|
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)
|
|
2967
|
-
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert
|
|
2968
|
-
onInsertPlace:(pick,pending)=>{
|
|
3037
|
+
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';}}, // armed → cancel target
|
|
3038
|
+
onInsertPlace:(pick,pending)=>{
|
|
3039
|
+
// Slice B: place an IMPORTED connection — bake a `custom` joint carrying its LOCAL mesh geometry at
|
|
3040
|
+
// the picked point (joint.place); expandCustom re-expands it into the scene as one selectable unit.
|
|
3041
|
+
if(pending&&pending.kind==='connection'&&pending.connection&&Array.isArray(pending.connection.geometry)){
|
|
3042
|
+
const conn=pending.connection;
|
|
3043
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3044
|
+
const joint={id,kind:'custom',name:conn.name||'Imported connection',place:pick.point,geometry:conn.geometry,source:'user'};
|
|
3045
|
+
if(pick.anchorId)joint.main=pick.anchorId; // snapped to a member face → record it for the inspector's "on member" line
|
|
3046
|
+
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)));});
|
|
3047
|
+
toast('Connection “'+(conn.name||'imported')+'” placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — select it to move or replace');
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
3050
|
+
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
|
|
2969
3051
|
const id='det'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);const sheet=(P&&P.sheet)||'';
|
|
2970
3052
|
const place={id,detailName:pending.name,sheet,anchorId:pick.anchorId||null,pos:pick.point,u:pick.u,n:pick.n,rotZ:0,size:1000,opacity:1};
|
|
2971
3053
|
edit(()=>{if(!Array.isArray(C.detail_placements))C.detail_placements=[];C.detail_placements.push(place);selIds=new Set(['det:'+id]);});
|
|
@@ -2976,7 +3058,7 @@ const view3dApi={
|
|
|
2976
3058
|
};
|
|
2977
3059
|
// Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
|
|
2978
3060
|
// changes go through render()'s setSelection — only geometry mutations need a rebuild.
|
|
2979
|
-
function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{window.Steel3DView.setSelection(selIds);build3DLegend();}).catch(()=>{});}} // rebuild also refreshes the legend (an edit may add/remove a profile)
|
|
3061
|
+
function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{window.Steel3DView.setSelection(selIds);build3DLegend();panel();}).catch(()=>{});}} // 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
|
|
2980
3062
|
// Insert-a-detail helpers (Slice 4). armInsert queues a detail image + arms the 3D placement pick;
|
|
2981
3063
|
// detailRequest records a create/modify request on the SAME tweak-contract channel, adding intent+target
|
|
2982
3064
|
// so the terminal AI knows whether to build a new detail or update a placed one, and where.
|
|
@@ -2984,6 +3066,12 @@ function armInsert(name){if(!name)return;const raw=(C.custom_details||{})[name];
|
|
|
2984
3066
|
if(!view3d){toast('Switch to the 3D view to place a detail');return;}
|
|
2985
3067
|
window.Steel3DView.setInsertMode(true,{name});
|
|
2986
3068
|
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 rides on the
|
|
3070
|
+
// pending object; onInsertPlace bakes the custom joint where the user clicks). 3D view only.
|
|
3071
|
+
function armConnectionInsert(connection){if(!connection||!Array.isArray(connection.geometry)||!connection.geometry.length){toast('That connection has no geometry to place');return;}
|
|
3072
|
+
if(!view3d){toast('Switch to the 3D view to place a connection');return;}
|
|
3073
|
+
window.Steel3DView.setInsertMode(true,{kind:'connection',connection});
|
|
3074
|
+
toast('Click in the model to place “'+(connection.name||'connection')+'” — Esc to cancel');}
|
|
2987
3075
|
async function detailRequest(intent,place,note){
|
|
2988
3076
|
// flushContract PUTs C to the server so the terminal AI reads the latest contract — but it clears the
|
|
2989
3077
|
// debounced autosave (saveT) WITHOUT writing localStorage, which would drop the just-placed detail from
|
|
@@ -3307,6 +3395,7 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
|
3307
3395
|
else{const note=document.createElement('div');note.style.cssText='padding:4px 10px;color:var(--mut);font-size:12px';note.textContent='No saved details yet — add one below.';frag.appendChild(note);}
|
|
3308
3396
|
frag.appendChild(document.createElement('hr'));
|
|
3309
3397
|
const add=document.createElement('button');add.textContent='+ Add an image…';add.onclick=()=>{insMenuClose();closeMore();document.getElementById('insFile').click();};frag.appendChild(add);
|
|
3398
|
+
const conn=document.createElement('button');conn.textContent='Connection…';conn.dataset.tip='Import a steel connection from an IFC file';conn.onclick=()=>{insMenuClose();closeMore();window.openConnImport();};frag.appendChild(conn);
|
|
3310
3399
|
insMenu.replaceChildren(frag);}
|
|
3311
3400
|
insBtn.onclick=e=>{e.stopPropagation();
|
|
3312
3401
|
if(d3.insertMode()){d3.setInsertMode(false);return;} // armed → cancel the pick
|
|
@@ -3316,6 +3405,93 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
|
3316
3405
|
readImageCompressed(f,b64=>{if(!b64){toast('Could not read that image — try another.');return;}
|
|
3317
3406
|
let base=(f.name||'Detail').replace(/\.[^.]+$/,'').trim()||'Detail',name=base,i=2;while(C.custom_details[name]!=null)name=base+' '+(i++);
|
|
3318
3407
|
edit(()=>{C.custom_details[name]=b64;});armInsert(name);});};
|
|
3408
|
+
// ── Import a connection from IFC (slice B): dropzone → parse → pick → extract → arm the crosshair
|
|
3409
|
+
// place. The reader runs in AWARE (web-ifc) via /api/import-connection; the UI renders + relays. Staged
|
|
3410
|
+
// states swap in one modal (never a disabled Place button — the whole body swaps). ──
|
|
3411
|
+
{ const ci$=id=>document.getElementById(id);
|
|
3412
|
+
let ciConns=[],ciSha=null,ciSel=null,ciAbort=null,ciSoftT=null;
|
|
3413
|
+
const ciClearSoft=()=>{if(ciSoftT){clearTimeout(ciSoftT);ciSoftT=null;}};
|
|
3414
|
+
const ciStage=s=>{ // 'drop' | 'prog' | 'pick'
|
|
3415
|
+
ci$('ciDropStage').style.display=s==='drop'?'':'none';
|
|
3416
|
+
ci$('ciProgStage').style.display=s==='prog'?'':'none';
|
|
3417
|
+
ci$('ciPickStage').style.display=s==='pick'?'':'none';
|
|
3418
|
+
ci$('ciClose').style.display=s==='prog'?'none':''; // no ✕ mid-run — Cancel owns the exit
|
|
3419
|
+
ci$('connImportModal').classList.toggle('pick',s==='pick'); // widen the panel for the list
|
|
3420
|
+
ci$('ciTitle').textContent=s==='prog'?'Reading the IFC file':s==='pick'?'Choose a connection':'Import a connection'; };
|
|
3421
|
+
const ciProg=(label,soft)=>{ciClearSoft();ci$('ciProgTxt').textContent=label;
|
|
3422
|
+
if(soft)ciSoftT=setTimeout(()=>{ci$('ciProgTxt').textContent=soft;},6000);}; // honest "still working" after 6s — no fake %
|
|
3423
|
+
const ciDropErr=msg=>{const e=ci$('ciDropErr');if(msg){e.textContent=msg;e.style.display='';}else{e.style.display='none';e.textContent='';}};
|
|
3424
|
+
const ciTotal=c=>(c.plates||0)+(c.bolts||0)+(c.welds||0)+(c.members||0);
|
|
3425
|
+
const ciSummary=c=>{const b=[],one=(n,s)=>{if(n)b.push(n+' '+s+(n===1?'':'s'));};one(c.plates,'plate');one(c.bolts,'bolt');one(c.welds,'weld');return b.join(' · ')||'no hardware read';};
|
|
3426
|
+
function openConnImport(){ if(!view3d){toast('Switch to the 3D view to import a connection');return;}
|
|
3427
|
+
ciConns=[];ciSha=null;ciSel=null;ciDropErr('');ci$('ciDrop').classList.remove('drag');
|
|
3428
|
+
ciStage('drop');ci$('connImportModal').style.display='flex'; }
|
|
3429
|
+
window.openConnImport=openConnImport; // the Insert ▾ menu item calls this
|
|
3430
|
+
function ciClose(){ciClearSoft();if(ciAbort){try{ciAbort.abort();}catch(_){}}ciAbort=null;ci$('connImportModal').style.display='none';}
|
|
3431
|
+
function ciReadFile(f){ if(!f)return;ciDropErr('');
|
|
3432
|
+
if(!/\.(ifc|ifczip)$/i.test(f.name||'')){ciDropErr('Not a valid IFC file — expected .ifc or .ifczip.');return;}
|
|
3433
|
+
const r=new FileReader();r.onload=()=>ciDoList(String(r.result||''));r.onerror=()=>ciDropErr('Could not read that file — try again.');r.readAsDataURL(f); }
|
|
3434
|
+
ci$('ciDrop').onclick=()=>ci$('ciFile').click();
|
|
3435
|
+
ci$('ciFile').onchange=e=>{const f=e.target.files&&e.target.files[0];e.target.value='';ciReadFile(f);};
|
|
3436
|
+
ci$('ciDrop').addEventListener('dragover',e=>{e.preventDefault();ci$('ciDrop').classList.add('drag');});
|
|
3437
|
+
ci$('ciDrop').addEventListener('dragleave',()=>ci$('ciDrop').classList.remove('drag'));
|
|
3438
|
+
ci$('ciDrop').addEventListener('drop',e=>{e.preventDefault();ci$('ciDrop').classList.remove('drag');const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0];ciReadFile(f);});
|
|
3439
|
+
async function ciDoList(dataUrl){
|
|
3440
|
+
ciStage('prog');ciProg('Parsing IFC… reading connections','Still reading — larger models can take up to 20 seconds');
|
|
3441
|
+
ciAbort=new AbortController();
|
|
3442
|
+
try{
|
|
3443
|
+
const res=await fetch('/api/import-connection/list',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({appId:APP_ID,dataUrl}),signal:ciAbort.signal});
|
|
3444
|
+
const j=await res.json().catch(()=>({ok:false}));ciClearSoft();
|
|
3445
|
+
if(!j.ok){ciStage('drop');ciDropErr(j.error||'Could not read that IFC file.');return;}
|
|
3446
|
+
ciSha=j.sha;ciConns=(j.connections||[]).slice().sort((a,b)=>ciTotal(b)-ciTotal(a)); // richest connection first
|
|
3447
|
+
if(!ciConns.length){ciStage('drop');ciDropErr('No connections found in that IFC — it may not be a detailed/fabricated model.');return;}
|
|
3448
|
+
if(ciConns.length===1){toast('Found one connection — '+(ciConns[0].name||'connection')+'. Building it…');ciDoExtract(ciConns[0]);return;}
|
|
3449
|
+
ciRenderList();
|
|
3450
|
+
}catch(e){ciClearSoft();if(e&&e.name==='AbortError')return;ciStage('drop');ciDropErr('Could not read that IFC file.');}
|
|
3451
|
+
}
|
|
3452
|
+
function ciRenderList(){ciStage('pick');ciSel=null;ci$('ciImport').disabled=true;ci$('ciSearch').value='';ciFilter('');setTimeout(()=>ci$('ciSearch').focus(),0);}
|
|
3453
|
+
function ciFilter(q){ q=(q||'').trim().toLowerCase();
|
|
3454
|
+
const matches=q?ciConns.filter(c=>String(c.name||'').toLowerCase().includes(q)||String(c.id||'').toLowerCase().includes(q)):ciConns;
|
|
3455
|
+
ci$('ciCount').textContent=q?(matches.length+' of '+ciConns.length+' match “'+q+'”'):(ciConns.length+' connection'+(ciConns.length===1?'':'s')+' found');
|
|
3456
|
+
const list=ci$('ciList'),frag=document.createDocumentFragment();
|
|
3457
|
+
if(!matches.length){const e=document.createElement('div');e.className='ci-empty';e.textContent='No connections match “'+q+'”. Try a different name.';frag.appendChild(e);}
|
|
3458
|
+
else for(const c of matches.slice(0,600)){
|
|
3459
|
+
const row=document.createElement('button');row.className='ci-row';row.dataset.id=c.id;
|
|
3460
|
+
const nm=document.createElement('span');nm.className='pn';nm.textContent=c.name||c.id;
|
|
3461
|
+
const pv=document.createElement('span');pv.className='pv';pv.textContent=ciSummary(c);
|
|
3462
|
+
row.append(nm,pv);row.onclick=()=>ciSelect(c,row);row.ondblclick=()=>{ciSelect(c,row);ciDoExtract(c);};
|
|
3463
|
+
frag.appendChild(row);
|
|
3464
|
+
}
|
|
3465
|
+
list.replaceChildren(frag);
|
|
3466
|
+
if(ciSel&&!matches.some(c=>c.id===ciSel.id)){ciSel=null;ci$('ciImport').disabled=true;} // selection got filtered out
|
|
3467
|
+
}
|
|
3468
|
+
function ciSelect(c,row){ciSel=c;ci$('ciImport').disabled=false;
|
|
3469
|
+
for(const r of ci$('ciList').querySelectorAll('.ci-row.sel'))r.classList.remove('sel');
|
|
3470
|
+
if(row)row.classList.add('sel');}
|
|
3471
|
+
function ciSelectByRow(row){const c=ciConns.find(x=>x.id===row.dataset.id);if(c)ciSelect(c,row);}
|
|
3472
|
+
ci$('ciSearch').addEventListener('input',e=>ciFilter(e.target.value));
|
|
3473
|
+
ci$('ciSearch').addEventListener('keydown',e=>{e.stopPropagation();
|
|
3474
|
+
if(e.key==='Enter'){e.preventDefault();if(ciSel)ciDoExtract(ciSel);else{const f=ci$('ciList').querySelector('.ci-row');if(f)f.click();}}
|
|
3475
|
+
else if(e.key==='ArrowDown'){e.preventDefault();const r=ci$('ciList').querySelector('.ci-row');if(r){r.focus();ciSelectByRow(r);}}});
|
|
3476
|
+
ci$('ciList').addEventListener('keydown',e=>{const rows=[...ci$('ciList').querySelectorAll('.ci-row')];if(!rows.length)return;const i=rows.indexOf(document.activeElement);
|
|
3477
|
+
if(e.key==='ArrowDown'){e.preventDefault();const n=rows[Math.min(rows.length-1,i+1)];if(n){n.focus();ciSelectByRow(n);}}
|
|
3478
|
+
else if(e.key==='ArrowUp'){e.preventDefault();if(i<=0){ci$('ciSearch').focus();}else{const n=rows[i-1];n.focus();ciSelectByRow(n);}}
|
|
3479
|
+
else if(e.key==='Enter'){e.preventDefault();if(ciSel)ciDoExtract(ciSel);}});
|
|
3480
|
+
ci$('ciImport').onclick=()=>{if(ciSel)ciDoExtract(ciSel);};
|
|
3481
|
+
async function ciDoExtract(cand){ if(!cand)return;
|
|
3482
|
+
ciStage('prog');ciProg('Building '+(cand.name||'connection')+'…','Still building — tessellating geometry');
|
|
3483
|
+
ciAbort=new AbortController();
|
|
3484
|
+
try{
|
|
3485
|
+
const res=await fetch('/api/import-connection/extract',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({appId:APP_ID,sha:ciSha,id:cand.id}),signal:ciAbort.signal});
|
|
3486
|
+
const j=await res.json().catch(()=>({ok:false}));ciClearSoft();
|
|
3487
|
+
if(!j.ok||!j.connection){toast(j.error||'Could not read that connection.');ciStage(ciConns.length>1?'pick':'drop');return;}
|
|
3488
|
+
ciClose();armConnectionInsert(j.connection);
|
|
3489
|
+
}catch(e){ciClearSoft();if(e&&e.name==='AbortError')return;toast('Could not read that connection.');ciStage(ciConns.length>1?'pick':'drop');}
|
|
3490
|
+
}
|
|
3491
|
+
ci$('ciCancel').onclick=ciClose; ci$('ciClose').onclick=ciClose;
|
|
3492
|
+
ci$('ciBackdrop').onclick=()=>{if(ci$('ciProgStage').style.display==='none')ciClose();}; // no-op mid-run so a stray backdrop click can't orphan the AWARE run
|
|
3493
|
+
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&ci$('connImportModal').style.display==='flex'){e.stopPropagation();ciClose();}},true);
|
|
3494
|
+
}
|
|
3319
3495
|
document.getElementById('m3dIso').onclick=()=>{if(d3.isIsolated())d3.clearIsolation();else d3.isolateSelected();}; // onIsolateChange refreshes the button label/visibility
|
|
3320
3496
|
// Work area: the ▢ Work area button opens a menu (Set to all objects / Define from selection / Show work area).
|
|
3321
3497
|
const workBtn=document.getElementById('m3dWork'),workMenu=document.getElementById('m3dWorkMenu');
|