@floless/app 0.57.0 → 0.59.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.
@@ -45,6 +45,7 @@
45
45
  .seg2{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;height:28px;margin-top:8px}
46
46
  .seg2 button{flex:1;border:0;border-radius:0;background:transparent;color:var(--mut)}
47
47
  .seg2 button.on{background:var(--brand);color:#fff}
48
+ .seg2.mtype button{font-size:10px;padding:0 1px} /* 5-way member-type control — tighter so Column/Ledger fit */
48
49
  .sect{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);margin:0 0 4px}
49
50
  .f{margin-top:8px} .unit{position:relative} .unit input{padding-right:34px}
50
51
  .unit span{position:absolute;right:8px;top:50%;transform:translateY(-50%);color:var(--mut);font-size:11px;pointer-events:none}
@@ -189,16 +190,67 @@
189
190
  circle.handle.ep-end{fill:#f472b6;stroke:#0b1220;stroke-width:2}
190
191
  .epdot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:middle}
191
192
  /* 3D-only floating chrome (parity with AWARE viewer-3d): toolbar TL, legend BL, ViewCube BR */
192
- #m3dBar{position:absolute;top:12px;left:12px;display:none;gap:6px;align-items:center;z-index:6}
193
+ #m3dBar{position:absolute;top:12px;left:12px;display:none;gap:6px;align-items:center;z-index:59} /* above the floating dim-label chips (57) so the Clip/Work-area dropdowns aren't occluded; still below toasts (60) */
193
194
  #m3dBar .seg-group,#m3dBar>button{box-shadow:0 4px 14px rgba(0,0,0,.45)}
194
195
  #m3dBar>button{font-size:12px;padding:5px 9px}
196
+ #m3dBar .m3dwrap{position:relative;display:inline-flex}
197
+ #m3dBar .m3dwrap>button{font-size:12px;padding:5px 9px;box-shadow:0 4px 14px rgba(0,0,0,.45)}
198
+ .m3dmenu{position:absolute;left:0;top:calc(100% + 6px);min-width:172px;background:var(--panel);border:1px solid #475569;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px 0;z-index:30;display:none}
199
+ .m3dmenu.open{display:block}
200
+ .m3dmenu button{display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap;font-size:12px;box-shadow:none}
201
+ .m3dmenu button:hover{background:#334155}
202
+ .m3dmenu button:disabled{opacity:.4;cursor:default;background:transparent}
203
+ .m3dmenu button.mdanger{color:#fca5a5} .m3dmenu button.mdanger:hover{background:#7f1d1d;color:#fecaca}
204
+ .m3dmenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
205
+ .m3dmenu label{display:flex;align-items:center;gap:7px;padding:7px 12px;color:var(--text);font-size:12px;cursor:pointer;white-space:nowrap}
206
+ .m3dmenu label:hover{background:#334155}
207
+ .m3dmenu label input{margin:0;accent-color:var(--brand);cursor:pointer}
208
+ /* thin cluster divider in the 3D toolbar */
209
+ #m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
210
+ /* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
211
+ #tooltip{position:fixed;z-index:80;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:11.5px;line-height:1.35;color:var(--text);pointer-events:none;max-width:260px;box-shadow:0 8px 22px rgba(0,0,0,.5);opacity:0;transition:opacity .12s}
212
+ #tooltip.show{opacity:1}
213
+ #m3dLegend .lrow .lx{margin-left:auto;color:var(--mut);padding:0 3px;border-radius:4px;visibility:hidden;font-size:13px;line-height:1}
214
+ #m3dLegend .lrow:hover .lx{visibility:visible}
215
+ #m3dLegend .lrow .lx:hover{color:#fecaca;background:#7f1d1d}
216
+ #m3dLegend .lrow.clip .clab{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
217
+ #m3dLegend .lrow.clip .lx{margin-left:0} /* the label's flex:1 already pushes On/Off + × to the right */
218
+ #m3dLegend .lrow.clip.sel{border-left:2px solid var(--brand);background:rgba(59,130,246,.16);padding-left:2px}
219
+ #m3dLegend .lrow.clip.sel .clab{color:var(--text)}
220
+ #m3dLegend .lrow.clip.sel .lsw{box-shadow:0 0 0 1.5px #f8fafc} /* white ring on the selected clip's swatch — mirrors the 3D endpoint ring */
221
+ #m3dLegend .cpill{font-size:9px;line-height:1;padding:2px 6px;border-radius:9px;border:1px solid #475569;background:#334155;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;flex:none;box-shadow:none;cursor:pointer}
222
+ #m3dLegend .cpill.on{background:var(--brand);border-color:var(--brand);color:#fff}
223
+ #m3dLegend .cpill:hover{border-color:#64748b}
195
224
  #m3dLegend{position:absolute;left:12px;bottom:64px;display:none;flex-direction:column;gap:1px;max-height:40%;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 10px;z-index:6;box-shadow:0 4px 14px rgba(0,0,0,.45);font-size:12px}
196
- #m3dLegend .lhint{color:var(--mut);font-size:10px;margin-bottom:4px}
225
+ #m3dLegend .lhint{color:var(--mut);font-size:10px;margin-bottom:4px;max-width:230px;white-space:normal} /* wrap-guard: the hint never drives the panel wider than the rows, so it can't clip on any font */
197
226
  #m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap}
198
227
  #m3dLegend .lrow:hover{background:#33415580}
199
228
  #m3dLegend .lrow.off{opacity:.4} #m3dLegend .lrow.off .lsw{filter:grayscale(1)}
229
+ #m3dLegend .lrow.solo{background:rgba(59,130,246,.12)} /* in the isolated set (Explorer-style multi-select highlight) */
230
+ #m3dLegend .lrow.dim .lsw{background:transparent;border:1.5px solid #67e8f9} /* dim-overlay rows: outline swatch (an annotation layer, not a part) */
231
+ #m3dLegend .lrow.dim.dimoff{opacity:.55} #m3dLegend .lrow.dim.dimoff .lsw{opacity:.35} /* off = a normal resting choice, not a hidden-part warning — gentler than .off, swatch stays cyan (no grayscale) */
200
232
  #m3dLegend .lsw{width:11px;height:11px;border-radius:2px;flex:none}
201
233
  #m3dLegend .lsec{color:#475569;font-size:10px;letter-spacing:.06em;text-transform:uppercase;margin:6px 0 2px;padding:0 4px}
234
+ /* member grouping: By profile / By type toggle + collapsible type categories (Phase 1) */
235
+ #m3dLegend .lmode{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;height:24px;margin-bottom:6px;flex:none}
236
+ #m3dLegend .lmode button{flex:1;border:0;background:transparent;color:var(--mut);font-size:11px;cursor:pointer}
237
+ #m3dLegend .lmode button.on{background:var(--brand);color:#fff}
238
+ #m3dLegend .cat-hdr{display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:5px;cursor:pointer;user-select:none;white-space:nowrap}
239
+ #m3dLegend .cat-hdr:hover{background:#33415580}
240
+ #m3dLegend .cat-hdr.empty .cat-label,#m3dLegend .cat-hdr.empty .cat-count{opacity:.55}
241
+ #m3dLegend .cat-hdr.drop-target{outline:1px solid var(--brand);background:rgba(59,130,246,.18)}
242
+ #m3dLegend .cat-chevron{font-size:10px;color:var(--mut);width:12px;flex:none;text-align:center}
243
+ #m3dLegend .cat-tog{font-size:12px;width:14px;flex:none;cursor:pointer;line-height:1;text-align:center}
244
+ #m3dLegend .cat-tog[data-state=on]{color:var(--brand)} #m3dLegend .cat-tog[data-state=off]{color:var(--mut)} #m3dLegend .cat-tog[data-state=mixed]{color:#64748b}
245
+ #m3dLegend .cat-label{flex:1;font-size:12px;color:var(--text)}
246
+ #m3dLegend .cat-count{font-size:10px;color:var(--mut);font-variant-numeric:tabular-nums}
247
+ #m3dLegend .lrow.typed{padding-left:20px}
248
+ #m3dLegend .cat-hdr.sub{padding-left:20px} /* a nested (middle) category, e.g. Dimensions → Base-plate */
249
+ #m3dLegend .lrow.sub{padding-left:34px} /* a row inside a nested category — indented one level deeper */
250
+ #m3dLegend .drag-handle{font-size:11px;color:var(--mut);cursor:grab;opacity:0;transition:opacity .1s;flex:none;padding:0 1px;line-height:1}
251
+ #m3dLegend .lrow:hover .drag-handle{opacity:1}
252
+ #m3dLegend .lrow.flash{background:rgba(59,130,246,.12)}
253
+ .leg-drag-ghost{position:fixed;pointer-events:none;z-index:70;background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:3px 8px;display:flex;align-items:center;gap:7px;font:12px system-ui;color:var(--text);width:200px;opacity:.88;box-shadow:0 4px 16px rgba(0,0,0,.6)}
202
254
  #m3dLegend .ldiv{height:1px;background:var(--line);margin:5px 2px}
203
255
  #m3dCube{position:absolute;right:12px;bottom:12px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))}
204
256
  </style>
@@ -257,16 +309,42 @@
257
309
  <div id=stage><svg id=svg></svg></div>
258
310
  <canvas id=stage3d tabindex=0 aria-label="3D model"></canvas>
259
311
  <div id=m3dBar role=group aria-label="3D view controls">
260
- <div class=seg-group id=m3dProj title="Camera projection"><button data-proj=persp class=on>Persp</button><button data-proj=ortho>Ortho</button></div>
261
- <div class=seg-group id=m3dMode title="Display mode"><button data-mode=solid class=on>Solid</button><button data-mode=wire>Wire</button><button data-mode=xray>X-ray</button></div>
262
- <button id=m3dFit title="Fit all to view">Fit</button>
263
- <button id=m3dRef title="Show the reference line (work-line between the end points) for every member">Ref line</button>
264
- <button id=m3dDim title="3D Dimension tool (D) — click two snapped points to measure. Free (3D), or X/Y/Z to lock an axis; hold Alt for a vertical (Z) measurement.">⊢ Dimension</button>
265
- <div class=seg-group id=m3dDimAxis title="Measurement axis" style="display:none"><button data-d3axis=free class=on title="Free 3D measurement (F)">Free</button><button data-d3axis=x title="Lock to the X axis (X)">X</button><button data-d3axis=y title="Lock to the Y axis (Y)">Y</button><button data-d3axis=z title="Lock to the Z / vertical axis (Z); hold Alt while picking for a quick vertical">Z</button></div>
266
- <button id=m3dDimShow title="Toggle visibility of placed 3D dimensions" style="display:none">Hide dims</button>
312
+ <!-- Camera -->
313
+ <div class=seg-group id=m3dProj><button data-proj=persp class=on data-tip="Perspective view — natural depth">Persp</button><button data-proj=ortho data-tip="Orthographic — true scale, no perspective">Ortho</button></div>
314
+ <button id=m3dFit data-tip="Fit all to view (Home)">Fit</button>
315
+ <span class=tb-sep></span>
316
+ <!-- Display / visibility -->
317
+ <div class=seg-group id=m3dMode><button data-mode=solid class=on data-tip="Solid shaded model">Solid</button><button data-mode=wire data-tip="Wireframe edges only">Wire</button><button data-mode=xray data-tip="See-through reveal hidden parts">X-ray</button></div>
318
+ <button id=m3dIso data-tip="Isolate selected hide everything else (Esc to exit)" style="display:none">Isolate</button>
319
+ <span class=tb-sep></span>
320
+ <!-- Measure / annotate -->
321
+ <button id=m3dRef data-tip="Show each member's reference line (centreline)">Ref line</button>
322
+ <button id=m3dDim data-tip="Measure — click two snapped points (D); axis Free/X/Y/Z, Alt = vertical">Dimension</button>
323
+ <div class=seg-group id=m3dDimAxis style="display:none"><button data-d3axis=free class=on data-tip="Free 3D measurement (F)">Free</button><button data-d3axis=x data-tip="Lock measurement to X (X)">X</button><button data-d3axis=y data-tip="Lock measurement to Y (Y)">Y</button><button data-d3axis=z data-tip="Lock to Z / vertical (Z); Alt = quick vertical">Z</button></div>
324
+ <button id=m3dDimShow data-tip="Show or hide placed 3D dimensions" style="display:none">Hide dims</button>
325
+ <span class=tb-sep></span>
326
+ <!-- Section -->
327
+ <div class=m3dwrap>
328
+ <button id=m3dClip data-tip="Clip planes and boxes — section to see inside a connection">Clip ▾</button>
329
+ <div id=m3dClipMenu class=m3dmenu role=menu>
330
+ <button data-clip=plane data-tip="Click a model face to cut the view there">Add clip plane</button>
331
+ <button data-clip=box data-tip="Section a box around the selection (or whole model)">Add clip box</button>
332
+ <hr>
333
+ <button data-clip=clear class=mdanger data-tip="Remove every clip">Clear all clips</button>
334
+ </div>
335
+ </div>
336
+ <div class=m3dwrap>
337
+ <button id=m3dWork data-tip="Working area — bound the view to a box (Tekla-style)">Work area ▾</button>
338
+ <div id=m3dWorkMenu class=m3dmenu role=menu>
339
+ <button data-wa=all data-tip="Bound the work area to the whole model">Set to all objects</button>
340
+ <button data-wa=sel data-tip="Bound the work area to the current selection">Define from selection</button>
341
+ <hr>
342
+ <label data-tip="Show or hide the work-area box"><input type=checkbox id=m3dWorkOn> Show work area</label>
343
+ </div>
344
+ </div>
267
345
  </div>
268
346
  <div id=m3dLegend></div>
269
- <div id=m3dCube title="Click a face for that view · drag the scene with the right button to orbit"></div>
347
+ <div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
270
348
  <div id=zoombar>
271
349
  <button id=zOut title="Zoom out">−</button>
272
350
  <input id=zRange type=range min=10 max=400 step=1 value=100>
@@ -371,9 +449,13 @@ async function boot() {
371
449
  }
372
450
  } catch (_) { /* no target goal shown */ }
373
451
  PAL = (Array.isArray(C.palette) && C.palette.length) ? C.palette : DEFAULT_PAL; WT = C.weights || {};
452
+ try { const _sr = await fetch('/api/aisc-shapes'); if (_sr.ok) { const _sj = await _sr.json(); if (Array.isArray(_sj.shapes)) window.__AISC_SHAPES = new Set(_sj.shapes); } } catch (_) { /* no shape table → _nominalPlf falls back to regex-only */ }
453
+ // No weight seeding needed: every RFI/weight read resolves through _wt(), which falls back to the
454
+ // SHAPES-guarded _nominalPlf for a valid AISC designation absent from the contract's weights map.
374
455
  C.custom_details = C.custom_details || {};
375
456
  C.profile_colors = C.profile_colors || {};
376
457
  if(!Array.isArray(C.dims3d))C.dims3d=[]; // model-global draft-only 3D dimensions
458
+ if(!C.dim_overlays||typeof C.dim_overlays!=='object'||Array.isArray(C.dim_overlays))C.dim_overlays={bolt_pitch:true,edge_clearance:true,cope_size:true,base_plate:true,anchor_depth:true}; // model-global legend DIMENSIONS toggles — all on by default
377
459
  main();
378
460
  // SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
379
461
  // We open our own EventSource to the same /api/events endpoint as the main app.
@@ -456,7 +538,7 @@ let calloutsVisible=true; // Workstream B: show/hide the clickable callout bub
456
538
  let csaxisMode=false, csDraft=null; // Local coordinate system "set axes" tool: armed flag + in-progress origin [x,y] (null until click 1). P.frame={o,u} holds the committed local frame (null = global).
457
539
  let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off,rot}, and a counter for unique ids on rapid clicks
458
540
  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
459
- let sel3dDimId=null; // the currently-selected 3D dimension id (model-global; 3D view highlights it, Delete removes it)
541
+ let sel3dDimIds=new Set(),dim3dAnchor=null; // selected 3D dimension ids (multi-select like parts; 3D view highlights them, Delete removes them)
460
542
  let selIds=new Set();
461
543
  let undo=[], redo=[];
462
544
  const byId=id=>P.members.find(m=>m.id===id);
@@ -515,8 +597,9 @@ const autofillTOS=syncDefaults;
515
597
  // live on one foundation sheet; the reader's ids are unique within the set that gets detailed). The one
516
598
  // unsupported edge is two DIFFERENT sheets each detailing a column that happens to share an id — then the
517
599
  // slice would over-match. Tracked as a reader-side id-uniqueness hardening. dims3d rides its own persistence.
518
- function snapshot(){const pm=new Set((P.members||[]).map(m=>m.id));return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null,joints:(C.joints||[]).filter(j=>j&&pm.has(j.main))});}
600
+ function snapshot(){const pm=new Set((P.members||[]).map(m=>m.id));return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null,joints:(C.joints||[]).filter(j=>j&&pm.has(j.main)),clips:(window.Steel3DView&&window.Steel3DView.clipState)?window.Steel3DView.clipState():null});} // clips/work area are view-state but ride the contract undo stack
519
601
  function refreshDims3d(){if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();} // repaint the 3D dim lines/labels from C.dims3d
