@floless/app 0.57.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +74 -19
- package/dist/web/steel-3d-view.js +755 -38
- package/dist/web/steel-editor.html +484 -76
- package/package.json +1 -1
|
@@ -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:
|
|
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
|
-
|
|
261
|
-
<div class=seg-group id=
|
|
262
|
-
<button id=m3dFit
|
|
263
|
-
<
|
|
264
|
-
|
|
265
|
-
<div class=seg-group id=
|
|
266
|
-
<button id=
|
|
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
|
|
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
|
|
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(
|
|
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=(
|
|
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=(
|
|
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(
|
|
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
|
|
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=
|
|
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=>
|
|
886
|
-
const totalW=allKnown?Math.round(arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT*
|
|
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=(
|
|
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=
|
|
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" · 1'-0 1/4"" 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)
|
|
971
|
-
const bpSect=bpj?`<div class=divrow><hr><span class=sect style="margin:0">Base plate
|
|
972
|
-
<div class=hint style="margin:0 0 6px">Tune this column's base plate —
|
|
973
|
-
${pFld('plateWidth','Plate width "N"','auto','mm')}${pFld('plateDepth','Plate depth "B"','auto','mm')}${pFld('thickness','Thickness','
|
|
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", 1'-0 1/4"); 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 ⌀','
|
|
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
|
|
984
|
-
return `<div class=elab>${label}</div><input id="spf_e${e}_${key}" inputmode=decimal placeholder="${esc(ph)}" value="${
|
|
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
|
|
987
|
-
${spF(e,j,'plateWidth','Plate width','auto','mm')}${spF(e,j,'plateHeight','Plate height','auto','mm')}${spF(e,j,'plateThickness','Thickness','
|
|
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 ⌀','
|
|
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=(
|
|
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=(
|
|
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()
|
|
1042
|
-
let n=raw===''?NaN:Number(raw);
|
|
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()
|
|
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:(
|
|
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=(
|
|
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:(
|
|
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&&
|
|
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
|
|
1420
|
-
onSelect:(id,additive)=>{selDimIds.clear();
|
|
1421
|
-
onSelectMany:(ids)=>{selDimIds.clear();
|
|
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);
|
|
1437
|
-
onSelectDim3d:(id)=>{
|
|
1438
|
-
|
|
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
|
|
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);
|
|
1460
|
-
|
|
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
|
-
|
|
1464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1471
|
-
|
|
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)
|
|
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:
|
|
1527
|
-
length_ft:+(len(m.wp[0],m.wp[1])/FT).toFixed(1),weight_plf:
|
|
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=>
|
|
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=(
|
|
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
|
-
|
|
1752
|
-
|
|
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>
|