@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.
@@ -236,19 +236,38 @@
236
236
  },
237
237
  "joint": {
238
238
  "type": "object",
239
- "required": ["id", "kind", "main"],
239
+ "required": ["id", "kind"],
240
240
  "additionalProperties": true,
241
- "description": "A placed connection the geometry-bearing recipe the connection engine expands into real 3D parts bound to its member(s). Distinct from the `connections[]` library (vendor-neutral type/detail/targets identities a joint can cite). The engine derives all geometry from this recipe + the live member positions, so a joint moves/re-solves with its members. v1 kinds: base-plate, shear-plate.",
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 the engine knows how to expand: base-plate, shear-plate (v1). 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`)." },
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); }
@@ -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
- baseMat.set(groupKey, new THREE.MeshStandardMaterial({ color: col, metalness: metal, roughness: rough }));
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 2D detail image into the 3D scene, near a beam (3D view only)">Insert detail…</button>
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 instruction='Modify the '+kindName+' connection "'+j.id+'" on member '+j.main+' (sheet '+((P&&P.sheet)||'?')+') — adjust, replace or move it per my request.';
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,isBP=j.kind==='base-plate',pp=j.params||{};
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 detail…';}}, // armed → cancel target
2968
- onInsertPlace:(pick,pending)=>{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
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');