602
+ function refreshOverlayDims3d(){if(window.Steel3DView&&window.Steel3DView.refreshOverlayDims)window.Steel3DView.refreshOverlayDims();} // re-derive the legend DIMENSIONS overlays from C.dim_overlays
520
603
  function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
521
604
  function apply(json){const d=JSON.parse(json);
522
605
  if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
@@ -525,8 +608,9 @@ function apply(json){const d=JSON.parse(json);
525
608
  updCS();
526
609
  selIds=new Set([...selIds].filter(id=>byId(id)));
527
610
  selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
528
- if(sel3dDimId&&!(C.dims3d||[]).some(x=>x.id===sel3dDimId))sel3dDimId=null; // drop a stale 3D-dim selection
611
+ if(sel3dDimIds.size){const ok=new Set((C.dims3d||[]).map(x=>x.id));sel3dDimIds=new Set([...sel3dDimIds].filter(id=>ok.has(id)));} // drop stale 3D-dim selections
529
612
  dimDraft=null;dimChainPrev=null;dimPrevClear(); // undo/redo changed geometry under the tool → abandon any in-progress dim placement / chain (else the chain head points at a removed segment)
613
+ if(window.Steel3DView&&window.Steel3DView.setClipState&&'clips' in d)window.Steel3DView.setClipState(d.clips); // restore clips + work area alongside the contract
530
614
  scheduleSave();render();sync3D();}
531
615
  function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
532
616
  function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
@@ -541,7 +625,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
541
625
  else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
542
626
  else el.textContent=msg||'Auto-save on';}
543
627
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
544
- custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, joints:C.joints,
628
+ custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, joints:C.joints,
545
629
  plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details,dims:p.dims,frame:p.frame||null}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
546
630
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
547
631
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
@@ -573,6 +657,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
573
657
  if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
574
658
  C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;if(Array.isArray(s.dims))p.dims=s.dims;if('frame' in s)p.frame=s.frame||null;p.autofilled=true;}});
575
659
  if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
660
+ if(d.dim_overlays&&typeof d.dim_overlays==='object'&&!Array.isArray(d.dim_overlays)){const o=d.dim_overlays;C.dim_overlays={bolt_pitch:o.bolt_pitch!==false,edge_clearance:o.edge_clearance!==false,cope_size:o.cope_size!==false,base_plate:o.base_plate!==false,anchor_depth:o.anchor_depth!==false};} // restore the legend DIMENSIONS toggles — sanitised to the known boolean keys (a corrupt/partial draft can't desync the legend from what's drawn; on unless explicitly false)
576
661
  if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
577
662
  if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
578
663
  function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
@@ -665,22 +750,31 @@ function renderDims(){if(!dimsVisible||!Array.isArray(P.dims))return '';
665
750
  s+=`<circle class=dimhandle data-dim="${esc(d.id)}" data-dimend="0" cx="${d.a[0]}" cy="${d.a[1]}" r="${HR}"/><circle class=dimhandle data-dim="${esc(d.id)}" data-dimend="1" cx="${d.b[0]}" cy="${d.b[1]}" r="${HR}"/>`;
666
751
  return s;}
667
752
  function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
753
+ // --- repeat last command (AutoCAD/Tekla): when NO tool is active, Space/Enter re-invokes the last command ---
754
+ let lastCmd=null;
755
+ function setLastCmd(label,run){lastCmd={label,run};}
756
+ function anyToolActive(){
757
+ if(dimMode||dimChain||dimSplitMode||geoMode||csaxisMode||mode==='add'||picking)return true; // 2D tools armed
758
+ if(view3d&&window.Steel3DView){if(window.Steel3DView.clipMode&&window.Steel3DView.clipMode())return true;const dm=document.getElementById('m3dDim');if(dm&&dm.classList.contains('on'))return true;} // 3D dim/clip armed
759
+ return false;
760
+ }
761
+ function repeatLast(){if(lastCmd){lastCmd.run();if(typeof toast==='function')toast('↻ '+lastCmd.label);}}
668
762
  // nearest end → intersection: extends when the line is past the end, trims when it crosses the member. ponytail: keeps the larger piece on a deep overshoot — undo+drag if that's wrong.
669
763
  // Multi: trim/extend the nearest end of EVERY given member to the (tA,tB) target line; members
670
764
  // parallel to the target (no intersection) are skipped. One undo for the whole batch.
671
765
  function snapEndMulti(ms,tA,tB){const pv=snapshot();let n=0;
672
766
  for(const m of ms){const I=lineX(m.wp[0],m.wp[1],tA,tB);if(!I)continue;
673
- const h=len(m.wp[0],I)<=len(m.wp[1],I)?0:1;m.wp[h]=I;m.rfi=(WT[m.profile]==null);n++;}
767
+ const h=len(m.wp[0],I)<=len(m.wp[1],I)?0:1;m.wp[h]=I;m.rfi=(_wt(m.profile)==null);n++;}
674
768
  if(n){pushUndo(pv);render();sync3D();}return n;}
675
769
  function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(JSON.stringify(m));
676
770
  const a=m.wp[0].slice(),b=m.wp[1].slice(),q=pt.slice(),mk=()=>({tos:null,note:'',tosDef:true,detail:''});
677
771
  m.wp=[a,q];if(m.ends)m.ends=[m.ends[0],mk()]; // first half keeps its start end, fresh cut end
678
772
  const c=JSON.parse(JSON.stringify(base));c.id='m'+Date.now();c.wp=[q.slice(),b];
679
773
  if(c.ends)c.ends=[mk(),base.ends?base.ends[1]:mk()]; // second half keeps the original far end
680
- c.rfi=(WT[c.profile]==null);P.members.push(c);selIds=new Set([m.id,c.id]);selDimIds.clear();geoMode=null;setGeo();pushUndo(pv);render();}
774
+ c.rfi=(_wt(c.profile)==null);P.members.push(c);selIds=new Set([m.id,c.id]);selDimIds.clear();geoMode=null;setGeo();pushUndo(pv);render();}
681
775
  // --- duplicates: members with coincident geometry (same two work-points, order-independent, ~3px tol) ---
682
776
  function dupKey(m){const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;}
683
- function dupScore(m){let s=0;if(WT[m.profile]!=null)s+=2;if(m.profile&&!/^MF/i.test(m.profile))s+=1;return s;} // keep the most-resolved copy
777
+ function dupScore(m){let s=0;if(_wt(m.profile)!=null)s+=2;if(m.profile&&!/^MF/i.test(m.profile))s+=1;return s;} // keep the most-resolved copy
684
778
  function redundantDups(){const g={};for(const m of P.members){(g[dupKey(m)]=g[dupKey(m)]||[]).push(m);}
685
779
  const out=[];for(const k in g){const grp=g[k];if(grp.length<2)continue;
686
780
  grp.sort((a,b)=>dupScore(b)-dupScore(a));for(let i=1;i<grp.length;i++)out.push(grp[i].id);} // keep [0], rest redundant
@@ -800,7 +894,7 @@ function render(){
800
894
  s+=renderDims();
801
895
  if(P.frame)s+=axisGlyphSvg(P.frame.o,P.frame.u,false); // local-axes glyph at the origin (only when a frame is set; removed on reset)
802
896
  svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS(); updConnBtn(); updBpBtn(); updSpBtn();
803
- if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
897
+ if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);} // keep the 3D highlight in sync; selecting a member clears any clip selection (exclusive)
804
898
  }
805
899
  function updDup(){const n=redundantDups().length;
806
900
  document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
@@ -822,7 +916,7 @@ function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"
822
916
  if(ln){ln.setAttribute('x1',m.wp[0][0]);ln.setAttribute('y1',m.wp[0][1]);ln.setAttribute('x2',m.wp[1][0]);ln.setAttribute('y2',m.wp[1][1]);}}
823
917
  function stats(){
824
918
  document.getElementById('mc').textContent=P.members.length;
825
- let w=0,rfi=0; for(const m of P.members){const wpf=WT[m.profile]; if(wpf==null)rfi++; else w+=len(m.wp[0],m.wp[1])/FT*wpf;}
919
+ let w=0,rfi=0; for(const m of P.members){const wpf=_wt(m.profile); if(wpf==null)rfi++; else w+=len(m.wp[0],m.wp[1])/FT*wpf;}
826
920
  document.getElementById('wt').textContent=(w/2000).toFixed(1);document.getElementById('wtlb').textContent=Math.round(w).toLocaleString();document.getElementById('rc').textContent=rfi;
827
921
  }
828
922
  // Aggregate one field across a multi-selection → the shared value, or VARIES when they differ. Drives the
@@ -866,6 +960,49 @@ function panel(){
866
960
  const ap=document.getElementById('addProf');
867
961
  ap.oninput=ap.onchange=e=>{addProfile=e.target.value.toUpperCase().replace(/ /g,'');};
868
962
  return;}
963
+ // A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
964
+ // read-only (parts have no own state; their params live on the parent joint) + a jump to that member.
965
+ {const selList=[...selIds];
966
+ const allParts=selList.length>0&&selList.every(id=>!P.members.some(m=>m.id===id));
967
+ const selPart=allParts?(selList.find(id=>/:bolt\d+$/.test(id))||selList[0]):null; // bolt array → a bolt is the representative
968
+ const ci=selPart?selPart.indexOf(':'):-1;
969
+ const j=selPart?(C.joints||[]).find(x=>x&&x.id===(ci>=0?selPart.slice(0,ci):selPart)):null;
970
+ if(selPart&&j){
971
+ const suf=ci>=0?selPart.slice(ci+1):'';const pp=j.params||{};const multi=selList.length>1;
972
+ const loc=j.at==='base'?'Base':j.at==='end1'?'End end':'Start end';
973
+ const pk=/^(bolt|head)/.test(suf)?'bolt':/^(fn|ln|nut)/.test(suf)?'nut':/^weld/.test(suf)?'weld':/^cope/.test(suf)?'cope':/^stiff/.test(suf)?'stiff':/^(rod|anchor)/.test(suf)?'rod':/^wsh/.test(suf)?'washer':/^wp/.test(suf)?'wplate':'plate';
974
+ const kindName=j.kind==='base-plate'?'Base plate':'Shear plate';
975
+ const head=({bolt:(multi?'Bolts':'Bolt'),nut:(multi?'Nuts':'Nut'),weld:'Weld',cope:'Cope',stiff:'Stiffener',rod:(multi?'Anchor rods':'Anchor rod'),washer:'Washer',wplate:'Washer plate'})[pk]||kindName;
976
+ const el=(partsById||{})[selPart]||null;const lbl=el&&el.meta&&el.meta.label;
977
+ const DEF={plateThickness:10,weldLeg:6,clearance:13,boltDia:20,boltPitch:70,copeRadius:12.7}; // engine defaults (mirror the member panel's spF defaults)
978
+ const auto='<span style="color:var(--mut)">auto</span>',dft=' <span style="color:var(--mut);font-size:11px">· default</span>';
979
+ const v=(k,u)=>{const o=pp[k];const set=(o!=null&&o!=='');const x=set?o:DEF[k];if(x==null)return auto;const t=(u==='mm')?esc(fmtFtIn(Number(x)/25.4)):esc(String(x))+(u?(' '+u):'');return t+(set?'':dft);}; // length params shown in ft-in (mm→in); counts unchanged
980
+ const dim=(n)=>(n==null?auto:esc(fmtFtIn(Number(n)/25.4))); // resolved connection geometry (mm) → ft-in
981
+ const dash='<span style="color:var(--mut)">—</span>'; // a value that isn't scheduled yet (e.g. embedment) — honest "exists but unset", read-only
982
+ const plen=(e)=>(e&&e.from&&e.to?Math.hypot(e.to[0]-e.from[0],e.to[1]-e.from[1],e.to[2]-e.from[2]):null); // a from→to part's length (nut height / washer thickness)
983
+ 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)">${l}</span><span style="font-variant-numeric:tabular-nums">${val}</span></div>`;
984
+ const sec=t=>`<div class=divrow><hr><span class=sect style="margin:0">${t}</span><hr></div>`;
985
+ let body='';
986
+ if(pk==='plate'&&j.kind==='shear-plate')body=sec('Plate')+kv('Width',dim(el&&el.width))+kv('Height',dim(el&&el.depth))+kv('Thickness',dim(el&&el.thickness))+kv('Weld leg',v('weldLeg','mm'))+kv('Clearance',v('clearance','mm'));
987
+ else if(pk==='bolt')body=sec('Bolts')+kv('Grid (cols × rows)',`${pp.boltCols||1} × ${pp.boltRows||3}`)+kv('Diameter',v('boltDia','mm'))+kv('Grade',pp.boltGrade?esc(pp.boltGrade):'A325'+dft)+kv('Pitch',v('boltPitch','mm'))+kv('Length','<span style="color:var(--mut)">auto (from grip)</span>');
988
+ else if(pk==='weld')body=sec('Weld')+kv('Leg',v('weldLeg','mm'));
989
+ else if(pk==='cope')body=sec('Cope')+kv('Length',dim(el&&el.width))+kv('Depth',dim(el&&el.depth))+kv('Re-entrant radius',v('copeRadius','mm'));
990
+ else if(pk==='stiff')body=sec('Stiffener')+`<div class=hint style="margin:0">Opposite-side web stiffener on the support.</div>`;
991
+ else if(pk==='rod'){const an=(((partsById||{})[selPart.replace(/:rod\d+$/,':plate')]||{}).meta||{}).anchor;const eset=!!(an&&an.embedSet); // projection/grout/embedment live on the parent base plate; rod stub length is meaningless until embedment is scheduled
992
+ body=sec('Anchor rod')+kv('Diameter',dim(el&&el.d))+kv('Projection',an?dim(an.projMm):auto)+kv('Grout',eset&&an.groutMm>0.5?dim(an.groutMm):dash)+kv('Embedment',eset?dim(an.embedMm):dash)+kv('Total length',eset?dim(an.projMm+(an.groutMm||0)+an.embedMm):dash);}
993
+ else if(pk==='nut')body=sec('Nut')+kv('Wrench size',dim(el&&el.acrossFlats))+kv('Height',dim(plen(el)));
994
+ else if(pk==='washer')body=sec('Washer')+kv('Outer ⌀',dim(el&&el.dOuter))+kv('Inner ⌀',dim(el&&el.dInner))+kv('Thickness',dim(plen(el)));
995
+ else if(pk==='wplate')body=sec('Washer plate')+kv('Width',dim(el&&el.width))+kv('Height',dim(el&&el.depth))+kv('Thickness',dim(el&&el.thickness));
996
+ else body=sec(kindName+'')+kv('Width',dim(el&&el.width))+kv('Height',dim(el&&el.depth))+kv('Thickness',dim(el&&el.thickness));
997
+ p.innerHTML=`<h3>${esc(head)} <span style="color:var(--mut);font-weight:400">· ${loc}</span></h3>
998
+ <div class="row hint" style="margin-top:2px">Read-only — auto-detailed on <b>${esc(j.main)}</b>; edit its parameters on the member.</div>
999
+ ${lbl?`<div class="row" style="margin:3px 0 0;font-size:12px;color:var(--brand);font-variant-numeric:tabular-nums">${esc(lbl)}</div>`:''}
1000
+ ${body}
1001
+ <div class=divrow><hr></div>
1002
+ <div class="row f"><button class=ghostw id=partEdit title="Select the parent member to edit this connection">✎ Edit on ${esc(j.main)} →</button></div>`;
1003
+ const eb=document.getElementById('partEdit');if(eb)eb.onclick=()=>{selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1004
+ return;
1005
+ }}
869
1006
  const arr=selArr();
870
1007
  if(arr.length===0){p.innerHTML='<h3 style="display:flex;justify-content:space-between;align-items:center">Legend'+(Object.keys(C.profile_colors).length?'<button class=ghost id=resetCols style="font-size:10px;padding:1px 6px">reset colours</button>':'')+'</h3><div class=legend>'+profs.filter(pr=>P.members.some(mm=>mm.profile===pr)).map(pr=>`<span><input type=color class=swc data-prof="${esc(pr)}" value="${colorFor(pr)}" title="Click to recolour ${esc(pr)}">${esc(pr)}</span>`).join('')+'</div><div class=hint style="margin-top:12px">Click a member to edit; <b>Ctrl+click</b> to add/remove; drag an empty area to <b>box-select</b> (Ctrl adds). Drag a selected line to move it (all selected move together) — it snaps onto nearby grid/endpoints; drag an end dot to adjust — also snaps (<b>Alt</b> off). Hold <b>Shift</b> to keep it straight (H/V). <b>Ctrl+D</b> duplicate, <b>Ctrl+Z/Y</b> undo/redo, <b>Del</b> delete, <b>Esc</b> deselect. <b>Ctrl+scroll</b> zoom, <b>middle-drag</b> pan, <b>Home</b> fit. Dashed = RFI (size unresolved, e.g. MF).</div>'+
871
1008
  '<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div class=sect>Project defaults</div>'+
@@ -882,8 +1019,8 @@ function panel(){
882
1019
  const beams=arr.filter(m=>m.role!=='column'), cols=arr.filter(m=>m.role==='column');
883
1020
  const allBeam=cols.length===0, allCol=beams.length===0;
884
1021
  const totalL=arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT,0).toFixed(1);
885
- const allKnown=arr.every(m=>WT[m.profile]!=null);
886
- const totalW=allKnown?Math.round(arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT*WT[m.profile],0)):null;
1022
+ const allKnown=arr.every(m=>_wt(m.profile)!=null);
1023
+ const totalW=allKnown?Math.round(arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT*_wt(m.profile),0)):null;
887
1024
  const dupSet=new Set(redundantDups()), dupSel=arr.filter(m=>dupSet.has(m.id)).length;
888
1025
  const profAgg=agg(arr,m=>m.profile), allVerified=arr.every(m=>m.verified===true);
889
1026
  const VV=v=>v===VARIES, valOf=v=>VV(v)||v==null?'':esc(fmtFtIn(v)), decOf=v=>VV(v)||v==null?'':esc(fmtDecIn(v));
@@ -909,6 +1046,7 @@ function panel(){
909
1046
  <div class="row hint" style="margin-top:0">Edits apply to <b>all ${arr.length}</b> selected · a blank <b>Varies</b> field is left unchanged.</div>
910
1047
  <div class=row><label>Profile</label><input id=pf class=combo data-src=profiles placeholder="${VV(profAgg)?'Varies':''}" value="${VV(profAgg)?'':esc(profAgg||'')}" autocomplete=off></div>
911
1048
  <div class="seg2 f"><button id=rBeam class="${allBeam?'on':''}">Beam</button><button id=rCol class="${allCol?'on':''}">Column</button></div>
1049
+ <div class="seg2 mtype" id=mTypeM style="margin-top:6px" title="Set member type for all selected — drives legend grouping">${MEMBER_TYPES.map(t=>`<button data-mtype="${t.k}" class="${arr.every(x=>memberTypeOf(x)===t.k)?'on':''}">${t.label}</button>`).join('')}</div>
912
1050
  ${(!allBeam&&!allCol)?`<div class=hint style="margin-top:4px">Mixed — ${beams.length} beam${beams.length>1?'s':''}, ${cols.length} column${cols.length>1?'s':''}</div>`:''}
913
1051
  <div class="row hint">Total length <b>${totalL} ft</b>${totalW!=null?` · <b>${totalW.toLocaleString()} lb</b>`:''}${dupSel?` · <span style="color:#fca5a5">${dupSel} duplicate${dupSel>1?'s':''}</span> (overlap a kept member — deleting these dedupes)`:''}</div>
914
1052
  <div class=row><button class=ghostw id=verifyBtn${allVerified?' style="border-color:#166534;color:#86efac"':''}>${allVerified?'✓ All verified — click to unverify':'Mark all verified'}</button></div>
@@ -922,7 +1060,8 @@ function panel(){
922
1060
  <div class=row><button class=ghostw id=swapEnds title="Reverse every selected member: swap each one's start and end (yellow ↔ magenta / bottom ↔ top) handles · Shortcut: P">⇄ Swap start ↔ end (all) <span style="opacity:.55">(P)</span></button></div>
923
1061
  <div class="row f"><button class=danger id=del>Delete selected (${arr.length})</button></div>`;
924
1062
  // wiring — every commit applies to the whole selection; a blank field is a no-op (leaves each member's own value).
925
- document.getElementById('pf').onchange=e=>{const v=e.target.value.toUpperCase().replace(/ /g,'');if(!v)return;edit(()=>{for(const m of selArr()){m.profile=v;m.rfi=(WT[v]==null);}if(!profs.includes(v)){profs.push(v);profs.sort();}});};
1063
+ document.getElementById('pf').onchange=e=>{const v=e.target.value.toUpperCase().replace(/ /g,'');if(!v)return;edit(()=>{for(const m of selArr()){m.profile=v;m.rfi=(_wt(v)==null);}if(!profs.includes(v)){profs.push(v);profs.sort();}});};
1064
+ document.querySelectorAll('#mTypeM button').forEach(b=>b.onclick=()=>{const v=b.dataset.mtype;edit(()=>{for(const m of selArr())m.memberType=v;});}); // set type for all selected → legend re-categorizes
926
1065
  document.getElementById('rBeam').onclick=()=>edit(()=>{for(const m of selArr())if(m.role==='column'){m.role='beam';ensureMeta(m);}});
927
1066
  document.getElementById('rCol').onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column'){m.role='column';ensureMeta(m);}});
928
1067
  document.getElementById('verifyBtn').onclick=()=>edit(()=>{const v=!allVerified;for(const m of selArr())m.verified=v;});
@@ -944,7 +1083,7 @@ function panel(){
944
1083
  wMTos('tosB',s1);wMText('ntB',s1,(o,v)=>o.note=v);wMText('dtB',s1,(o,v)=>o.detail=v);
945
1084
  {const me=document.getElementById('matchEnds');if(me)me.onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail,conn:m.ends[0].conn,connVerified:m.ends[0].connVerified};});}}
946
1085
  return;}
947
- const m=ensureMeta(arr[0]), wpf=WT[m.profile], L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column'), pos=posDefault(m);
1086
+ const m=ensureMeta(arr[0]), wpf=_wt(m.profile), L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column'), pos=posDefault(m);
948
1087
  const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
949
1088
  const mfSug=((m.mf||/^MF/i.test(m.profile))&&_lvl&&C.moment_frames)?[...new Set(C.moment_frames.flatMap(f=>f[_lvl]||[]))].filter(Boolean):[];
950
1089
  const ftFld=(id,label,v)=>`<div class=elab>${label}</div><input id=${id} inputmode=decimal placeholder="5 3/4&quot; · 1&#39;-0 1/4&quot;" value="${esc(fmtFtIn(v))}"><span class=edec>${esc(fmtDecIn(v))}</span>`;
@@ -967,12 +1106,12 @@ function panel(){
967
1106
  <div class=divrow><hr><button class=ghost id=matchEnds>Match → both ends</button><hr></div>
968
1107
  <div class=sect>${dotE}End</div>${tosFld('tosB','TOS',m.ends[1])}${cFld('cnB',m.ends[1])}${fb(m.ends[1],nin('ntB',m.ends[1].note)+dFld('dtB',m.ends[1]))}`;
969
1108
  const bpj=col?(C.joints||[]).find(j=>j&&j.kind==='base-plate'&&j.main===m.id):null; // this column's base-plate joint, if detailed
970
- const pFld=(key,label,ph,unit)=>`<div class=elab>${label}</div><input id=bp_${key} inputmode=decimal placeholder="${esc(ph)}" value="${bpj&&bpj.params&&bpj.params[key]!=null?esc(String(bpj.params[key])):''}" autocomplete=off>${unit?`<span class=edec>${unit}</span>`:''}`;
971
- const bpSect=bpj?`<div class=divrow><hr><span class=sect style="margin:0">Base plate · mm</span><hr></div>
972
- <div class=hint style="margin:0 0 6px">Tune this column's base plate — millimetres; empty = engine default ("auto" plate size = derived from the column profile). The AI fills these from a schedule; tweak here.</div>
973
- ${pFld('plateWidth','Plate width "N"','auto','mm')}${pFld('plateDepth','Plate depth "B"','auto','mm')}${pFld('thickness','Thickness','25','mm')}${pFld('weldLeg','Weld leg','8','mm')}
1109
+ const pFld=(key,label,ph,unit)=>{const raw=bpj&&bpj.params&&bpj.params[key]!=null?bpj.params[key]:null;const val=raw==null?'':(unit==='mm'?esc(fmtFtIn(Number(raw)/25.4)):esc(String(raw)));return `<div class=elab>${label}</div><input id=bp_${key} inputmode=decimal placeholder="${esc(ph)}" value="${val}" autocomplete=off>${unit&&unit!=='mm'?`<span class=edec>${unit}</span>`:''}`;}; // mm params shown/entered in ft-in
1110
+ const bpSect=bpj?`<div class=divrow><hr><span class=sect style="margin:0">Base plate</span><hr></div>
1111
+ <div class=hint style="margin:0 0 6px">Tune this column's base plate — inches / ft-in (e.g. 3/4&quot;, 1'-0 1/4&quot;); empty = engine default ("auto" plate size = derived from the column profile). The AI fills these from a schedule; tweak here.</div>
1112
+ ${pFld('plateWidth','Plate width "N"','auto','mm')}${pFld('plateDepth','Plate depth "B"','auto','mm')}${pFld('thickness','Thickness','1"','mm')}${pFld('weldLeg','Weld leg','5/16"','mm')}
974
1113
  <div class=elab style="margin-top:7px;opacity:.7">Anchor kit</div>
975
- ${pFld('boltCols','Bolt columns','2','')}${pFld('boltRows','Bolt rows','2','')}${pFld('boltDia','Bolt ⌀','24','mm')}
1114
+ ${pFld('boltCols','Bolt columns','2','')}${pFld('boltRows','Bolt rows','2','')}${pFld('boltDia','Bolt ⌀','1"','mm')}${pFld('embedment','Embedment','auto','mm')}${pFld('grout','Grout','auto','mm')}
976
1115
  <div class="row f"><button class="ghostw" id=bpRemove title="Delete this column's base plate" style="color:#fca5a5;border-color:#7f1d1d">Remove base plate</button></div>`:'';
977
1116
  // This BEAM's shear-plate joints — one params block per detailed END (start/end). Mirrors bpSect but
978
1117
  // per-end (a beam can be detailed at both ends), and adds the clearance + web-side + stiffener controls.
@@ -980,15 +1119,17 @@ function panel(){
980
1119
  const spAllAuto=spjs.length>0&&spjs.every(x=>x.j.source==='auto');
981
1120
  // DOM ids key on the END INDEX (0/1), NEVER the joint id (a free-string schema field) — so a crafted
982
1121
  // joints[].id can't inject into an attribute position; the handlers below close over the real joint.
983
- const spF=(e,j,key,label,ph,unit)=>{const v=j&&j.params&&j.params[key]!=null?String(j.params[key]):'';
984
- return `<div class=elab>${label}</div><input id="spf_e${e}_${key}" inputmode=decimal placeholder="${esc(ph)}" value="${esc(v)}" autocomplete=off>${unit?`<span class=edec>${unit}</span>`:''}`;};
1122
+ const spF=(e,j,key,label,ph,unit)=>{const raw=j&&j.params&&j.params[key]!=null?j.params[key]:null;const val=raw==null?'':(unit==='mm'?esc(fmtFtIn(Number(raw)/25.4)):esc(String(raw)));
1123
+ return `<div class=elab>${label}</div><input id="spf_e${e}_${key}" inputmode=decimal placeholder="${esc(ph)}" value="${val}" autocomplete=off>${unit&&unit!=='mm'?`<span class=edec>${unit}</span>`:''}`;}; // mm params shown/entered in ft-in
985
1124
  const spBlock=({e,j})=>{const dot=e===1?dotE:dotS,lbl=e===1?'End':'Start',st=!!(j.params&&j.params.stiffener);
986
- return `<div class=divrow><hr><span class=sect style="margin:0">${dot}${lbl} · Shear plate · mm</span><hr></div>
987
- ${spF(e,j,'plateWidth','Plate width','auto','mm')}${spF(e,j,'plateHeight','Plate height','auto','mm')}${spF(e,j,'plateThickness','Thickness','10','mm')}${spF(e,j,'weldLeg','Weld leg','6','mm')}${spF(e,j,'clearance','Clearance (beam gap)','13','mm')}
1125
+ return `<div class=divrow><hr><span class=sect style="margin:0">${dot}${lbl} · Shear plate</span><hr></div>
1126
+ ${spF(e,j,'plateWidth','Plate width','auto','mm')}${spF(e,j,'plateHeight','Plate height','auto','mm')}${spF(e,j,'plateThickness','Thickness','3/8"','mm')}${spF(e,j,'weldLeg','Weld leg','1/4"','mm')}${spF(e,j,'clearance','Clearance (beam gap)','1/2"','mm')}
988
1127
  <div class=elabrow><span class=elab>Web side</span></div>
989
1128
  <div class=seg2 style="margin-top:0"><button id="spside_e${e}_n" class="${(j.params&&j.params.webSide<0)?'':'on'}">Near</button><button id="spside_e${e}_f" class="${(j.params&&j.params.webSide<0)?'on':''}">Far</button></div>
1129
+ <div class=elabrow><span class=elab>Weld sides</span></div>
1130
+ <div class=seg2 style="margin-top:0"><button id="spw_e${e}_n" class="${(j.params&&j.params.weldSide==='near')?'on':''}">Near</button><button id="spw_e${e}_f" class="${(j.params&&j.params.weldSide==='far')?'on':''}">Far</button><button id="spw_e${e}_b" class="${(!j.params||!j.params.weldSide||j.params.weldSide==='both')?'on':''}">Both</button></div>
990
1131
  <div class=elab style="margin-top:7px;opacity:.7">Bolts</div>
991
- ${spF(e,j,'boltCols','Bolt columns','1','')}${spF(e,j,'boltRows','Bolt rows','3','')}${spF(e,j,'boltDia','Bolt ⌀','20','mm')}${spF(e,j,'boltPitch','Bolt spacing','70','mm')}
1132
+ ${spF(e,j,'boltCols','Bolt columns','1','')}${spF(e,j,'boltRows','Bolt rows','3','')}${spF(e,j,'boltDia','Bolt ⌀','3/4"','mm')}${spF(e,j,'boltPitch','Bolt spacing','2 3/4"','mm')}
992
1133
  <div class=elabrow><span class=elab>Bolt grade</span></div>
993
1134
  <div class=seg2 style="margin-top:0"><button id="spg_e${e}_325" class="${(j.params&&j.params.boltGrade==='A490')?'':'on'}">A325</button><button id="spg_e${e}_490" class="${(j.params&&j.params.boltGrade==='A490')?'on':''}">A490</button></div>
994
1135
  <div class=hint style="margin:4px 0 0">Bolt length auto-sizes from the grip (AISC) → shown on the bolt callout.</div>
@@ -1000,6 +1141,7 @@ function panel(){
1000
1141
  p.innerHTML=`<h3>Member ${esc(m.id)}</h3>
1001
1142
  <div class=row><label>Profile</label><div style="display:flex;gap:6px"><input id=pf class=combo data-src=profiles value="${esc(m.profile)}" style="flex:1" autocomplete=off><button id=pickProf class="ghost${(picking&&pickKind==='profile')?' on':''}" title="Pick profile by clicking a label in the drawing">⌖ pick</button></div>${(picking&&pickKind==='profile')?'<div class="hint" style="margin-top:4px;font-style:italic;color:var(--brand)">Click a profile label in the drawing…</div>':(picking&&pickKind==='detail')?'<div class="hint" style="margin-top:4px;font-style:italic;color:#a855f7">Click a detail callout in the drawing…</div>':''}</div>
1002
1143
  <div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
1144
+ <div class="seg2 mtype" id=mTypeS style="margin-top:6px" title="Member type — drives legend grouping (independent of the Beam/Column geometry above)">${MEMBER_TYPES.map(t=>`<button data-mtype="${t.k}" class="${memberTypeOf(m)===t.k?'on':''}">${t.label}</button>`).join('')}</div>
1003
1145
  <div class="row hint">Length <b>${L} ft</b> · ${wpf==null?'<span class=pill style="background:#7f1d1d">RFI — size unresolved</span>':'Weight <b>'+(len(m.wp[0],m.wp[1])/FT*wpf).toFixed(0)+' lb</b> · '+wpf+' lb/ft'}</div>
1004
1146
  <div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} title="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
1005
1147
  ${mfSug.length?`<div class="row" style="border:1px solid #a855f7;border-radius:6px;padding:7px 8px;background:rgba(168,85,247,.07)"><div class=elab style="color:#c4b5fd;margin:0">Moment-frame girder · ${_lvl==='roof'?'roof':'2nd floor'} (from Frames)</div><div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px">${mfSug.map(s=>`<button class="ghost mfsug${s===m.profile?' on':''}" data-s="${esc(s)}">${esc(s)}</button>`).join('')}</div></div>`:''}
@@ -1013,11 +1155,12 @@ function panel(){
1013
1155
  <div class=hint style="margin-top:6px">${geoMode==='el'?'Click a <b>target line</b> — another member or a grey segment. The nearest end of this member snaps to where the two lines meet (extends if short, trims if it overshoots).':geoMode==='split'?'Click a <b>point on this member</b> to cut it into two members.':'<b>Extend/Trim</b> (E) an end to meet another line · <b>Split</b> (S) at a point. <b>Esc</b> cancels.'}</div>
1014
1156
  <div class=row><button class=ghostw id=swapEnds title="Reverse the member: swap the start (${col?'bottom':'yellow'}) and end (${col?'top':'magenta'}) handles · Shortcut: P">⇄ Swap start ↔ end <span style="opacity:.55">(P)</span></button></div>
1015
1157
  <div class="row f"><button class=danger id=del>Delete member</button></div>`;
1016
- document.getElementById('pf').onchange=e=>edit(()=>{const v=e.target.value.toUpperCase().replace(/ /g,'');m.profile=v;m.rfi=(WT[v]==null);if(v&&!profs.includes(v)){profs.push(v);profs.sort();}});
1158
+ document.getElementById('pf').onchange=e=>edit(()=>{const v=e.target.value.toUpperCase().replace(/ /g,'');m.profile=v;m.rfi=(_wt(v)==null);if(v&&!profs.includes(v)){profs.push(v);profs.sort();}});
1017
1159
  document.getElementById('pickProf').onclick=()=>{if(picking&&pickKind==='profile'){picking=false;}else{picking=true;pickKind='profile';pickEnd=null;}render();};
1160
+ document.querySelectorAll('#mTypeS button').forEach(b=>b.onclick=()=>{const v=b.dataset.mtype;if(memberTypeOf(m)!==v)edit(()=>{m.memberType=v;});}); // retype → legend re-categorizes (edit() → sync3D → build3DLegend); undoable
1018
1161
  document.getElementById('rBeam').onclick=()=>{if(col)edit(()=>{m.role='beam';});};
1019
1162
  document.getElementById('rCol').onclick=()=>{if(!col)edit(()=>{m.role='column';});};
1020
- document.querySelectorAll('.mfsug').forEach(b=>b.onclick=()=>edit(()=>{m.profile=b.dataset.s;m.mf=true;m.rfi=(WT[m.profile]==null);if(!profs.includes(m.profile)){profs.push(m.profile);profs.sort();}})); // record the moment-frame provenance (drives the confidence "schedule-resolved" factor)
1163
+ document.querySelectorAll('.mfsug').forEach(b=>b.onclick=()=>edit(()=>{m.profile=b.dataset.s;m.mf=true;m.rfi=(_wt(m.profile)==null);if(!profs.includes(m.profile)){profs.push(m.profile);profs.sort();}})); // record the moment-frame provenance (drives the confidence "schedule-resolved" factor)
1021
1164
  const _gel=document.getElementById('geoEL'),_gsp=document.getElementById('geoSplit');
1022
1165
  if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};
1023
1166
  if(_gsp)_gsp.onclick=()=>{geoMode=(geoMode==='split'?null:'split');setGeo();render();};
@@ -1038,13 +1181,13 @@ function panel(){
1038
1181
  wireDet('dtC',m.col);wireConn('cnC',m.col);
1039
1182
  if(bpj){const MAXCOUNT=20, MAXDIM=100000; // bolt grid is capped (scene parts = rows×cols, so a mistyped 1e5 can't explode the renderer); dims sanity-bounded
1040
1183
  const wireBp=(key,isInt)=>{const i=document.getElementById('bp_'+key);if(!i)return;i.onchange=e=>{
1041
- const raw=e.target.value.trim().replace(',','.'); // accept a comma decimal (EU locale); Number() (unlike parseFloat) rejects trailing junk like "24mm" instead of silently truncating
1042
- let n=raw===''?NaN:Number(raw);if(isInt)n=Math.round(n);
1184
+ const raw=e.target.value.trim();
1185
+ let n;if(isInt){n=raw===''?NaN:Math.round(Number(raw.replace(',','.')));}else{const pl=parseLen(raw);n=(pl==null)?NaN:pl*25.4;} // dimensional fields are entered in ft-in/inches → store mm
1043
1186
  edit(()=>{if(!bpj.params)bpj.params={};
1044
1187
  if(isFinite(n)&&n>0)bpj.params[key]=Math.min(isInt?MAXCOUNT:MAXDIM,n);
1045
1188
  else delete bpj.params[key]; // empty / invalid / ≤0 → drop the override so the engine falls back to its default
1046
1189
  bpj.source='user';});};}; // through edit() → the param change is undoable; editing also makes the plate user-owned (survives the auto-detail "Clear")
1047
- ['plateWidth','plateDepth','thickness','boltDia','weldLeg'].forEach(k=>wireBp(k,false));['boltCols','boltRows'].forEach(k=>wireBp(k,true));
1190
+ ['plateWidth','plateDepth','thickness','boltDia','weldLeg','embedment','grout'].forEach(k=>wireBp(k,false));['boltCols','boltRows'].forEach(k=>wireBp(k,true));
1048
1191
  {const rm=document.getElementById('bpRemove');if(rm)rm.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(j=>j!==bpj);});}} // Remove is undoable
1049
1192
  }else{
1050
1193
  wireTos('tosA',m.ends[0]);
@@ -1058,7 +1201,7 @@ function panel(){
1058
1201
  // makes the plate user-owned (survives the auto-detail "Clear"). Bolt grid capped (rows×cols = parts).
1059
1202
  for(const {e,j} of spjs){const jid=j.id,MAXC=20,MAXD=100000; // jid is a JS closure var (the find key), never placed in the DOM
1060
1203
  const wsp=(key,isInt)=>{const i=document.getElementById('spf_e'+e+'_'+key);if(!i)return;i.onchange=ev=>{
1061
- const raw=(ev.target.value||'').trim().replace(',','.');let n=raw===''?NaN:Number(raw);if(isInt)n=Math.round(n);
1204
+ const raw=(ev.target.value||'').trim();let n;if(isInt){n=raw===''?NaN:Math.round(Number(raw.replace(',','.')));}else{const pl=parseLen(raw);n=(pl==null)?NaN:pl*25.4;} // dimensional fields entered in ft-in/inches → store mm
1062
1205
  edit(()=>{const jj=(C.joints||[]).find(x=>x&&x.id===jid);if(!jj)return;if(!jj.params)jj.params={};
1063
1206
  if(isFinite(n)&&n>0)jj.params[key]=Math.min(isInt?MAXC:MAXD,n);else delete jj.params[key];
1064
1207
  jj.source='user';});};};
@@ -1066,6 +1209,10 @@ function panel(){
1066
1209
  {const setSide=(s)=>edit(()=>{const jj=(C.joints||[]).find(x=>x&&x.id===jid);if(!jj)return;if(!jj.params)jj.params={};jj.params.webSide=s;jj.source='user';}); // which face of the web the plate laps
1067
1210
  const n=document.getElementById('spside_e'+e+'_n');if(n)n.onclick=()=>setSide(1);
1068
1211
  const f=document.getElementById('spside_e'+e+'_f');if(f)f.onclick=()=>setSide(-1);}
1212
+ {const setWeld=(s)=>edit(()=>{const jj=(C.joints||[]).find(x=>x&&x.id===jid);if(!jj)return;if(!jj.params)jj.params={};jj.params.weldSide=s;jj.source='user';}); // AISC fillet: which face(s) of the fin plate are welded to the support
1213
+ const wn=document.getElementById('spw_e'+e+'_n');if(wn)wn.onclick=()=>setWeld('near');
1214
+ const wf=document.getElementById('spw_e'+e+'_f');if(wf)wf.onclick=()=>setWeld('far');
1215
+ const wb=document.getElementById('spw_e'+e+'_b');if(wb)wb.onclick=()=>setWeld('both');}
1069
1216
  {const setGrade=(g)=>edit(()=>{const jj=(C.joints||[]).find(x=>x&&x.id===jid);if(!jj)return;if(!jj.params)jj.params={};jj.params.boltGrade=g;jj.source='user';}); // AISC bolt grade (length auto-sizes from grip in the engine)
1070
1217
  const a=document.getElementById('spg_e'+e+'_325');if(a)a.onclick=()=>setGrade('A325');
1071
1218
  const b=document.getElementById('spg_e'+e+'_490');if(b)b.onclick=()=>setGrade('A490');}
@@ -1076,7 +1223,7 @@ function panel(){
1076
1223
  {const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
1077
1224
  }
1078
1225
  function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const prof=addProfile;const id='m'+Date.now();const pv=snapshot();
1079
- P.members.push(ensureMeta({id,profile:prof,wp:[sg.a.slice(),sg.b.slice()],angle:sg.o,rfi:(WT[prof]==null)}));
1226
+ P.members.push(ensureMeta({id,profile:prof,wp:[sg.a.slice(),sg.b.slice()],angle:sg.o,rfi:(_wt(prof)==null)}));
1080
1227
  if(prof&&!profs.includes(prof)){profs.push(prof);profs.sort();}pushUndo(pv);render();sync3D();}
1081
1228
  // --- marquee hit-test (crossing selection): does segment ab touch rect [x0,y0,x1,y1]? ---
1082
1229
  function inRect(p,r){return p[0]>=r[0]&&p[0]<=r[2]&&p[1]>=r[1]&&p[1]<=r[3];}
@@ -1271,7 +1418,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1271
1418
  if(t.classList.contains('handle')){const id=t.dataset.mid||[...selIds][0],m=byId(id),h=+t.dataset.h;if(!m)return;buildSnap(id);
1272
1419
  drag={type:'end',h,id,anchor:m.wp[1-h].slice(),pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
1273
1420
  if(t.classList.contains('lblhot')){const prof=t.dataset.prof;
1274
- if(picking&&pickKind==='profile'&&selIds.size===1){const id=[...selIds][0];picking=false;edit(()=>{const m=byId(id);m.profile=prof;m.rfi=(WT[prof]==null);if(!profs.includes(prof)){profs.push(prof);profs.sort();}});return;}
1421
+ if(picking&&pickKind==='profile'&&selIds.size===1){const id=[...selIds][0];picking=false;edit(()=>{const m=byId(id);m.profile=prof;m.rfi=(_wt(prof)==null);if(!profs.includes(prof)){profs.push(prof);profs.sort();}});return;}
1275
1422
  if(mode==='add'){addProfile=prof;const hi=document.getElementById('addProf');if(hi)hi.value=addProfile;const ph=document.getElementById('pickHint');if(ph){ph.classList.add('pick');setTimeout(()=>ph&&ph.classList.remove('pick'),450);}return;}
1276
1423
  return;}
1277
1424
  if(t.classList.contains('dethot')){const det=t.dataset.det;
@@ -1329,7 +1476,7 @@ svg.addEventListener('pointermove',e=>{
1329
1476
  svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
1330
1477
  if(drag.type==='draw'){const a=[drag.x0,drag.y0],b=drag.cur||a;drag.line.remove();
1331
1478
  if(len(a,b)>6){const dx=Math.abs(b[0]-a[0]),dy=Math.abs(b[1]-a[1]),o=dx>3*dy?'H':(dy>3*dx?'V':'D');
1332
- const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(WT[addProfile]==null)}));
1479
+ const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(_wt(addProfile)==null)}));
1333
1480
  if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}pushUndo(pv);}
1334
1481
  drag=null;render();return;}
1335
1482
  if(drag.type==='marquee'){if(!drag.add){selIds.clear();selDimIds.clear();}
@@ -1338,8 +1485,8 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
1338
1485
  drag.rect.remove();drag=null;render();return;}
1339
1486
  if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
1340
1487
  function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
1341
- document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();}; // entering add/sel disarms the Dimension + set-axes tools (else they keep eating canvas clicks) and drops any dim selection
1342
- document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();};
1488
+ document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();setLastCmd('Add member',()=>{if(mode!=='add'){if(dimMode){dimMode=false;setDimMode();}mode='add';selIds.clear();setMode();render();}});}; // entering add/sel disarms the Dimension + set-axes tools (else they keep eating canvas clicks) and drops any dim selection
1489
+ document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();setLastCmd('Dimension',()=>{if(!dimMode){dimMode=true;setDimMode();render();}});};
1343
1490
  document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
1344
1491
  document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
1345
1492
  function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
@@ -1373,24 +1520,26 @@ addEventListener('keydown',e=>{
1373
1520
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
1374
1521
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
1375
1522
  if(!view3d&&!inForm&&(((e.key==='z'||e.key==='Z')&&e.altKey)||(e.key===' '&&e.shiftKey))){e.preventDefault();zoomToSelection();return;} // 2D zoom-to-selected (Tekla Shift+Space / Alt+Z); 3D handles its own (steel-3d-view onKey)
1523
+ if((e.key===' '||e.key==='Enter')&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&!e.shiftKey){if(!anyToolActive()&&lastCmd){e.preventDefault();repeatLast();}return;} // idle Space/Enter → repeat the last command (AutoCAD/Tekla)
1376
1524
  if(e.key==='Escape'&&csaxisMode){if(csDraft){csDraft=null;csPrevClear();}else{csaxisMode=false;setCsMode();}render();return;} // 1st Esc drops the in-progress origin, 2nd exits set-axes
1377
1525
  if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
1378
1526
  if(e.key==='Escape'&&dimMode){if(dimChainPrev){dimChainPrev=null;dimPrevClear();}else if(dimDraft){dimDraft=null;dimPrevClear();}else{dimMode=false;setDimMode();}render();return;} // Esc: end the chain → drop the in-progress dim → exit the tool
1379
1527
  if(e.key==='Escape'&&selDimIds.size){if(dimSplitMode){dimSplitMode=false;snapClear();render();}else{selDimIds.clear();render();}return;} // Esc on a selected dim: end split mode → else deselect
1380
1528
  if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
1381
- if((e.key==='Delete'||e.key==='Backspace')&&view3d&&sel3dDimId&&!selIds.size&&!inForm){e.preventDefault();view3dApi.onDeleteDim3d(sel3dDimId);return;} // 3D: Delete removes the selected 3D dim — only when no member is selected (selection is exclusive, so a stale dim id never shadows a member delete)
1529
+ if((e.key==='Delete'||e.key==='Backspace')&&view3d&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length&&!selIds.size&&!sel3dDimIds.size&&!selDimIds.size&&!inForm){e.preventDefault();window.Steel3DView.deleteSelectedClips();return;} // 3D: Del removes the selected clip plane(s)/box(es) — only when nothing else is selected (clip selection is exclusive)
1530
+ if((e.key==='Delete'||e.key==='Backspace')&&view3d&&sel3dDimIds.size&&!selIds.size&&!inForm){e.preventDefault();[...sel3dDimIds].forEach(id=>view3dApi.onDeleteDim3d(id));return;} // 3D: Delete removes ALL selected 3D dims — only when no member is selected
1382
1531
  if((e.key==='Delete'||e.key==='Backspace')&&(selDimIds.size||selIds.size)&&!inForm){e.preventDefault();edit(()=>{ // deletes everything selected — dims and/or members (a marquee can hold both)
1383
1532
  if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
1384
1533
  if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
1385
- if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'&&!view3d){e.preventDefault();dimMode=!dimMode;setDimMode();render();return;} // D — toggle the Dimension tool
1534
+ if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'&&!view3d){e.preventDefault();dimMode=!dimMode;setDimMode();render();setLastCmd('Dimension',()=>{if(!dimMode){dimMode=true;setDimMode();render();}});return;} // D — toggle the Dimension tool
1386
1535
  if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
1387
1536
  if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
1388
1537
  if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
1389
1538
  if(!inForm&&selDimIds.size>=1&&!selIds.size&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='s'){e.preventDefault();toggleDimSplit();return;} // S — toggle "add split point" on the selected dimension(s)
1390
1539
  if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
1391
- if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
1540
+ if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();setLastCmd('Extend/Trim',()=>{geoMode='el';setGeo();render();});return;} // Extend/Trim — any selection
1392
1541
  if(kk==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
1393
- if(kk==='s'&&selIds.size===1&&!selDimIds.size){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}} // member Split — single member only, and not while dims are also selected (mixed → ambiguous, so neither split fires)
1542
+ if(kk==='s'&&selIds.size===1&&!selDimIds.size){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();setLastCmd('Split',()=>{geoMode='split';setGeo();render();});return;}} // member Split — single member only, and not while dims are also selected (mixed → ambiguous, so neither split fires)
1394
1543
  if(!(e.ctrlKey||e.metaKey))return;const k=e.key.toLowerCase();
1395
1544
  if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
1396
1545
  else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
@@ -1413,12 +1562,12 @@ moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();
1413
1562
  // fetches the SAME scene the bake uses (/api/contract/:id/scene) so 2D and 3D show one contract. ---
1414
1563
  // Coped beams (auto, clash-driven) — cache the cut labels per member as the scene is fetched so the
1415
1564
  // member Inspector can show a read-only "Cope (auto)" note (a coped beam otherwise looks like an error).
1416
- let copesByMember={};
1565
+ let copesByMember={}, partsById={};
1417
1566
  const view3dApi={
1418
1567
  // POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
1419
- fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');const cm={};for(const el of ((j.scene&&j.scene.elements)||[])){if(el&&el.kind==='cut'&&el.member){(cm[el.member]||(cm[el.member]=[])).push((el.meta&&el.meta.label)||'Cope');}}copesByMember=cm;return j.scene;}, // also cache cope labels per member for the Inspector
1420
- onSelect:(id,additive)=>{selDimIds.clear();sel3dDimId=null;if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D pick → shared selection (clears 2D + 3D dim selection so Delete is unambiguous)
1421
- onSelectMany:(ids)=>{selDimIds.clear();sel3dDimId=null;selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D box-select → shared selection
1568
+ fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');const cm={},pb={};for(const el of ((j.scene&&j.scene.elements)||[])){if(!el)continue;if(el.id)pb[el.id]=el;if(el.kind==='cut'&&el.member){(cm[el.member]||(cm[el.member]=[])).push((el.meta&&el.meta.label)||'Cope');}}copesByMember=cm;partsById=pb;return j.scene;}, // cache resolved parts by id (real dims for the part Inspector, not just "auto") AND cope labels per member (the coped-beam note)
1569
+ onSelect:(id,additive)=>{selDimIds.clear();sel3dDimIds.clear();if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D pick → shared selection (clears 2D + 3D dim selection so Delete is unambiguous)
1570
+ onSelectMany:(ids)=>{selDimIds.clear();sel3dDimIds.clear();selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D box-select → shared selection
1422
1571
  getMembers:()=>P.members, // raw members (wp) for snap geometry
1423
1572
  ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
1424
1573
  defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
@@ -1431,44 +1580,261 @@ const view3dApi={
1431
1580
  onTrimExtend:(targetId)=>{const tm=byId(targetId);if(tm){geoMode=null;setGeo();snapEndMulti(selArr(),tm.wp[0],tm.wp[1]);}}, // 3D Trim/Extend → every selected member's nearest end to the target line
1432
1581
  onSplit:(id,wp)=>{const m=byId(id);if(m){doSplit(m,wp);sync3D();}}, // 3D Split → cut at the clicked point on the member
1433
1582
  getDims3d:()=>C.dims3d||[],
1583
+ getDimOverlays:()=>C.dim_overlays||{}, // the legend DIMENSIONS toggles {bolt_pitch,edge_clearance,cope_size} — the 3D view derives + filters overlays from this
1434
1584
  // dims3d is model-global: mutate + persist directly. NOT via edit() — a plan-scoped undo snapshot would corrupt dims3d across plans (undo on plan A could wipe dims added on plan B), and edit()'s sync3D would needlessly re-extrude every member mesh for a pure annotation. boot() guarantees C.dims3d is an array.
1435
1585
  onAddDim3d:(dim)=>{C.dims3d.push(dim);scheduleSave();refreshDims3d();},
1436
- onDeleteDim3d:(id)=>{C.dims3d=(C.dims3d||[]).filter(x=>x.id!==id);if(sel3dDimId===id)sel3dDimId=null;scheduleSave();refreshDims3d();},
1437
- onSelectDim3d:(id)=>{sel3dDimId=id||null;if(id){selIds=new Set();selDimIds.clear();}render();}, // selecting a 3D dim is exclusive: clears member + 2D-dim selection (and updates the 3D member highlight via render)
1438
- selDim3d:()=>sel3dDimId,
1586
+ onDeleteDim3d:(id)=>{C.dims3d=(C.dims3d||[]).filter(x=>x.id!==id);sel3dDimIds.delete(id);scheduleSave();refreshDims3d();},
1587
+ onSelectDim3d:(id,mods)=>{ // Ctrl/Shift multi-select (same as parts); selecting a dim is exclusive with member/2D-dim/clip selection
1588
+ if(!id){sel3dDimIds.clear();render();return;}
1589
+ selIds=new Set();selDimIds.clear();if(window.Steel3DView&&window.Steel3DView.setSelectedClips)window.Steel3DView.setSelectedClips([]);
1590
+ const ids=(C.dims3d||[]).map(d=>d.id),cur=new Set(sel3dDimIds);let next;
1591
+ if(mods&&mods.shift&&dim3dAnchor!=null&&ids.includes(dim3dAnchor)&&ids.includes(id)){const i0=ids.indexOf(dim3dAnchor),i1=ids.indexOf(id);next=ids.slice(Math.min(i0,i1),Math.max(i0,i1)+1);}
1592
+ else if(mods&&(mods.ctrl||mods.meta)){next=new Set(cur);next.has(id)?next.delete(id):next.add(id);next=[...next];dim3dAnchor=id;}
1593
+ else{next=[id];dim3dAnchor=id;}
1594
+ sel3dDimIds=new Set(next);render();},
1595
+ selDim3d:()=>[...sel3dDimIds],
1439
1596
  newDim3dId:()=>'d3'+Date.now()+'_'+(dimSeq++), // same scheme as 2D dim ids (Date.now + dimSeq tie-breaker)
1440
1597
  fmtLen:(mm)=>fmtFtIn(mm/304.8*12),
1598
+ onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
1599
+ beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
1600
+ 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)
1601
+ onIsolateChange:()=>{updateIsolateBtn();refresh3DLegend();}, // isolate / show-all (incl. Esc, legend "show all", showAllGroups clearing connHidden) → refresh the toolbar button + the legend rows' hidden state
1602
+ onWorkAreaChange:()=>updateWorkBtn(), // work area set / toggled → refresh the button + its checkbox
1441
1603
  };
1442
1604
  // Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
1443
1605
  // changes go through render()'s setSelection — only geometry mutations need a rebuild.
1444
1606
  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)
1445
1607
  // Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
1446
1608
  // double-click isolate — mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
1447
- let leg3dClickT=null;
1609
+ let leg3dClickT=null,legendAnchor=null;
1610
+ // Explorer-style multi-isolate on dbl-click of a legend group row: plain = isolate just this group; Ctrl = toggle
1611
+ // it in/out of the isolated set; Shift = the contiguous range from the anchor row to this one (in displayed order).
1612
+ function legendIsolate(k,e){
1613
+ if(!window.Steel3DView)return;
1614
+ const keys=[...document.querySelectorAll('#m3dLegend .lrow[data-key]')].map(r=>r.dataset.key); // group rows in displayed order
1615
+ const cur=new Set(window.Steel3DView.groupState().solo);
1616
+ let next;
1617
+ if(e.shiftKey&&legendAnchor!=null&&keys.includes(legendAnchor)&&keys.includes(k)){
1618
+ const i0=keys.indexOf(legendAnchor),i1=keys.indexOf(k);next=keys.slice(Math.min(i0,i1),Math.max(i0,i1)+1); // range from the anchor (anchor unchanged, like Explorer)
1619
+ }else if(e.ctrlKey||e.metaKey){
1620
+ next=new Set(cur);next.has(k)?next.delete(k):next.add(k);next=[...next];legendAnchor=k; // toggle this group in/out
1621
+ }else{next=[k];legendAnchor=k;} // plain: isolate only this group
1622
+ window.Steel3DView.setSoloGroups(next);refresh3DLegend();
1623
+ }
1624
+ let clipAnchor=null;
1625
+ // Clip-list selection — same multi-select as parts/dims: plain = select only this clip; Ctrl = toggle it in/out;
1626
+ // Shift = the range from the anchor (in list order). Selecting a clip clears member/dim selection (exclusive).
1627
+ function clipSelect(id,e){
1628
+ if(!window.Steel3DView)return;
1629
+ if(selIds.size||sel3dDimIds.size||(typeof selDimIds!=='undefined'&&selDimIds.size)){selIds=new Set();sel3dDimIds.clear();if(typeof selDimIds!=='undefined')selDimIds.clear();render();}
1630
+ const ids=window.Steel3DView.getClips().map(c=>c.id),cur=new Set(window.Steel3DView.selectedClips());
1631
+ let next;
1632
+ if(e.shiftKey&&clipAnchor!=null&&ids.includes(clipAnchor)&&ids.includes(id)){
1633
+ const i0=ids.indexOf(clipAnchor),i1=ids.indexOf(id);next=ids.slice(Math.min(i0,i1),Math.max(i0,i1)+1);
1634
+ }else if(e.ctrlKey||e.metaKey){
1635
+ next=new Set(cur);next.has(id)?next.delete(id):next.add(id);next=[...next];clipAnchor=id;
1636
+ }else{next=(cur.size===1&&cur.has(id))?[]:[id];clipAnchor=id;} // plain click toggles single select
1637
+ window.Steel3DView.setSelectedClips(next);
1638
+ }
1639
+ // Inline-rename a clip from the legend: double-click the label → a themed input; Enter/blur commits (rejected
1640
+ // with a toast if the name is empty or already used), Esc cancels. Rename is undoable (renameClip pushes it).
1641
+ function startClipRename(c,labEl){
1642
+ const inp=document.createElement('input');inp.value=c.label;
1643
+ inp.style.cssText='flex:1;min-width:0;font:12px system-ui;background:#0b1220;color:var(--text);border:1px solid var(--brand);border-radius:4px;padding:0 4px;outline:none';
1644
+ labEl.replaceWith(inp);inp.focus();inp.select();
1645
+ let done=false;
1646
+ const finish=(save)=>{if(done)return;done=true;
1647
+ if(save){const nm=inp.value.trim();if(nm&&nm!==c.label){if(window.Steel3DView.renameClip(c.id,nm))return;toast('That clip name is already in use');}} // success → renameClip rebuilt the legend
1648
+ build3DLegend();}; // cancel / no change / name-taken → restore the original row
1649
+ inp.addEventListener('keydown',ev=>{ev.stopPropagation();if(ev.key==='Enter'){ev.preventDefault();finish(true);}else if(ev.key==='Escape'){ev.preventDefault();finish(false);}});
1650
+ inp.addEventListener('blur',()=>finish(true));
1651
+ }
1448
1652
  // Connection-part group keys, in assembly reading order (plate → fasteners → weld). Used to split the
1449
1653
  // legend into a MEMBERS list and a CONNECTIONS section so the steel parts don't blur into one long list.
1450
- const CONN_ORDER=['base-plate','washer-plate','anchor','nut','washer','weld'];
1654
+ const CONN_ORDER=['base-plate','shear-plate','washer-plate','stiffener','anchor','bolt','nut','washer','weld']; // plates → secondary plates → fastener rods → hardware → weld (so shear-plate parts file under Connections, not Members)
1655
+ // Member-type categories (Phase 1): a grouping label per member, defaulting from its structural role. The
1656
+ // legend can group members by TYPE (these categories) or by PROFILE (the W-size). memberType is independent
1657
+ // of role (role drives geometry — column=vertical); it only drives legend grouping, so it round-trips on the
1658
+ // member as a plain field (persist() saves members wholesale).
1659
+ const MEMBER_TYPES=[{k:'column',label:'Column'},{k:'beam',label:'Beam'},{k:'brace',label:'Brace'},{k:'ledger',label:'Ledger'},{k:'frame',label:'Frame'}];
1660
+ const MTYPE_LABEL=Object.fromEntries(MEMBER_TYPES.map(t=>[t.k,t.label]));
1661
+ function memberTypeOf(m){return (m&&MTYPE_LABEL[m.memberType])?m.memberType:((m&&m.role==='column')?'column':'beam');} // default Column for a column role, else Beam
1662
+ function profileKeyOf(m){return (m&&m.profile||'').trim().toUpperCase();} // matches the scene group key (contract-to-scene upper-cases the profile)
1663
+ function categoryOfProfile(profKey){for(const m of (P.members||[]))if(profileKeyOf(m)===profKey)return memberTypeOf(m);return 'beam';} // a profile-group's category = the type of its member(s)
1664
+ let legendMode=(localStorage.getItem('floless.legendMode')==='type')?'type':'profile';
1665
+ let collapsedCats=new Set((()=>{try{return JSON.parse(localStorage.getItem('floless.legendCollapsed')||'[]');}catch{return [];}})());
1666
+ function saveLegendPrefs(){try{localStorage.setItem('floless.legendMode',legendMode);localStorage.setItem('floless.legendCollapsed',JSON.stringify([...collapsedCats]));}catch{}}
1667
+ // Drag a typed member row onto another type category to retype it. Pointer Events (NOT the HTML5 drag API,
1668
+ // which paints a white browser ghost on Windows). A 6px threshold tells a drag from the row's click(hide) /
1669
+ // dbl-click(isolate); the drag handle is the only initiator.
1670
+ function wireRowDrag(row,g){const handle=row.querySelector('.drag-handle');if(!handle)return;
1671
+ handle.addEventListener('pointerdown',e=>{e.preventDefault();e.stopPropagation();
1672
+ const sx=e.clientX,sy=e.clientY;let ghost=null,started=false,target=null;
1673
+ const move=ev=>{
1674
+ if(!started){if(Math.hypot(ev.clientX-sx,ev.clientY-sy)<6)return;started=true;row._dragging=true;clearTimeout(leg3dClickT); // crossed the threshold → it's a drag, not a click
1675
+ ghost=document.createElement('div');ghost.className='leg-drag-ghost';const sw=document.createElement('span');sw.className='lsw';sw.style.background=g.color;ghost.append(sw,document.createTextNode(g.label));document.body.appendChild(ghost);}
1676
+ ghost.style.transform=`translate(${ev.clientX+12}px,${ev.clientY-14}px)`;
1677
+ const elp=document.elementFromPoint(ev.clientX,ev.clientY);const hdr=elp&&elp.closest?elp.closest('#m3dLegend .cat-hdr'):null;
1678
+ if(hdr!==target){if(target)target.classList.remove('drop-target');target=hdr;if(target)target.classList.add('drop-target');}};
1679
+ const up=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);
1680
+ if(ghost)ghost.remove();if(target)target.classList.remove('drop-target');
1681
+ const drop=started&&target?target.dataset.cat:null;setTimeout(()=>{row._dragging=false;},0); // keep _dragging set through this tick's click
1682
+ if(drop)retypeProfile(g.key,drop);};
1683
+ document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);});}
1684
+ // Retype every member of a profile-group to `cat` (undoable); the legend re-categorises on rebuild + flashes the moved row.
1685
+ function retypeProfile(profKey,cat){if(!MTYPE_LABEL[cat])return;
1686
+ const targets=(P.members||[]).filter(m=>profileKeyOf(m)===profKey);
1687
+ if(!targets.length||targets.every(m=>memberTypeOf(m)===cat))return; // already that type → no-op (no empty undo step)
1688
+ edit(()=>{for(const m of targets)m.memberType=cat;});
1689
+ setTimeout(()=>{const r=[...document.querySelectorAll('#m3dLegend .lrow[data-key]')].find(r=>r.dataset.key===profKey);if(r){r.classList.add('flash');setTimeout(()=>r.classList.remove('flash'),400);}},250);}
1690
+ // Connection categories ARE the joints (Phase 2): every part of a joint — including its own nuts/washers/welds
1691
+ // — files under that connection. A part's connection = the joint its id prefixes (e.g. "bp-c1:weld" → bp-c1 →
1692
+ // base-plate). Shared part-kinds are split per-connection by hiding the actual part IDS (setIdsHidden), since
1693
+ // the "weld"/"nut" GROUP spans both joints. Unknown joints fall under "Other".
1694
+ const CONN_TYPES=[{k:'base-plate',label:'Base-plate'},{k:'shear-plate',label:'Shear-plate'}];
1695
+ function connTypeOf(partId){const jid=String(partId||'').split(':')[0];const j=(C.joints||[]).find(x=>x&&x.id===jid);return j?j.kind:'other';}
1696
+ const DIM_CATS=[['bolt_pitch','Bolt pitch / gauge'],['edge_clearance','Edge clearance'],['cope_size','Cope size'],['base_plate','Base-plate anchors'],['anchor_depth','Anchor projection / embedment']];
1697
+ const DIM_LABEL=Object.fromEntries(DIM_CATS);
1698
+ // Dimension overlays grouped by the connection they annotate (a middle category under Dimensions). bolt_pitch/
1699
+ // edge_clearance/cope_size come off the shear-plate fin plate + cope; base_plate/anchor_depth off the base plate.
1700
+ const DIM_CONN=[{ct:'base-plate',label:'Base-plate',cats:['base_plate','anchor_depth']},{ct:'shear-plate',label:'Shear-plate',cats:['bolt_pitch','edge_clearance','cope_size']}];
1451
1701
  function build3DLegend(){const host=document.getElementById('m3dLegend');if(!host||!window.Steel3DView)return;
1452
1702
  const groups=window.Steel3DView.getGroups();host.replaceChildren();
1453
1703
  if(!groups.length){host.style.display='none';return;}
1454
- const hint=document.createElement('div');hint.className='lhint';hint.textContent='click: hide/show · dbl-click: isolate';host.appendChild(hint);
1455
- const addRow=g=>{const row=document.createElement('div');row.className='lrow';row.dataset.key=g.key;
1704
+ const hint=document.createElement('div');hint.className='lhint';hint.textContent='click hide/show · dbl-click isolate · Ctrl/Shift multi';host.appendChild(hint);
1705
+ const addRow=(g,indent,draggable)=>{const row=document.createElement('div');row.className='lrow'+(indent?' typed':'');row.dataset.key=g.key;
1706
+ if(draggable){const dh=document.createElement('span');dh.className='drag-handle';dh.textContent='⠿';dh.title='Drag onto another type';['click','dblclick'].forEach(ev=>dh.addEventListener(ev,e=>e.stopPropagation()));row.appendChild(dh);} // handle = the only drag initiator; swallow its own clicks so it never toggles the row
1456
1707
  const sw=document.createElement('span');sw.className='lsw';sw.style.background=g.color;
1457
1708
  row.append(sw,document.createTextNode(g.label));
1458
- row.addEventListener('click',()=>{clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
1459
- row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);window.Steel3DView.soloToggle(g.key);refresh3DLegend();});
1460
- host.appendChild(row);};
1709
+ row.addEventListener('click',()=>{if(row._dragging)return;clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
1710
+ row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);legendIsolate(g.key,e);});
1711
+ if(draggable)wireRowDrag(row,g);
1712
+ host.appendChild(row);return row;};
1713
+ // A collapsible legend category: chevron (collapse) + tri-state master on/off (■/□/◪) + label + count.
1714
+ // getState()→'on'|'off'|'mixed' drives the master glyph; onToggle() runs the master action (refresh follows).
1715
+ const buildCatHeader=(cat,label,count,opts)=>{opts=opts||{};const hdr=document.createElement('div');hdr.className='cat-hdr'+(opts.empty?' empty':'')+(opts.sub?' sub':'');hdr.dataset.cat=cat;hdr._getState=opts.getState;
1716
+ const chev=Object.assign(document.createElement('span'),{className:'cat-chevron',textContent:collapsedCats.has(cat)?'▶':'▼'});
1717
+ const tog=Object.assign(document.createElement('span'),{className:'cat-tog',title:opts.toggleTitle||('Show / hide all '+label.toLowerCase())});if(opts.empty||!opts.onToggle)tog.style.display='none';
1718
+ const lab=Object.assign(document.createElement('span'),{className:'cat-label',textContent:label});
1719
+ const cnt=Object.assign(document.createElement('span'),{className:'cat-count',textContent:'('+count+')'});
1720
+ hdr.append(chev,tog,lab,cnt);
1721
+ hdr.addEventListener('click',e=>{if(e.target===tog)return;if(collapsedCats.has(cat))collapsedCats.delete(cat);else collapsedCats.add(cat);saveLegendPrefs();build3DLegend();});
1722
+ if(opts.onToggle)tog.addEventListener('click',e=>{e.stopPropagation();opts.onToggle();refresh3DLegend();});
1723
+ updateCatTog(hdr);return hdr;};
1724
+ // master state/action over a set of part-group keys (members + connection categories share this)
1725
+ const grpState=keys=>{const h=new Set(window.Steel3DView.groupState().hidden);const n=keys.filter(k=>h.has(k)).length;return n===0?'on':(n===keys.length?'off':'mixed');};
1726
+ const grpToggle=keys=>{const h=new Set(window.Steel3DView.groupState().hidden);window.Steel3DView.setGroupsHidden(keys,keys.every(k=>!h.has(k)));}; // all-on → hide all; else show all
1461
1727
  const members=groups.filter(g=>!CONN_ORDER.includes(g.key));
1462
1728
  const conns=groups.filter(g=>CONN_ORDER.includes(g.key)).sort((a,b)=>CONN_ORDER.indexOf(a.key)-CONN_ORDER.indexOf(b.key));
1463
- for(const g of members)addRow(g);
1464
- if(conns.length){
1729
+ if(members.length){ // By profile ⇄ By type toggle, above the hint
1730
+ const mode=document.createElement('div');mode.className='lmode';
1731
+ for(const [mk,lbl] of [['profile','By profile'],['type','By type']]){const b=document.createElement('button');b.textContent=lbl;if(legendMode===mk)b.classList.add('on');
1732
+ b.onclick=()=>{if(legendMode===mk)return;legendMode=mk;if(mk==='type'&&window.Steel3DView.groupState().solo.length)window.Steel3DView.showAllGroups();saveLegendPrefs();build3DLegend();};
1733
+ mode.appendChild(b);}
1734
+ host.insertBefore(mode,host.firstChild);
1735
+ }
1736
+ if(legendMode==='type'&&members.length){ // group the profile-rows under their member-type categories
1737
+ const byCat=new Map(MEMBER_TYPES.map(t=>[t.k,[]]));
1738
+ for(const g of members){(byCat.get(categoryOfProfile(g.key))||byCat.get('beam')).push(g);}
1739
+ for(const {k,label} of MEMBER_TYPES){const gs=byCat.get(k)||[],keys=gs.map(g=>g.key);
1740
+ host.appendChild(buildCatHeader(k,label,gs.length,{empty:!gs.length,getState:()=>grpState(keys),onToggle:()=>grpToggle(keys),toggleTitle:'Show / hide all '+label.toLowerCase()+'s'}));
1741
+ if(!collapsedCats.has(k))for(const g of gs)addRow(g,true,true);}
1742
+ } else for(const g of members)addRow(g);
1743
+ if(conns.length){ // group connection PARTS under their joint (Phase 2): each part-kind a row, hidden per-id
1465
1744
  if(members.length){host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));}
1466
1745
  host.appendChild(Object.assign(document.createElement('div'),{className:'lsec',textContent:'Connections'}));
1467
- for(const g of conns)addRow(g);
1746
+ const meta=new Map(conns.map(g=>[g.key,g])); // part-kind → {label,color}
1747
+ const connParts=Object.values(partsById||{}).filter(e=>e&&e.id&&CONN_ORDER.includes(e.group));
1748
+ if(!connParts.length){for(const g of conns)addRow(g);} // scene parts not cached yet → flat fallback (connections never vanish)
1749
+ else{
1750
+ const byConn=new Map(); // connType → Map(part-kind → [ids])
1751
+ for(const e of connParts){const ct=connTypeOf(e.id);if(!byConn.has(ct))byConn.set(ct,new Map());const pk=byConn.get(ct);if(!pk.has(e.group))pk.set(e.group,[]);pk.get(e.group).push(e.id);}
1752
+ const hiddenSet=()=>new Set(window.Steel3DView.connHiddenIds());
1753
+ const order=[...CONN_TYPES.map(t=>t.k),...[...byConn.keys()].filter(k=>!CONN_TYPES.some(t=>t.k===k))];
1754
+ for(const ct of order){const pk=byConn.get(ct);if(!pk||!pk.size)continue;
1755
+ const allIds=[].concat(...[...pk.values()]),label=(CONN_TYPES.find(t=>t.k===ct)||{}).label||'Other',ck='conn-'+ct;
1756
+ host.appendChild(buildCatHeader(ck,label,pk.size,{
1757
+ getState:()=>{const h=hiddenSet(),n=allIds.filter(id=>h.has(id)).length;return n===0?'on':(n===allIds.length?'off':'mixed');},
1758
+ onToggle:()=>{const h=hiddenSet();window.Steel3DView.setIdsHidden(allIds,allIds.every(id=>!h.has(id)));}, // all-on → hide all; else show all
1759
+ toggleTitle:'Show / hide the '+label.toLowerCase()+' connection'}));
1760
+ if(!collapsedCats.has(ck))for(const [grp,ids] of pk){const m=meta.get(grp)||{label:grp,color:'#94a3b8'};
1761
+ const row=document.createElement('div');row.className='lrow typed';row.dataset.connkey=ck+':'+grp;row._ids=ids;
1762
+ const sw=document.createElement('span');sw.className='lsw';sw.style.background=m.color;row.append(sw,document.createTextNode(m.label));
1763
+ row.addEventListener('click',()=>{const h=hiddenSet();window.Steel3DView.setIdsHidden(ids,!ids.every(id=>h.has(id)));refresh3DLegend();}); // click toggles just this connection's parts of this kind
1764
+ host.appendChild(row);}
1765
+ }
1766
+ }
1767
+ }
1768
+ // DIMENSIONS — a DIFFERENT axis of toggle from the part rows above: each row shows/hides a derived dim
1769
+ // OVERLAY (cyan annotation), not a part. Outline swatch, its own hint, single-click only (no dbl-click
1770
+ // isolate), and a gentler off-state — off is a normal resting choice here, not a hidden-part warning.
1771
+ const ov=C.dim_overlays||{};
1772
+ const addDimRow=(cat,label,sub)=>{const row=document.createElement('div');row.className='lrow dim typed'+(sub?' sub':'');row.dataset.dim=cat;
1773
+ const sw=document.createElement('span');sw.className='lsw';
1774
+ row.append(sw,document.createTextNode(label));
1775
+ row.classList.toggle('dimoff',ov[cat]===false);
1776
+ // toggle the overlay; persist DIRECTLY (model-global, like dims3d — never via edit(), which would snapshot a per-plan undo)
1777
+ row.addEventListener('click',()=>{const on=C.dim_overlays[cat]!==false;C.dim_overlays[cat]=!on;row.classList.toggle('dimoff',on);scheduleSave();refreshOverlayDims3d();});
1778
+ host.appendChild(row);};
1779
+ if(members.length||conns.length)host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
1780
+ const dimState=()=>{const on=DIM_CATS.filter(([k])=>C.dim_overlays[k]!==false).length;return on===0?'off':(on===DIM_CATS.length?'on':'mixed');};
1781
+ const dimToggle=()=>{const anyOn=DIM_CATS.some(([k])=>C.dim_overlays[k]!==false);for(const [k] of DIM_CATS)C.dim_overlays[k]=!anyOn;scheduleSave();refreshOverlayDims3d();build3DLegend();};
1782
+ const dcState=cats=>{const on=cats.filter(k=>C.dim_overlays[k]!==false).length;return on===0?'off':(on===cats.length?'on':'mixed');};
1783
+ const dcToggle=cats=>{const anyOn=cats.some(k=>C.dim_overlays[k]!==false);for(const k of cats)C.dim_overlays[k]=!anyOn;scheduleSave();refreshOverlayDims3d();build3DLegend();};
1784
+ host.appendChild(buildCatHeader('dims','Dimensions',DIM_CATS.length,{getState:dimState,onToggle:dimToggle,toggleTitle:'Show / hide all dimension overlays'}));
1785
+ if(!collapsedCats.has('dims')){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'click: show / hide'}));
1786
+ for(const dc of DIM_CONN){const ck='dims-'+dc.ct; // middle category: overlays grouped by connection
1787
+ host.appendChild(buildCatHeader(ck,dc.label,dc.cats.length,{sub:true,getState:()=>dcState(dc.cats),onToggle:()=>dcToggle(dc.cats),toggleTitle:'Show / hide all '+dc.label.toLowerCase()+' dimensions'}));
1788
+ if(!collapsedCats.has(ck))for(const k of dc.cats)addDimRow(k,DIM_LABEL[k],true);}
1789
+ }
1790
+ // CLIP — the active clip planes/boxes (a third axis: each HIDES geometry beyond it). Click a row to enable/
1791
+ // disable it (eye-style, like a hidden part); the × deletes. Empty state reads "(no clips)" so the section
1792
+ // looks available, not broken. Rebuilt by api.onClipsChange whenever a clip is added/removed/toggled.
1793
+ const clips=window.Steel3DView.getClips();
1794
+ host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
1795
+ const clipState=()=>{if(!clips.length)return 'off';const on=clips.filter(c=>c.enabled).length;return on===0?'off':(on===clips.length?'on':'mixed');};
1796
+ const clipToggle=()=>{const anyOn=clips.some(c=>c.enabled);for(const c of clips){if(!!c.enabled===anyOn)window.Steel3DView.toggleClip(c.id);}}; // all-on → disable all; else enable all (onClipsChange rebuilds)
1797
+ host.appendChild(buildCatHeader('clip','Clip',clips.length,{empty:!clips.length,getState:clipState,onToggle:clips.length?clipToggle:null,toggleTitle:'Enable / disable all clips'}));
1798
+ if(collapsedCats.has('clip')){/* collapsed → no rows */}
1799
+ else if(!clips.length){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'(no clips)'}));}
1800
+ else for(const c of clips){
1801
+ // Three separate zones (no fighting): swatch/label = SELECT (reveals its 3D drag handles), On/Off pill = ENABLE, × = DELETE.
1802
+ const row=document.createElement('div');row.className='lrow clip typed'+(c.selected?' sel':''); // enable state is shown by the On/Off pill, not by dimming the row
1803
+ const sw=document.createElement('span');sw.className='lsw';sw.style.background=c.kind==='box'?'#93c5fd':'#3b82f6';sw.title='Select — show its drag handles in 3D'; // box = lighter blue, plane = brand blue
1804
+ const lab=document.createElement('span');lab.className='clab';lab.textContent=c.label;lab.title='Click to select · double-click to rename';
1805
+ const tog=document.createElement('button');tog.className='cpill'+(c.enabled?' on':'');tog.textContent=c.enabled?'On':'Off';tog.title='Enable / disable this clip';
1806
+ const x=document.createElement('span');x.className='lx';x.textContent='×';x.title='Delete this clip';
1807
+ row.append(sw,lab,tog,x);
1808
+ sw.addEventListener('click',e=>{e.stopPropagation();clipSelect(c.id,e);}); // Ctrl/Shift = multi-select (same as parts/dims)
1809
+ let clipClickT=null;
1810
+ lab.addEventListener('click',e=>{e.stopPropagation();clearTimeout(clipClickT);const ev={ctrlKey:e.ctrlKey,metaKey:e.metaKey,shiftKey:e.shiftKey};clipClickT=setTimeout(()=>clipSelect(c.id,ev),200);}); // deferred so a double-click (rename) can cancel the select
1811
+ lab.addEventListener('dblclick',e=>{e.stopPropagation();e.preventDefault();clearTimeout(clipClickT);startClipRename(c,lab);});
1812
+ tog.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.toggleClip(c.id);});
1813
+ x.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.removeClip(c.id);});
1814
+ host.appendChild(row);
1468
1815
  }
1469
1816
  host.style.display='flex';refresh3DLegend();}
1470
- function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3DView.groupState(),hidden=new Set(st.hidden);
1471
- document.querySelectorAll('#m3dLegend .lrow').forEach(r=>{const k=r.dataset.key;r.classList.toggle('off',hidden.has(k)||(st.solo!==null&&st.solo!==k));});}
1817
+ // The contextual Isolate / Show all toolbar button: visible when something's selected OR while isolated (so
1818
+ // "Show all" stays reachable after the selection is cleared). Updated on selection change + via onIsolateChange.
1819
+ function updateIsolateBtn(){const b=document.getElementById('m3dIso');if(!b||!window.Steel3DView||!window.Steel3DView.isIsolated)return;
1820
+ const iso=window.Steel3DView.isIsolated();
1821
+ b.style.display=(iso||selIds.size>0)?'':'none';
1822
+ b.classList.toggle('on',iso);
1823
+ b.textContent=iso?'Show all':'Isolate';
1824
+ b.setAttribute('data-tip', iso?'Restore all hidden members':'Isolate selected — hide everything else (Esc to exit)');} // themed tooltip, updated with state (no native title)
1825
+ // The Work area button + its "Show work area" checkbox reflect whether a work area is on (api.onWorkAreaChange).
1826
+ function updateWorkBtn(){const b=document.getElementById('m3dWork'),ck=document.getElementById('m3dWorkOn');if(!b||!window.Steel3DView||!window.Steel3DView.workAreaState)return;
1827
+ const st=window.Steel3DView.workAreaState(),on=!!(st&&st.on);
1828
+ b.classList.toggle('on',on);if(ck)ck.checked=on;}
1829
+ // A type category's master toggle reads its profiles' hide state: all visible = ■ on, all hidden = □ off,
1830
+ // some = ◪ mixed. Colour comes from data-state (CSS); the glyph from the textContent.
1831
+ function updateCatTog(hdr){const tog=hdr&&hdr.querySelector('.cat-tog');if(!tog||!hdr._getState||tog.style.display==='none')return;
1832
+ const state=hdr._getState();tog.dataset.state=state;tog.textContent=state==='on'?'■':(state==='off'?'□':'◪');}
1833
+ function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3DView.groupState(),hidden=new Set(st.hidden),solo=new Set(st.solo);
1834
+ document.querySelectorAll('#m3dLegend .lrow[data-key]').forEach(r=>{const k=r.dataset.key;r.classList.toggle('off',hidden.has(k)||(solo.size>0&&!solo.has(k)));r.classList.toggle('solo',solo.size>0&&solo.has(k));}); // PROFILE rows only (data-key); .off = hidden or outside the isolated set; .solo = inside it (Explorer-style highlight)
1835
+ const ch=new Set(window.Steel3DView.connHiddenIds?window.Steel3DView.connHiddenIds():[]); // per-part connection hide
1836
+ document.querySelectorAll('#m3dLegend .lrow[data-connkey]').forEach(r=>{const ids=r._ids||[];r.classList.toggle('off',ids.length>0&&ids.every(id=>ch.has(id)));});
1837
+ document.querySelectorAll('#m3dLegend .cat-hdr').forEach(updateCatTog);} // refresh the type-category master toggles too
1472
1838
  let bar3dWired=false;
1473
1839
  function seg3dActive(sel,attr,val){document.querySelectorAll(sel+' button').forEach(b=>b.classList.toggle('on',b.getAttribute(attr)===val));}
1474
1840
  function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
@@ -1477,9 +1843,28 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
1477
1843
  document.getElementById('m3dFit').onclick=()=>window.Steel3DView.frameAll();
1478
1844
  document.getElementById('m3dRef').onclick=()=>{const on=!window.Steel3DView.refLine();window.Steel3DView.setRefLine(on);document.getElementById('m3dRef').classList.toggle('on',on);};
1479
1845
  const d3=window.Steel3DView;
1480
- document.getElementById('m3dDim').onclick=()=>d3.toggleDimTool(); // toggleDimTool()'s reflectDimBar() owns the button .on class + axis/show visibility (single source, no duplicate DOM toggles)
1846
+ document.getElementById('m3dDim').onclick=()=>{d3.toggleDimTool();setLastCmd('Dimension',()=>{const b=document.getElementById('m3dDim');if(b&&!b.classList.contains('on'))d3.toggleDimTool();});}; // toggleDimTool()'s reflectDimBar() owns the button .on class + axis/show visibility (single source, no duplicate DOM toggles)
1481
1847
  document.querySelectorAll('#m3dDimAxis button').forEach(b=>b.onclick=()=>{d3.setDimAxis(b.dataset.d3axis);seg3dActive('#m3dDimAxis','data-d3axis',b.dataset.d3axis);});
1482
- document.getElementById('m3dDimShow').onclick=()=>{const on=!d3.dims3dVisible();d3.setDims3dVisible(on);document.getElementById('m3dDimShow').textContent=on?'Hide dims':'Show dims';};}
1848
+ document.getElementById('m3dDimShow').onclick=()=>{const on=!d3.dims3dVisible();d3.setDims3dVisible(on);document.getElementById('m3dDimShow').textContent=on?'Hide dims':'Show dims';};
1849
+ // Clip: the ⬡ Clip button opens a small menu (Add clip plane / box / Clear). While the plane-pick is armed the
1850
+ // button becomes a cancel target (handled by onClipModeChange). Outside-click closes the menu (capture mousedown).
1851
+ const clipBtn=document.getElementById('m3dClip'),clipMenu=document.getElementById('m3dClipMenu');
1852
+ function clipMenuOutside(e){if(!clipMenu.contains(e.target)&&e.target!==clipBtn)clipMenuClose();}
1853
+ function clipMenuClose(){clipMenu.classList.remove('open');document.removeEventListener('mousedown',clipMenuOutside,true);}
1854
+ clipBtn.onclick=e=>{e.stopPropagation();
1855
+ if(d3.clipMode()){d3.setClipMode(null);return;} // armed → cancel the pick
1856
+ if(clipMenu.classList.contains('open'))clipMenuClose();
1857
+ else{clipMenu.classList.add('open');document.addEventListener('mousedown',clipMenuOutside,true);}};
1858
+ clipMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{clipMenuClose();const a=b.dataset.clip;
1859
+ if(a==='plane'){d3.setClipMode('plane');setLastCmd('Clip plane',()=>d3.setClipMode('plane'));}else if(a==='box'){d3.setClipMode('box');setLastCmd('Clip box',()=>d3.setClipMode('box'));}else if(a==='clear')d3.clearClips();});
1860
+ document.getElementById('m3dIso').onclick=()=>{if(d3.isIsolated())d3.clearIsolation();else d3.isolateSelected();}; // onIsolateChange refreshes the button label/visibility
1861
+ // Work area: the ▢ Work area button opens a menu (Set to all objects / Define from selection / Show work area).
1862
+ const workBtn=document.getElementById('m3dWork'),workMenu=document.getElementById('m3dWorkMenu');
1863
+ function workMenuOutside(e){if(!workMenu.contains(e.target)&&e.target!==workBtn)workMenuClose();}
1864
+ function workMenuClose(){workMenu.classList.remove('open');document.removeEventListener('mousedown',workMenuOutside,true);}
1865
+ workBtn.onclick=e=>{e.stopPropagation();if(workMenu.classList.contains('open'))workMenuClose();else{updateWorkBtn();workMenu.classList.add('open');document.addEventListener('mousedown',workMenuOutside,true);}};
1866
+ workMenu.querySelectorAll('button[data-wa]').forEach(b=>b.onclick=()=>{workMenuClose();const a=b.dataset.wa;if(a==='all')d3.workAreaSetAll();else if(a==='sel'){if(!d3.workAreaFromSelection())d3.workAreaSetAll();}}); // define from selection; fall back to whole model if nothing's selected
1867
+ document.getElementById('m3dWorkOn').onchange=e=>{d3.workAreaToggle(e.target.checked);};}
1483
1868
  function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
1484
1869
  view3d=on;
1485
1870
  const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
@@ -1496,7 +1881,7 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
1496
1881
  async function setView(on){
1497
1882
  if(on){
1498
1883
  if(dimMode||dimChain||dimSplitMode){dimMode=dimChain=dimSplitMode=false;selDimIds.clear();setDimMode();document.body.classList.remove('dimsplit');} // entering 3D disarms the 2D dim/chain/split tools so their X/Y/F/S/Esc keys don't double-fire with the 3D tool
1499
- if(selIds.size)sel3dDimId=null; // a member selected in 2D wins over a stale 3D-dim selection
1884
+ if(selIds.size)sel3dDimIds.clear(); // a member selected in 2D wins over a stale 3D-dim selection
1500
1885
  if(!window.Steel3DView){toast('3D view unavailable (renderer failed to load)');return;} // stay in 2D
1501
1886
  try{
1502
1887
  if(!view3dReady){window.Steel3DView.init(document.getElementById('stage3d'),view3dApi);view3dReady=true;}
@@ -1523,8 +1908,8 @@ stage.addEventListener('pointerdown',e=>{if(e.button!==1)return;e.preventDefault
1523
1908
  pan={x:e.clientX,y:e.clientY,sl:stage.scrollLeft,st:stage.scrollTop};stage.setPointerCapture(e.pointerId);stage.style.cursor='grabbing';});
1524
1909
  stage.addEventListener('pointermove',e=>{if(!pan)return;stage.scrollLeft=pan.sl-(e.clientX-pan.x);stage.scrollTop=pan.st-(e.clientY-pan.y);});
1525
1910
  stage.addEventListener('pointerup',()=>{if(pan){pan=null;stage.style.cursor='';}});
1526
- document.getElementById('exp').onclick=()=>{const out={pt_per_ft:FT,elev_units:'inch',members:P.members.map(m=>{ensureMeta(m);return {profile:m.profile,workpoint:m.wp,role:m.role,rfi:WT[m.profile]==null,
1527
- length_ft:+(len(m.wp[0],m.wp[1])/FT).toFixed(1),weight_plf:WT[m.profile],
1911
+ document.getElementById('exp').onclick=()=>{const out={pt_per_ft:FT,elev_units:'inch',members:P.members.map(m=>{ensureMeta(m);return {profile:m.profile,workpoint:m.wp,role:m.role,rfi:_wt(m.profile)==null,
1912
+ length_ft:+(len(m.wp[0],m.wp[1])/FT).toFixed(1),weight_plf:_wt(m.profile),
1528
1913
  ends:m.role==='column'?undefined:m.ends, column:m.role==='column'?m.col:undefined};})};
1529
1914
  const b=new Blob([JSON.stringify(out,null,1)],{type:'application/json'});
1530
1915
  const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='contract_edited.json';a.click();};
@@ -1710,7 +2095,7 @@ document.getElementById('framesBtn').onclick=openFrames;
1710
2095
  document.getElementById('framesClose').onclick=closeFrames;
1711
2096
  document.getElementById('framesBackdrop').onclick=closeFrames;
1712
2097
  // --- RFI list: every unresolved member (no AISC size) with the reason, editable + locatable ---
1713
- function rfiList(){return P.members.filter(m=>WT[m.profile]==null);}
2098
+ function rfiList(){return P.members.filter(m=>_wt(m.profile)==null);}
1714
2099
  function rfiReason(m){if(!m.profile)return 'No profile assigned';
1715
2100
  if(/^MF/i.test(m.profile))return 'Moment-frame mark — pick a girder (see Frames)';
1716
2101
  if(/^BF/i.test(m.profile))return 'Brace-frame mark — pick a section';
@@ -1734,7 +2119,7 @@ function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList
1734
2119
  <td><button class=ghost data-loc="${esc(m.id)}">Locate</button></td></tr>`;}).join('')+'</tbody></table>')
1735
2120
  :'<div class=hint>No unresolved members — every member has a resolved AISC size. ✓</div>';
1736
2121
  g.querySelectorAll('input.combo[data-mid]').forEach(inp=>inp.onchange=e=>{const m=byId(inp.dataset.mid);if(!m)return;
1737
- const v=e.target.value.toUpperCase().replace(/ /g,'');edit(()=>{m.profile=v;m.rfi=(WT[v]==null);if(v&&!profs.includes(v)){profs.push(v);profs.sort();}});openRFI();});
2122
+ const v=e.target.value.toUpperCase().replace(/ /g,'');edit(()=>{m.profile=v;m.rfi=(_wt(v)==null);if(v&&!profs.includes(v)){profs.push(v);profs.sort();}});openRFI();});
1738
2123
  g.querySelectorAll('button[data-loc]').forEach(b=>b.onclick=()=>{const m=byId(b.dataset.loc);if(!m)return;selDimIds.clear();selIds=new Set([m.id]);geoMode=null;setGeo();closeRFI();render();zoomToMember(m);});
1739
2124
  document.getElementById('rfiModal').style.display='flex';}
1740
2125
  function closeRFI(){document.getElementById('rfiModal').style.display='none';}
@@ -1748,8 +2133,18 @@ const BANDW={verified:1,high:.9,med:.65,low:.35,rfi:0};
1748
2133
  const BANDLAB={verified:'Verified',high:'High',med:'Medium',low:'Low',rfi:'RFI'};
1749
2134
  const BANDC={verified:'#86efac',high:'#4ade80',med:'#fbbf24',low:'#fca5a5',rfi:'#fca5a5'};
1750
2135
  const BANDPILL={verified:'background:#166534;color:#86efac',high:'background:#14532d;color:#4ade80',med:'background:#713f12;color:#fbbf24',low:'background:#7f1d1d;color:#fca5a5',rfi:'background:#7f1d1d;color:#fca5a5'};
1751
- function _wt(profile){if(!WT||!profile)return null;if(profile in WT)return WT[profile]==null?null:WT[profile];
1752
- const h=Object.entries(WT).find(([k])=>k.toUpperCase()===profile.toUpperCase());return h?(h[1]==null?null:h[1]):null;}
2136
+ // The valid AISC US designation set lives on window.__AISC_SHAPES (set in boot() from /api/aisc-shapes).
2137
+ // boot() is in an OUTER scope than these helpers, so a window global is the shared channel. Null → regex-only.
2138
+ // The nominal weight (lb/ft) ENCODED in a US designation — for W/M/S/HP/C/MC/WT/MT/ST the number after the
2139
+ // "×" IS the AISC lb/ft (W10X33 = 33). Resolves a valid size even with no weights map — but ONLY a size that
2140
+ // actually exists in the AISC catalogue (SHAPES), so a regex-matching but non-standard string (e.g. W99X999)
2141
+ // stays unresolved (RFI) instead of being trusted for a bogus name-encoded weight.
2142
+ function _nominalPlf(p){const k=String(p||'').toUpperCase().replace(/\s/g,'');const m=k.match(/^(W|M|S|HP|C|MC|WT|MT|ST)\d+(?:\.\d+)?X([\d.]+)$/);if(!m)return null;const _S=window.__AISC_SHAPES;if(_S&&!_S.has(k))return null;return parseFloat(m[2]);}
2143
+ function _ensureWt(p){if(p&&WT&&WT[p]==null){const v=_nominalPlf(p);if(v!=null)WT[p]=v;}}
2144
+ function _wt(profile){if(!profile)return null;
2145
+ if(WT&&profile in WT&&WT[profile]!=null)return WT[profile];
2146
+ if(WT){const h=Object.entries(WT).find(([k])=>k.toUpperCase()===profile.toUpperCase());if(h&&h[1]!=null)return h[1];}
2147
+ return _nominalPlf(profile);} // fall back to the lb/ft encoded in a standard designation
1753
2148
  const _isMf=p=>!!p&&/(^|[^A-Z])MF($|[^A-Z])/i.test(p);
1754
2149
  function _confDupIds(members){const g={};const key=m=>{if(!m.wp||m.wp.length<2)return null;const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;};
1755
2150
  const rank=m=>{let s=0;if(m.profile&&!_isMf(m.profile))s++;if(m.profile&&m.profile.trim()!=='')s++;return s;};
@@ -2206,4 +2601,17 @@ document.getElementById('askAiSend').onclick = async () => {
2206
2601
  document.getElementById('reloadB').onclick = () => location.reload();
2207
2602
  } // end main()
2208
2603
  boot().catch(err => { document.body.replaceChildren(Object.assign(document.createElement('p'), { textContent: 'Failed to load: ' + err.message, style: 'padding:24px;color:#ef4444' })); });
2604
+
2605
+ // Themed tooltips (replaces native title=) for the 3D toolbar — one shared element, shown on
2606
+ // data-tip hover after a short delay, positioned below the control and clamped to the viewport.
2607
+ (function(){ const tip=document.createElement('div'); tip.id='tooltip'; document.body.appendChild(tip); let t=null;
2608
+ function show(el){ const txt=el.getAttribute('data-tip'); if(!txt) return; tip.textContent=txt; tip.classList.add('show');
2609
+ const r=el.getBoundingClientRect(), tw=tip.offsetWidth, th=tip.offsetHeight;
2610
+ let x=Math.max(6,Math.min(r.left+r.width/2-tw/2, innerWidth-tw-6)), y=r.bottom+6; if(y+th>innerHeight-6) y=r.top-th-6;
2611
+ tip.style.left=x+'px'; tip.style.top=y+'px'; }
2612
+ function hide(){ clearTimeout(t); tip.classList.remove('show'); }
2613
+ document.addEventListener('pointerover', e=>{ const el=e.target.closest('[data-tip]'); if(!el) return; clearTimeout(t); t=setTimeout(()=>show(el),400); });
2614
+ document.addEventListener('pointerout', e=>{ if(e.target.closest('[data-tip]')) hide(); });
2615
+ document.addEventListener('pointerdown', hide, true); // a click hides the tip immediately
2616
+ })();
2209
2617
  </script></body></html>