@floless/app 0.67.0 → 0.69.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 +3 -2
- package/dist/schemas/steel.takeoff.v1.schema.json +12 -1
- package/dist/web/app.js +2 -2
- package/dist/web/aware.js +4 -4
- package/dist/web/index.html +2 -2
- package/dist/web/steel-3d-view.js +61 -0
- package/dist/web/steel-editor.html +413 -131
- package/package.json +2 -1
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui;height:100vh;display:flex;flex-direction:column}
|
|
13
13
|
header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line)}
|
|
14
14
|
header b{font-size:14px} .stat{color:var(--mut)} .stat b{color:var(--text)}
|
|
15
|
+
header .tb-sep{width:1px;height:20px;background:var(--line);flex:0 0 auto;align-self:center} /* thin divider grouping the header action clusters (history | tools | AI | more) */
|
|
16
|
+
#dim3dWrap{gap:8px} /* 3D Dimension cluster in the header — display flips inline-flex/none by view (applyViewState) */
|
|
15
17
|
button{background:#334155;color:var(--text);border:1px solid #475569;border-radius:6px;padding:5px 10px;cursor:pointer;font:13px system-ui}
|
|
16
18
|
button:hover{background:#475569} button.on{background:var(--brand);border-color:var(--brand)}
|
|
17
19
|
button:disabled{opacity:.4;cursor:default;background:#334155}
|
|
@@ -101,6 +103,20 @@
|
|
|
101
103
|
#moreMenu .msnap-hdr .chev{color:var(--mut);font-size:10px;transition:transform .15s}
|
|
102
104
|
#moreMenu .msnap-hdr[aria-expanded=true] .chev{transform:rotate(90deg)}
|
|
103
105
|
#moreMenu .msnap-sect{display:none} #moreMenu .msnap-sect.open{display:block}
|
|
106
|
+
/* Section accordions — every ⋯ section (Snapping, Display, Detailing, …) collapses/expands like Snapping; state persists (wireMoreSections). */
|
|
107
|
+
#moreMenu .msec-hdr{display:flex;align-items:center;justify-content:space-between;font-weight:600;border-top:1px solid var(--line)} /* headers read as headers: semibold + a divider between sections */
|
|
108
|
+
#moreMenu #snapHdr{border-top:0} /* first section — no divider above it */
|
|
109
|
+
#moreMenu .msec-hdr .chev{color:var(--mut);font-size:10px;transition:transform .15s}
|
|
110
|
+
#moreMenu .msec-hdr[aria-expanded=true]{background:rgba(59,130,246,.08);color:var(--brand)} /* the OPEN section's header is brand-tinted so its (indented) items read as "inside" it, not merged with the next section */
|
|
111
|
+
#moreMenu .msec-hdr[aria-expanded=true] .chev{transform:rotate(90deg);color:var(--brand)}
|
|
112
|
+
#moreMenu .msec-hdr[aria-expanded=true]:hover{background:#334155;color:var(--text)} /* keep the normal hover feedback on an open header */
|
|
113
|
+
#moreMenu .msec-body{display:none} #moreMenu .msec-body.open{display:block;padding-bottom:4px}
|
|
114
|
+
#moreMenu .msec-body button,#moreMenu .msec-body .mhint{padding-left:26px} /* indent items so they sit visually UNDER their header */
|
|
115
|
+
/* Insert-detail now lives in the Detailing section; its picker flies out to the LEFT of the ⋯ menu (which hugs the right edge). */
|
|
116
|
+
#moreMenu .m3dwrap.ins-in-menu{display:block}
|
|
117
|
+
#moreMenu #m3dInsert.on{color:var(--brand)}
|
|
118
|
+
#moreMenu #m3dInsertMenu{left:auto;right:calc(100% + 4px);top:0}
|
|
119
|
+
body:not(.v3d) #moreMenu #insWrap{display:none} /* Insert detail places into the 3D scene — hide it in 2D (needs 2 ids to beat the .m3dwrap.ins-in-menu display:block) */
|
|
104
120
|
#moreMenu button.msnap{display:flex;align-items:center;gap:0}
|
|
105
121
|
#moreMenu button.msnap.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool elsewhere in this menu) */
|
|
106
122
|
#moreMenu .mck,.cmmenu .mck{position:relative;width:26px;height:14px;margin-right:9px;border-radius:7px;border:1px solid var(--line);background:#0b1220;flex:none;transition:background-color .15s,border-color .15s} /* delicate CSS-only slider switch — shared by the ⋯ Snapping rows and the Move/Copy → Drag-to-move/copy toggle */
|
|
@@ -229,6 +245,11 @@
|
|
|
229
245
|
text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
|
|
230
246
|
line.dim.dimsel{stroke:#f59e0b} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
|
|
231
247
|
circle.dimhandle{fill:#22d3ee;stroke:#0b1220;stroke-width:2;cursor:grab}
|
|
248
|
+
/* property-label chips (right-click → Properties popup): dark chip + brand-blue accent, distinct from the cyan dimension chips.
|
|
249
|
+
pointer-events:none on the whole group — labels are passive annotations; clicks/marquee/drag pass through to the members underneath. */
|
|
250
|
+
g.pllabels{pointer-events:none}
|
|
251
|
+
rect.plchip{fill:#0b1220;stroke:var(--brand);stroke-width:1;vector-effect:non-scaling-stroke}
|
|
252
|
+
text.pltx{fill:#e2e8f0;font-family:system-ui;text-anchor:middle;dominant-baseline:central}
|
|
232
253
|
/* structural grid layer (under the members): dash-dot slate lines + label bubbles at both ends */
|
|
233
254
|
line.gridln{stroke:#64748b;stroke-width:1.3;stroke-dasharray:12 4 2 4;vector-effect:non-scaling-stroke;pointer-events:none;opacity:.85}
|
|
234
255
|
line.gridhit{stroke:transparent;stroke-width:9;vector-effect:non-scaling-stroke;pointer-events:stroke}
|
|
@@ -279,16 +300,46 @@
|
|
|
279
300
|
.m3dmenu.open{display:block}
|
|
280
301
|
.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}
|
|
281
302
|
.m3dmenu button:hover{background:#334155}
|
|
303
|
+
.m3dmenu button.on{color:var(--brand)} /* active choice in a radio-style menu (Camera / Display) */
|
|
304
|
+
.m3dmenu button.on::after{content:'✓';float:right;margin-left:16px;color:var(--brand)}
|
|
282
305
|
.m3dmenu button:disabled{opacity:.4;cursor:default;background:transparent}
|
|
283
306
|
.m3dmenu button.mdanger{color:#fca5a5} .m3dmenu button.mdanger:hover{background:#7f1d1d;color:#fecaca}
|
|
284
307
|
.m3dmenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
|
|
285
308
|
.m3dmenu label{display:flex;align-items:center;gap:7px;padding:7px 12px;color:var(--text);font-size:12px;cursor:pointer;white-space:nowrap}
|
|
286
309
|
.m3dmenu label:hover{background:#334155}
|
|
287
|
-
.m3dmenu label input{margin:0;accent-color:var(--brand);cursor:pointer}
|
|
310
|
+
.m3dmenu label input{margin:0;width:auto;accent-color:var(--brand);cursor:pointer} /* width:auto — don't inherit the global input{width:100%}; keeps the checkbox tight against its label (View / Plane / Work toggles) */
|
|
288
311
|
.m3dmenu .wpprow{display:flex;gap:4px;align-items:center;padding:4px 12px}
|
|
289
312
|
.m3dmenu .wpprow button{width:auto;flex:none;padding:4px 8px;border:1px solid var(--line);border-radius:5px}
|
|
290
313
|
.m3dmenu .wpprow input{width:74px;height:24px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 6px;font:12px system-ui}
|
|
291
314
|
.m3dmenu .wpprow input:focus{outline:none;border-color:var(--brand)}
|
|
315
|
+
/* right-click property-labels popup — floating panel, built from the same tokens as .m3dmenu / .mpanel (no new vocabulary) */
|
|
316
|
+
#propPop{position:fixed;left:0;top:0;z-index:46;width:280px;max-height:60vh;display:none;flex-direction:column;background:var(--panel);border:1px solid #475569;border-radius:10px;box-shadow:0 16px 48px rgba(0,0,0,.6);font-size:12px;color:var(--text)}
|
|
317
|
+
#propPop.open{display:flex}
|
|
318
|
+
#propPop .pph{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line)}
|
|
319
|
+
#propPop .pph b{font-size:12px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
320
|
+
#propPop .pph .pcount{color:var(--mut);font-size:10px;white-space:nowrap}
|
|
321
|
+
#propPop .pph button{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:4px;font-size:11px;padding:1px 6px;cursor:pointer;line-height:1.5}
|
|
322
|
+
#propPop .pph button:hover{border-color:var(--brand);color:var(--text)}
|
|
323
|
+
#propPop .pph button.pin.on{border-color:var(--brand);color:var(--brand)}
|
|
324
|
+
#propPop .ppsearch{padding:8px 10px 4px}
|
|
325
|
+
#propPop .ppsearch input{width:100%;height:26px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:0 8px;font:12px system-ui;box-sizing:border-box}
|
|
326
|
+
#propPop .ppsearch input:focus{outline:none;border-color:var(--brand)}
|
|
327
|
+
#propPop .ppmeta{color:var(--mut);font-size:10px;padding:2px 10px 0}
|
|
328
|
+
#propPop .ppscope{color:var(--mut);font-size:10px;padding:2px 10px 6px;font-style:italic}
|
|
329
|
+
#propPop .pplist{overflow:auto;flex:1;padding:2px 0;min-height:40px;border-top:1px solid var(--line)}
|
|
330
|
+
#propPop .pprow{display:flex;align-items:center;gap:8px;min-height:32px;padding:2px 10px;cursor:pointer}
|
|
331
|
+
#propPop .pprow:hover{background:#334155}
|
|
332
|
+
#propPop .pprow.dis{opacity:.5;cursor:default}
|
|
333
|
+
#propPop .pprow.dis:hover{background:transparent}
|
|
334
|
+
#propPop .pprow input{margin:0;accent-color:var(--brand);cursor:pointer;flex:none;width:auto} /* width:auto beats the editor's global input{width:100%} that would otherwise stretch the checkbox across the row */
|
|
335
|
+
#propPop .pprow .pn{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
336
|
+
#propPop .pprow .pv{color:var(--mut);font-variant-numeric:tabular-nums;max-width:44%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right}
|
|
337
|
+
#propPop .pprow .pv.varies{font-style:italic}
|
|
338
|
+
#propPop .ppempty{color:var(--mut);font-size:11px;padding:12px 10px;text-align:center}
|
|
339
|
+
#propPop .ppfoot{border-top:1px solid var(--line);padding:8px 10px;display:flex;flex-direction:column;gap:6px}
|
|
340
|
+
#propPop .ppfoot .seg2{margin-top:0}
|
|
341
|
+
#propPop .ppfoot label{display:flex;align-items:center;gap:7px;color:var(--text);cursor:pointer;font-size:11px}
|
|
342
|
+
#propPop .ppfoot label input{margin:0;accent-color:var(--brand);cursor:pointer;flex:none;width:auto}
|
|
292
343
|
/* thin cluster divider in the 3D toolbar */
|
|
293
344
|
#m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
|
|
294
345
|
/* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
|
|
@@ -349,22 +400,29 @@
|
|
|
349
400
|
</head><body>
|
|
350
401
|
<header>
|
|
351
402
|
<b>Steel Model</b>
|
|
352
|
-
<div id=viewToggle role=group aria-label="Canvas view"><button id=vt2d class="seg on" aria-pressed=true
|
|
353
|
-
<select id=planSel
|
|
403
|
+
<div id=viewToggle role=group aria-label="Canvas view"><button id=vt2d class="seg on" aria-pressed=true data-tip="Plan view (2D overlay)">2D</button><button id=vt3d class=seg aria-pressed=false data-tip="3D model view">3D</button></div>
|
|
404
|
+
<select id=planSel data-tip="Switch plan view"></select>
|
|
354
405
|
<span class=stat>Members <b id=mc>0</b></span><span class=stat>Weight <b id=wt>0</b> tons · <b id=wtlb>0</b> lb</span>
|
|
355
|
-
<span class=stat id=rfiStat
|
|
356
|
-
<span class=stat id=dupStat
|
|
357
|
-
<span class=stat id=csStat
|
|
358
|
-
<span class=stat id=snapStat style="display:none"
|
|
359
|
-
<span class=stat id=confStat
|
|
360
|
-
<span class=stat id=saveStat
|
|
406
|
+
<span class=stat id=rfiStat data-tip="Click to list members with an unresolved size (RFI — Request for Information)">RFI <b id=rc>0</b></span>
|
|
407
|
+
<span class=stat id=dupStat data-tip="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
|
|
408
|
+
<span class=stat id=csStat data-tip="Coordinate system — Global by default. Set a local frame from the ⋯ menu for skewed framing.">Axes <b>Global</b></span>
|
|
409
|
+
<span class=stat id=snapStat style="display:none" data-tip="Snap restricted for this operation — right-click the canvas to change; click here or press Esc to clear">Snap <b>—</b></span>
|
|
410
|
+
<span class=stat id=confStat data-tip="Confidence report — AI-read score vs. target. Click to review element evidence." style="display:none">Confidence <b id=confPct>—</b><span id=confTgt></span></span>
|
|
411
|
+
<span class=stat id=saveStat data-tip="Edits auto-save in this browser (localStorage)">Saved</span>
|
|
361
412
|
<span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
|
|
362
|
-
<button id=undoB
|
|
363
|
-
<button id=redoB
|
|
364
|
-
<
|
|
365
|
-
<button id=
|
|
413
|
+
<button id=undoB data-tip="Undo (Ctrl+Z)">↶</button>
|
|
414
|
+
<button id=redoB data-tip="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
|
|
415
|
+
<span class=tb-sep></span>
|
|
416
|
+
<button id=mAdd data-tip="Toggle add-member mode — drag to draw. Shift=ortho, Alt=no snap, right-click=restrict snap to one type">Add member</button>
|
|
417
|
+
<button id=dimB data-tip="Dimension tool (D) — click two snapped points, then a third to place. Default Free (aligned); hold Shift to lock to an axis, X/Y force horizontal/vertical, F free. Right-click the canvas to restrict snapping to one type.">⊢ Dimension</button>
|
|
418
|
+
<!-- 3D Dimension cluster — relocated here from the 3D toolbar so Dimension has ONE home; shown only in 3D (applyViewState). Same ids so steel-3d-view.js reflectDimBar() still drives it. -->
|
|
419
|
+
<span id=dim3dWrap style="display:none;align-items:center;gap:8px">
|
|
420
|
+
<button id=m3dDim data-tip="Measure in 3D — click two snapped points (D); axis Free / X / Y / Z, Alt = vertical; right-click = restrict snap">⊢ Dimension</button>
|
|
421
|
+
<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>
|
|
422
|
+
<button id=m3dDimShow data-tip="Show or hide placed 3D dimensions" style="display:none">Hide dims</button>
|
|
423
|
+
</span>
|
|
366
424
|
<div class=cmwrap>
|
|
367
|
-
<button id=xfB aria-haspopup=menu aria-expanded=false
|
|
425
|
+
<button id=xfB aria-haspopup=menu aria-expanded=false data-tip="Move, Copy, array and to-level, plus how left-dragging a member behaves. Move (M) / Copy (C) also arm from the keyboard.">Move / Copy ▾</button>
|
|
368
426
|
<div class=cmmenu id=xfMenu role=menu>
|
|
369
427
|
<div class=mlabel>Move</div>
|
|
370
428
|
<button id=mvTwoB>Move — two points <span class=mkbd>M</span></button>
|
|
@@ -374,52 +432,63 @@
|
|
|
374
432
|
<button id=cpArrB>Copy array…</button>
|
|
375
433
|
<button id=cpLevelB>Copy to level…</button>
|
|
376
434
|
<div class=mlabel>Dragging</div>
|
|
377
|
-
<button id=dragMoveB role=menuitemcheckbox aria-checked=false
|
|
435
|
+
<button id=dragMoveB role=menuitemcheckbox aria-checked=false data-tip="When ON, left-dragging a member moves it and Ctrl+drag copies it. When OFF (default), a drag does neither — a click only selects, so members can't be nudged by accident (the Move and Copy tools still work). Applies to 2D and 3D."><span class=mck aria-hidden=true></span>Drag to move/copy</button>
|
|
378
436
|
</div>
|
|
379
437
|
</div>
|
|
438
|
+
<span class=tb-sep></span>
|
|
380
439
|
<button id=askAiBtn>Ask AI ▸</button>
|
|
440
|
+
<span class=tb-sep></span>
|
|
381
441
|
<div id=moreWrap>
|
|
382
|
-
<button id=moreBtn
|
|
442
|
+
<button id=moreBtn data-tip="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
|
|
383
443
|
<div id=moreMenu role=menu>
|
|
384
|
-
|
|
385
|
-
<
|
|
386
|
-
|
|
387
|
-
<button class="msnap on" data-snap=
|
|
388
|
-
<button class="msnap on" data-snap=
|
|
389
|
-
<button class="msnap on" data-snap=
|
|
390
|
-
<button class="msnap" data-snap=
|
|
444
|
+
<!-- Every section is a collapse/expand accordion (same as Snapping); state persists in localStorage (wireMoreSections). -->
|
|
445
|
+
<button id=snapHdr class="msnap-hdr msec-hdr" data-sec=snap aria-expanded=false aria-controls=snapSect data-tip="Running snaps — click to expand and turn each on/off (also on the snap bar, bottom-right)">Snapping<span class=chev aria-hidden=true>▸</span></button>
|
|
446
|
+
<div id=snapSect class="msnap-sect msec-body">
|
|
447
|
+
<button class="msnap on" data-snap=end role=menuitemcheckbox aria-checked=true data-tip="Snap to member/segment endpoints"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>□</span>Endpoint</button>
|
|
448
|
+
<button class="msnap on" data-snap=int role=menuitemcheckbox aria-checked=true data-tip="Snap to where two members/segments cross"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>✕</span>Intersection</button>
|
|
449
|
+
<button class="msnap on" data-snap=mid role=menuitemcheckbox aria-checked=true data-tip="Snap to the midpoint of a member/segment"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>△</span>Midpoint</button>
|
|
450
|
+
<button class="msnap on" data-snap=line role=menuitemcheckbox aria-checked=true data-tip="Snap to the nearest point on any member/segment line"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>⧗</span>Nearest</button>
|
|
451
|
+
<button class="msnap" data-snap=ext role=menuitemcheckbox aria-checked=false data-tip="Snap along the invisible extension of a member/line past its endpoint, with a dashed guide line"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>┈</span>Extension</button>
|
|
391
452
|
<div class=mhint>Right-click the canvas any time to force one snap type for a single pick.</div>
|
|
392
453
|
</div>
|
|
454
|
+
<button class=msec-hdr data-sec=display aria-expanded=false data-tip="Show/hide plan layers, and edit grid lines">Display<span class=chev aria-hidden=true>▸</span></button>
|
|
455
|
+
<div class=msec-body>
|
|
456
|
+
<button id=dimToggleB data-tip="Show or hide all placed dimensions on the plan">Hide dimensions</button>
|
|
457
|
+
<button id=calloutToggleB data-tip="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
|
|
458
|
+
<button id=gridToggleB data-tip="Show or hide the grid lines in 2D and 3D">Hide grid</button>
|
|
459
|
+
<button id=gridEditB data-tip="Grid lines — a plan reference with structural bay spacings (n*d repeats a bay). Shows in 2D and 3D; drawing and drags snap to its lines and intersections.">Grid lines…</button>
|
|
460
|
+
</div>
|
|
461
|
+
<button class=msec-hdr data-sec=detailing aria-expanded=false data-tip="Connection details, plates, frames, and inserted detail images">Detailing<span class=chev aria-hidden=true>▸</span></button>
|
|
462
|
+
<div class=msec-body>
|
|
463
|
+
<button id=detailsBtn>Details</button>
|
|
464
|
+
<button id=connBtn data-tip="A lookup table of connection types (moment, shear, pinned, …). Each maps to a design detail and, per platform, a component ID; reference a row from each member end.">Connections</button>
|
|
465
|
+
<div class="m3dwrap ins-in-menu" id=insWrap>
|
|
466
|
+
<button id=m3dInsert data-tip="Insert a 2D detail image into the 3D scene, near a beam (3D view only)">Insert detail…</button>
|
|
467
|
+
<div id=m3dInsertMenu class=m3dmenu role=menu></div>
|
|
468
|
+
<input id=insFile type=file accept="image/*" style="display:none">
|
|
469
|
+
</div>
|
|
470
|
+
<button id=bpBtn data-tip="Auto-detail base plates on every column (a plate + anchor kit + weld, shown in 3D). Tune sizes per the schedule via the AI or per-column params.">Base plates</button>
|
|
471
|
+
<button id=spBtn data-tip="Auto-detail bolted shear (fin) plates on eligible beam ends (a fin plate + bolt group + weld, shown in 3D). Tune sizes per the schedule via the AI or per-end params.">Shear plates</button>
|
|
472
|
+
<button id=framesBtn>Frames</button>
|
|
473
|
+
</div>
|
|
474
|
+
<button class=msec-hdr data-sec=checks aria-expanded=false data-tip="Find and fix modelling problems">Model checks<span class=chev aria-hidden=true>▸</span></button>
|
|
475
|
+
<div class=msec-body>
|
|
476
|
+
<button id=dupB data-tip="Selects overlapping members drawn twice by mistake — review, then press Delete to remove the extra one.">Duplicates</button>
|
|
477
|
+
<button id=revB data-tip="Select beams drawn the wrong way — a mostly-horizontal beam should run left→right, a steep/skew beam bottom→up. Review, then press P to swap their direction.">Reversed beams</button>
|
|
478
|
+
<button id=mrgB data-tip="Joins straight, end-to-end runs of the same profile into one beam. Undoable.">Merge collinear beams</button>
|
|
479
|
+
</div>
|
|
480
|
+
<button class=msec-hdr data-sec=coords aria-expanded=false data-tip="Local vs global drawing axes (for skewed framing)">Coordinate system<span class=chev aria-hidden=true>▸</span></button>
|
|
481
|
+
<div class=msec-body>
|
|
482
|
+
<button id=csSetB data-tip="Define a local X axis by clicking two points (origin, then X-direction). Y follows at 90°. Orthogonal drawing and X/Y dimensions then snap to this frame — for skewed framing.">↺ Set local axes…</button>
|
|
483
|
+
<button id=csResetB data-tip="Return to the global X/Y axes (default)" disabled>Reset to global axes</button>
|
|
484
|
+
</div>
|
|
485
|
+
<button class=msec-hdr data-sec=session aria-expanded=false data-tip="Reload from the server, or export the data">Session<span class=chev aria-hidden=true>▸</span></button>
|
|
486
|
+
<div class=msec-body>
|
|
487
|
+
<button id=reloadB data-tip="Reloads this page and pulls in any changes your terminal AI made since you last opened it.">↻ Reload from server</button>
|
|
488
|
+
<button id=exp>Export contract…</button>
|
|
489
|
+
</div>
|
|
393
490
|
<hr>
|
|
394
|
-
<
|
|
395
|
-
<button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
|
|
396
|
-
<button id=calloutToggleB title="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
|
|
397
|
-
<hr>
|
|
398
|
-
<div class=mlabel>Grid</div>
|
|
399
|
-
<button id=gridEditB title="Grid lines — a plan reference with Tekla-style spacings (n*d repeats a bay). Shows in 2D and 3D; drawing and drags snap to its lines and intersections.">Grid lines…</button>
|
|
400
|
-
<button id=gridToggleB title="Show or hide the grid lines in 2D and 3D">Hide grid</button>
|
|
401
|
-
<hr>
|
|
402
|
-
<div class=mlabel>Coordinate system</div>
|
|
403
|
-
<button id=csSetB title="Define a local X axis by clicking two points (origin, then X-direction). Y follows at 90°. Orthogonal drawing and X/Y dimensions then snap to this frame — for skewed framing.">↺ Set local axes…</button>
|
|
404
|
-
<button id=csResetB title="Return to the global X/Y axes (default)" disabled>Reset to global</button>
|
|
405
|
-
<hr>
|
|
406
|
-
<div class=mlabel>Review</div>
|
|
407
|
-
<button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
|
|
408
|
-
<button id=revB title="Select beams drawn the wrong way — a mostly-horizontal beam should run left→right, a steep/skew beam bottom→up. Review, then press P to swap their direction.">Reversed beams</button>
|
|
409
|
-
<button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
|
|
410
|
-
<hr>
|
|
411
|
-
<div class=mlabel>Reference</div>
|
|
412
|
-
<button id=detailsBtn>Details</button>
|
|
413
|
-
<button id=connBtn title="Connection library — map each connection type to its design detail# and per-platform component id; reference a row from each member end">Connections</button>
|
|
414
|
-
<button id=bpBtn title="Auto-detail base plates on every column (a plate + anchor kit + weld, shown in 3D). Tune sizes per the schedule via the AI or per-column params.">Base plates</button>
|
|
415
|
-
<button id=spBtn title="Auto-detail bolted shear (fin) plates on eligible beam ends (a fin plate + bolt group + weld, shown in 3D). Tune sizes per the schedule via the AI or per-end params.">Shear plates</button>
|
|
416
|
-
<button id=framesBtn>Frames</button>
|
|
417
|
-
<hr>
|
|
418
|
-
<div class=mlabel>Session</div>
|
|
419
|
-
<button id=reloadB title="Reload from server — picks up any AI writebacks">↻ Reload</button>
|
|
420
|
-
<button id=exp>Export contract</button>
|
|
421
|
-
<hr>
|
|
422
|
-
<button id=revertB class=mdanger title="Discard all saved edits and reload the detected contract">Revert</button>
|
|
491
|
+
<button id=revertB class=mdanger data-tip="Throws away every change you've made in this browser and reloads the original AI-read model. This can't be undone.">Revert all edits…</button>
|
|
423
492
|
</div>
|
|
424
493
|
</div>
|
|
425
494
|
</header>
|
|
@@ -428,20 +497,37 @@
|
|
|
428
497
|
<div id=stage><svg id=svg></svg></div>
|
|
429
498
|
<canvas id=stage3d tabindex=0 aria-label="3D model"></canvas>
|
|
430
499
|
<div id=m3dBar role=group aria-label="3D view controls">
|
|
431
|
-
<!-- Camera -->
|
|
432
|
-
<div class=
|
|
500
|
+
<!-- Camera projection — dropdown (like Plane / Work area); the button shows the current mode -->
|
|
501
|
+
<div class=m3dwrap>
|
|
502
|
+
<button id=m3dProjBtn data-tip="Camera projection — perspective, or orthographic (true scale)">Persp ▾</button>
|
|
503
|
+
<div id=m3dProj class=m3dmenu role=menu>
|
|
504
|
+
<button data-proj=persp class=on data-tip="Perspective — natural depth">Perspective</button>
|
|
505
|
+
<button data-proj=ortho data-tip="Orthographic — true scale, no perspective">Orthographic</button>
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
433
508
|
<button id=m3dFit data-tip="Fit all to view (Home)">Fit</button>
|
|
509
|
+
<button id=m3dFitSel data-tip="Zoom to selected — frame just the selected members (Alt+Z)">Zoom sel</button>
|
|
434
510
|
<span class=tb-sep></span>
|
|
435
|
-
<!-- Display
|
|
436
|
-
<div class=
|
|
511
|
+
<!-- Display mode — dropdown; the button shows the current mode -->
|
|
512
|
+
<div class=m3dwrap>
|
|
513
|
+
<button id=m3dModeBtn data-tip="Display mode — solid, wireframe, or see-through (X-ray)">Solid ▾</button>
|
|
514
|
+
<div id=m3dMode class=m3dmenu role=menu>
|
|
515
|
+
<button data-mode=solid class=on data-tip="Solid shaded model">Solid</button>
|
|
516
|
+
<button data-mode=wire data-tip="Wireframe — edges only">Wire</button>
|
|
517
|
+
<button data-mode=xray data-tip="See-through — reveal hidden parts">X-ray</button>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
437
520
|
<button id=m3dIso data-tip="Isolate selected — hide everything else (Esc to exit)" style="display:none">Isolate</button>
|
|
438
521
|
<span class=tb-sep></span>
|
|
439
|
-
<!--
|
|
440
|
-
|
|
441
|
-
<
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
522
|
+
<!-- Display toggles: reference lines + mark labels (grouped into a menu, like Plane / Work area).
|
|
523
|
+
The Dimension tool moved to the header so it lives in one place across 2D and 3D. -->
|
|
524
|
+
<div class=m3dwrap>
|
|
525
|
+
<button id=m3dView data-tip="Show or hide reference lines and mark labels on the 3D model">View ▾</button>
|
|
526
|
+
<div id=m3dViewMenu class=m3dmenu role=menu>
|
|
527
|
+
<label data-tip="Show each member's reference line (centreline)"><input type=checkbox id=m3dRef> Ref line</label>
|
|
528
|
+
<label data-tip="Show each member's mark / id label in the 3D view"><input type=checkbox id=m3dLabels> Labels</label>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
445
531
|
<span class=tb-sep></span>
|
|
446
532
|
<!-- Section -->
|
|
447
533
|
<div class=m3dwrap>
|
|
@@ -453,11 +539,6 @@
|
|
|
453
539
|
<button data-clip=clear class=mdanger data-tip="Remove every clip">Clear all clips</button>
|
|
454
540
|
</div>
|
|
455
541
|
</div>
|
|
456
|
-
<div class=m3dwrap>
|
|
457
|
-
<button id=m3dInsert data-tip="Insert a 2D detail image into the 3D scene, near a beam">Insert ▾</button>
|
|
458
|
-
<div id=m3dInsertMenu class=m3dmenu role=menu></div>
|
|
459
|
-
<input id=insFile type=file accept="image/*" style="display:none">
|
|
460
|
-
</div>
|
|
461
542
|
<div class=m3dwrap>
|
|
462
543
|
<button id=m3dWp data-tip="Working plane — every 3D pick (Move/Copy, next: drawing) lands on it. Set it from a face, 3 points, or a principal plane.">◇ Plane ▾</button>
|
|
463
544
|
<div id=m3dWpMenu class=m3dmenu role=menu>
|
|
@@ -488,19 +569,19 @@
|
|
|
488
569
|
<div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
|
|
489
570
|
<div id=m3dAxes></div>
|
|
490
571
|
<div id=zoombar>
|
|
491
|
-
<button id=zOut
|
|
572
|
+
<button id=zOut data-tip="Zoom out">−</button>
|
|
492
573
|
<input id=zRange type=range min=10 max=400 step=1 value=100>
|
|
493
|
-
<button id=zIn
|
|
574
|
+
<button id=zIn data-tip="Zoom in">+</button>
|
|
494
575
|
<span id=zPct>100%</span>
|
|
495
|
-
<button id=zFit
|
|
576
|
+
<button id=zFit data-tip="Zoom to fit (Home)">Fit</button>
|
|
496
577
|
</div>
|
|
497
578
|
<!-- Quick-access snap toggles (same persistent state as the ⋯ menu → Snapping section; keep the two in sync — same 5 items). -->
|
|
498
579
|
<div id=snapBar role=group aria-label="Snap settings">
|
|
499
|
-
<button data-snap=end
|
|
500
|
-
<button data-snap=int
|
|
501
|
-
<button data-snap=mid
|
|
502
|
-
<button data-snap=line
|
|
503
|
-
<button data-snap=ext
|
|
580
|
+
<button data-snap=end data-tip="Endpoint snap">□</button>
|
|
581
|
+
<button data-snap=int data-tip="Intersection snap">✕</button>
|
|
582
|
+
<button data-snap=mid data-tip="Midpoint snap">△</button>
|
|
583
|
+
<button data-snap=line data-tip="Nearest snap">⧗</button>
|
|
584
|
+
<button data-snap=ext data-tip="Extension snap">┈</button>
|
|
504
585
|
</div>
|
|
505
586
|
</div>
|
|
506
587
|
<aside id=panel></aside>
|
|
@@ -513,7 +594,7 @@
|
|
|
513
594
|
<div id=detailNewForm style="display:none;padding:14px;border-bottom:1px solid var(--line)">
|
|
514
595
|
<label class=elab for=dnName>Detail name</label>
|
|
515
596
|
<input id=dnName placeholder="e.g. 5-S504 or My moment conn." autocomplete=off>
|
|
516
|
-
<div id=dnDrop
|
|
597
|
+
<div id=dnDrop data-tip="Paste or click to choose"><span id=dnDropTxt>Paste a screenshot (Ctrl+V) — or click to choose an image</span><img id=dnPrev alt=""></div>
|
|
517
598
|
<input id=dnFile type=file accept="image/*" hidden>
|
|
518
599
|
<div id=dnBtns><button id=dnCancel class=ghost>Cancel</button><button id=dnAdd>Add detail</button></div>
|
|
519
600
|
<div class=hint id=dnHint>For anything the auto‑detection missed. The image stays on your machine.</div>
|
|
@@ -532,7 +613,7 @@
|
|
|
532
613
|
<div class=mpanel><div class=mhead><b>Unresolved members — RFI</b><button id=rfiClose>✕</button></div>
|
|
533
614
|
<div id=rfiGrid style="padding:14px;overflow:auto"></div></div></div>
|
|
534
615
|
<div id=confModal><div class=mbackdrop id=confBackdrop></div>
|
|
535
|
-
<div class=mpanel><div class=mhead><b>Confidence report</b><label class=conf-tgt
|
|
616
|
+
<div class=mpanel><div class=mhead><b>Confidence report</b><label class=conf-tgt data-tip="Confidence target for this read (%). Overrides the workflow default; saved with the contract.">Target <input id=confTarget type=number min=0 max=100 step=1> %</label><button id=confClose>✕</button></div>
|
|
536
617
|
<div id=confCats class=conf-cats></div>
|
|
537
618
|
<div id=confFilter class=conf-filter></div>
|
|
538
619
|
<div id=confBody class=conf-body></div></div></div>
|
|
@@ -607,7 +688,7 @@ async function boot() {
|
|
|
607
688
|
if(!Array.isArray(C.dims3d))C.dims3d=[]; // model-global draft-only 3D dimensions
|
|
608
689
|
if(!Array.isArray(C.detail_placements))C.detail_placements=[]; // model-global draft-only placed detail images (Slice 4)
|
|
609
690
|
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
|
|
610
|
-
main();
|
|
691
|
+
main(); // (C.prop_labels is normalised inside main(), right after the PROP_DEFS/sanitizePropLabels registry is defined — see ~"normalise the contract's incoming value" — since that helper isn't in scope out here in boot())
|
|
611
692
|
// SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
|
|
612
693
|
// We open our own EventSource to the same /api/events endpoint as the main app.
|
|
613
694
|
// Uses the same parse format: es.onmessage → JSON.parse(e.data) → {type, ...}.
|
|
@@ -672,7 +753,7 @@ function showAiUpdateBanner(message) {
|
|
|
672
753
|
const dismissBtn = document.createElement('button');
|
|
673
754
|
dismissBtn.style.cssText = 'background:transparent;color:#93c5fd;border:1px solid #3b82f6;border-radius:6px;padding:5px 10px;cursor:pointer;font:13px system-ui';
|
|
674
755
|
dismissBtn.textContent = '✕';
|
|
675
|
-
dismissBtn.
|
|
756
|
+
dismissBtn.dataset.tip = 'Dismiss';
|
|
676
757
|
dismissBtn.onclick = () => bar.remove();
|
|
677
758
|
|
|
678
759
|
bar.appendChild(msg);
|
|
@@ -832,7 +913,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
|
|
|
832
913
|
else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
|
|
833
914
|
else el.textContent=msg||'Auto-save on';}
|
|
834
915
|
function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
|
|
835
|
-
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, detail_placements:C.detail_placements,
|
|
916
|
+
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, prop_labels:C.prop_labels, joints:C.joints, detail_placements:C.detail_placements,
|
|
836
917
|
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,grid:p.grid||null}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
|
|
837
918
|
// --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
|
|
838
919
|
// localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
|
|
@@ -866,6 +947,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
|
|
|
866
947
|
if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
|
|
867
948
|
if(Array.isArray(d.detail_placements))C.detail_placements=d.detail_placements; // restore placed details
|
|
868
949
|
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)
|
|
950
|
+
if('prop_labels' in d)C.prop_labels=sanitizePropLabels(d.prop_labels); // restore canvas property-label display state (sanitised to known keys/placement)
|
|
869
951
|
if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
|
|
870
952
|
if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
|
|
871
953
|
function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
|
|
@@ -1070,7 +1152,7 @@ function renderGridPanel(p){
|
|
|
1070
1152
|
const nCols=planColumnCount(),hasEls=!!filterElsForPlan();
|
|
1071
1153
|
p.innerHTML=`<span class=badge>Grid lines</span>
|
|
1072
1154
|
<div class=hint style="margin-top:8px">No grid yet. Grid lines are a plan reference — they don't constrain members.</div>
|
|
1073
|
-
<div class=gbtns><button id=gridReadB ${hasEls?'':'disabled'}
|
|
1155
|
+
<div class=gbtns><button id=gridReadB ${hasEls?'':'disabled'} data-tip="${hasEls?'Detect the printed grid lines and bubbles in this sheet’s linework. Undoable.':'Needs this sheet’s filter linework — re-read the drawing with the steel filter step to capture it.'}">From drawing</button><button id=gridAutoB ${nCols<2?'disabled':''} data-tip="${nCols<2?'Needs at least two columns in the model to infer spacing.':'Infer spacings and origin from the column layout. Undoable.'}">Auto from columns</button><button id=gridBlankB data-tip="Start from a 4×4 grid of 30' bays and edit the spacings. Undoable.">Start blank</button></div>
|
|
1074
1156
|
<div class=hint style="margin-top:10px">Spacing uses <b>n*d</b> to repeat (e.g. <b>3*25'</b> = three 25' bays). Tokens: <b>25'-6"</b>, <b>6"</b>, <b>7.5m</b>, bare mm.</div>`;
|
|
1075
1157
|
const rb=document.getElementById('gridReadB');if(rb)rb.onclick=()=>gridReadFromDrawing(null);
|
|
1076
1158
|
const ab=document.getElementById('gridAutoB');if(ab)ab.onclick=()=>{const g=gc.inferGrid(P.members,ppf);if(!g){toast('No columns to infer a grid from');return;}edit(()=>{P.grid=g;});};
|
|
@@ -1093,14 +1175,14 @@ function renderGridPanel(p){
|
|
|
1093
1175
|
<div class=sect style="margin-top:10px">Labels</div>
|
|
1094
1176
|
<div style="display:flex;gap:6px"><input id=gLX class=gin value="${esc(g.labels_x||'')}" placeholder="auto: 1 2 3…" autocomplete=off spellcheck=false><input id=gLY class=gin value="${esc(g.labels_y||'')}" placeholder="auto: A B C…" autocomplete=off spellcheck=false></div>
|
|
1095
1177
|
<div id=gLfb class=gfb></div>
|
|
1096
|
-
${(!g.labels_x&&!g.labels_y)?'<div class=gbtns style="margin-top:6px"><button id=gridLabelsAiB class=ghostw
|
|
1178
|
+
${(!g.labels_x&&!g.labels_y)?'<div class=gbtns style="margin-top:6px"><button id=gridLabelsAiB class=ghostw data-tip="Many drawings outline their bubble text, so the marks can’t be read deterministically. This sends a request to your AI terminal — it reads the printed marks from the source drawing and fills the labels as a contract update.">Ask AI to read the labels ▸</button></div>':''}
|
|
1097
1179
|
<div class=sect style="margin-top:10px">Extension</div>
|
|
1098
1180
|
<input id=gExt class=gin style="width:110px" value="${esc(gc.fmtLen(isFinite(g.ext)?g.ext:1524))}" autocomplete=off spellcheck=false>
|
|
1099
1181
|
<div class=hint style="margin-top:3px">How far every line runs past the outermost, in plan. Drag a bubble <b>along</b> its line to override just that one.</div>
|
|
1100
|
-
${(()=>{const n=Object.keys(g.ends_x||{}).length+Object.keys(g.ends_y||{}).length;return n?`<div class=gfb style="display:flex;align-items:center;gap:8px;margin-top:5px"><span>${n} line${n===1?'':'s'} with a custom extent</span><button id=gridResetExtB class=ghost style="height:22px;padding:0 8px;font-size:11px"
|
|
1101
|
-
<div class=gbtns><button id=gridPickB class="${gridPick?'on':''}"
|
|
1182
|
+
${(()=>{const n=Object.keys(g.ends_x||{}).length+Object.keys(g.ends_y||{}).length;return n?`<div class=gfb style="display:flex;align-items:center;gap:8px;margin-top:5px"><span>${n} line${n===1?'':'s'} with a custom extent</span><button id=gridResetExtB class=ghost style="height:22px;padding:0 8px;font-size:11px" data-tip="Reset every line back to the Extension value above. Undoable.">Reset all</button></div>`:'';})()}
|
|
1183
|
+
<div class=gbtns><button id=gridPickB class="${gridPick?'on':''}" data-tip="Click the grid's 1-A corner on the plan — snaps to member ends. Esc cancels.">Pick origin</button><button id=gridReadB2 ${filterElsForPlan()?'':'disabled'} data-tip="${filterElsForPlan()?'Re-detect the printed grid from this sheet’s linework — keeps your levels and hand-set labels. Undoable.':'Needs this sheet’s filter linework — re-read the drawing with the steel filter step to capture it.'}">From drawing</button><button id=gridAutoB2 ${nCols<2?'disabled':''} data-tip="${nCols<2?'Needs at least two columns in the model to infer spacing.':'Re-infer spacings and origin from the column layout. Undoable.'}">Auto from columns</button></div>
|
|
1102
1184
|
${gridPick?'<div class=hint style="margin-top:6px">Click the grid’s <b>1-A corner</b> on the plan — snaps to member ends (<b>Alt</b> off). <b>Esc</b> cancels.</div>':''}
|
|
1103
|
-
<div class=gbtns style="margin-top:16px"><button id=gridRemoveB class=ghostw style="flex:1"
|
|
1185
|
+
<div class=gbtns style="margin-top:16px"><button id=gridRemoveB class=ghostw style="flex:1" data-tip="Delete all grid lines and levels. Undoable.">Remove grid</button><button id=gridDoneB>Done</button></div>`;
|
|
1104
1186
|
const fbSpacing=(inpId,fbId,style,override,emptyMsg)=>{
|
|
1105
1187
|
const el=document.getElementById(fbId),v=document.getElementById(inpId).value;
|
|
1106
1188
|
const r=gc.parseSpacings(v);
|
|
@@ -1291,9 +1373,11 @@ function render(){
|
|
|
1291
1373
|
for(const k in grp){const a=grp[k],n=a.length;a.forEach((it,j)=>{const x=it.c[0]+(j-(n-1)/2)*R*2,y=it.c[1];const d=`data-bx="${it.c[0]}" data-fi="${j}" data-gn="${n}"`;
|
|
1292
1374
|
s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
|
|
1293
1375
|
s+=renderDims();
|
|
1376
|
+
s+=renderPropLabels(); // right-click property-label chips (2D); 3D labels ride the div-overlay pool
|
|
1294
1377
|
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)
|
|
1295
1378
|
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(); updGridToggle();
|
|
1296
1379
|
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)
|
|
1380
|
+
syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
|
|
1297
1381
|
}
|
|
1298
1382
|
function updDup(){const n=redundantDups().length;
|
|
1299
1383
|
document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
|
|
@@ -1311,6 +1395,15 @@ function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.da
|
|
|
1311
1395
|
svg.querySelectorAll('text.gridtx').forEach(t=>t.style.fontSize=(12/zoom)+'px');
|
|
1312
1396
|
svg.querySelectorAll('rect.dimchip').forEach(r=>{const w=(+r.dataset.w)/zoom,h=(+r.dataset.h)/zoom;
|
|
1313
1397
|
r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.cx)-w/2);r.setAttribute('y',(+r.dataset.cy)-h/2);});
|
|
1398
|
+
// property-label chips: recompute size + stacked screen offset from the baked anchor (ax,ay) + per-row screen
|
|
1399
|
+
// offset (off) so they stay constant on screen while zooming (like dim chips) — AND re-apply the zoom
|
|
1400
|
+
// threshold live (data-mlen × zoom), so zooming reveals/hides labels as members cross ~24px on screen.
|
|
1401
|
+
{const plG=svg.querySelector('g.pllabels');if(plG){let hid=0;
|
|
1402
|
+
plG.querySelectorAll('rect.plchip').forEach(r=>{const w=(+r.dataset.tw*6.4+12)/zoom,h=15/zoom,cy=(+r.dataset.ay)+(+r.dataset.off)/zoom;
|
|
1403
|
+
const show=(+r.dataset.mlen)*zoom>=PLABEL_MIN_PX;r.style.display=show?'':'none';if(!show)hid++;
|
|
1404
|
+
r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.ax)-w/2);r.setAttribute('y',cy-h/2);});
|
|
1405
|
+
plG.querySelectorAll('text.pltx').forEach(t=>{const show=(+t.dataset.mlen)*zoom>=PLABEL_MIN_PX;t.style.display=show?'':'none';t.setAttribute('y',(+t.dataset.ay)+(+t.dataset.off)/zoom);t.style.fontSize=(11/zoom)+'px';});
|
|
1406
|
+
propLabelsHidden=hid;updatePropHint();}} // hid counts hidden chip ROWS (>0 ⇒ note shows) — the note only needs the boolean
|
|
1314
1407
|
const cg=svg.querySelector('g.csglyph');if(cg&&P.frame)cg.outerHTML=axisGlyphSvg(P.frame.o,P.frame.u,false);} // glyph is sized in 1/zoom → regenerate on zoom (like the dim chips)
|
|
1315
1408
|
function updateHandles(m){svg.querySelectorAll(`circle.handle[data-mid="${m.id}"]`).forEach(h=>{const i=+h.dataset.h;h.setAttribute('cx',m.wp[i][0]);h.setAttribute('cy',m.wp[i][1]);});}
|
|
1316
1409
|
function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
|
|
@@ -1346,7 +1439,7 @@ function panel(){
|
|
|
1346
1439
|
<div class=sect style="margin-top:12px">Direction</div>
|
|
1347
1440
|
<div class=seg2><button id=dimAxFree class="${dimAxis==='free'?'on':''}">Free</button><button id=dimAxX class="${dimAxis==='x'?'on':''}">X</button><button id=dimAxY class="${dimAxis==='y'?'on':''}">Y</button></div>
|
|
1348
1441
|
<div class=sect style="margin-top:12px">Mode</div>
|
|
1349
|
-
<div class=seg2><button id=dimChainB class="${dimChain?'on':''}"
|
|
1442
|
+
<div class=seg2><button id=dimChainB class="${dimChain?'on':''}" data-tip="Chain (C) — keep adding dimensions from the last point; Esc/Enter ends the chain">⛓ Chain</button></div>
|
|
1350
1443
|
<div class=hint style="margin-top:8px">Snaps to grid intersections, member ends, and onto member lines — same as drawing. <b>Alt</b> turns snap off. <b>Esc</b> cancels.</div>`;
|
|
1351
1444
|
document.getElementById('dimAxFree').onclick=()=>{dimSetAxis('free');dimRefreshPrev();panel();};
|
|
1352
1445
|
document.getElementById('dimAxX').onclick=()=>{dimSetAxis('x');dimRefreshPrev();panel();};
|
|
@@ -1357,7 +1450,7 @@ function panel(){
|
|
|
1357
1450
|
p.innerHTML=`<span class=badge>Dimension${selDimIds.size>1?'s · '+selDimIds.size:''}</span>
|
|
1358
1451
|
${one?`<div class=hint style="margin-top:8px">Length <b>${esc(dimValueText(dimGeo(one.a,one.b,one.axis,one.off).px))}</b> · ${({free:'Free (aligned)',x:'X (horizontal)',y:'Y (vertical)'})[one.axis]}</div>`:''}
|
|
1359
1452
|
<div class=sect style="margin-top:12px">Edit</div>
|
|
1360
|
-
<div class=seg2><button id=dimSplitB class="${dimSplitMode?'on':''}"
|
|
1453
|
+
<div class=seg2><button id=dimSplitB class="${dimSplitMode?'on':''}" data-tip="Add split point (S) — click points along the dimension to insert extra dimension points; each click splits the segment under it. Esc ends.">✂ Add split point</button></div>
|
|
1361
1454
|
<div class=hint style="margin-top:8px">${dimSplitMode?'Click points along the dimension to split it — keep clicking to add more. <b>Alt</b> = no snap · <b>Esc</b> ends.':'Drag an end handle to re-measure · <b>Del</b> removes · <b>Esc</b> deselects.'}</div>`;
|
|
1362
1455
|
document.getElementById('dimSplitB').onclick=()=>toggleDimSplit();
|
|
1363
1456
|
return;}
|
|
@@ -1392,7 +1485,7 @@ function panel(){
|
|
|
1392
1485
|
+sec('Orientation')+`<div class=detrow><label class=detf><span>Rotation °</span><input id=detRot inputmode=decimal value="${esc(String(dp.rotZ||0))}" autocomplete=off></label><label class=detf><span>Size</span><input id=detSize inputmode=decimal value="${ftin(dp.size||1000)}" autocomplete=off></label></div>`
|
|
1393
1486
|
+sec('Appearance')+`<div class=detrow style="align-items:center"><span class=detf style="flex:none">Opacity</span><input id=detOpacity type=range min=0 max=100 value="${opPct}"><span id=detOpacityV class=edec style="min-width:36px;text-align:right;font-variant-numeric:tabular-nums">${opPct}%</span></div>`
|
|
1394
1487
|
+`<div class=divrow><hr></div>`
|
|
1395
|
-
+`<div class="row f" style="gap:6px;flex-wrap:wrap"><button class=ghostw id=detAsk
|
|
1488
|
+
+`<div class="row f" style="gap:6px;flex-wrap:wrap"><button class=ghostw id=detAsk data-tip="Record a request for your terminal AI to build/adjust this detail">Ask AI to build this…</button><button class=danger id=detRemove>Remove detail</button></div>`;
|
|
1396
1489
|
p.innerHTML=html;
|
|
1397
1490
|
const find=()=>(C.detail_placements||[]).find(x=>x&&x.id===detId);
|
|
1398
1491
|
const wr=(id,fn)=>{const i=document.getElementById(id);if(i)i.onchange=e=>fn(e.target.value);};
|
|
@@ -1445,12 +1538,12 @@ function panel(){
|
|
|
1445
1538
|
${lbl?`<div class="row" style="margin:3px 0 0;font-size:12px;color:var(--brand);font-variant-numeric:tabular-nums">${esc(lbl)}</div>`:''}
|
|
1446
1539
|
${body}
|
|
1447
1540
|
<div class=divrow><hr></div>
|
|
1448
|
-
<div class="row f"><button class=ghostw id=partEdit
|
|
1541
|
+
<div class="row f"><button class=ghostw id=partEdit data-tip="Select the parent member to edit this connection">✎ Edit on ${esc(j.main)} →</button></div>`;
|
|
1449
1542
|
const eb=document.getElementById('partEdit');if(eb)eb.onclick=()=>{selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
|
|
1450
1543
|
return;
|
|
1451
1544
|
}}
|
|
1452
1545
|
const arr=selArr();
|
|
1453
|
-
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)}"
|
|
1546
|
+
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)}" data-tip="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>'+
|
|
1454
1547
|
'<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div class=sect>Project defaults</div>'+
|
|
1455
1548
|
'<div class=hint style="margin:0 0 6px;font-size:11px">Level <b>'+esc(fmtFtIn(defaultTOS))+'</b> ('+esc(P.sheet)+'). '+((P.tos_callouts&&P.tos_callouts.length)?'Per-zone T.O. STEEL callouts from the drawing applied to each member; ':'')+'ends ticked <b>default</b> follow the level.</div>'+
|
|
1456
1549
|
'<div class=elab>Default TOS</div><input id=defTos inputmode=decimal placeholder="5 3/4" · 1'-0 1/4"" value="'+esc(fmtFtIn(defaultTOS))+'"><span class=edec>'+esc(fmtDecIn(defaultTOS))+'</span>'+
|
|
@@ -1492,7 +1585,7 @@ function panel(){
|
|
|
1492
1585
|
<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>
|
|
1493
1586
|
<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>
|
|
1494
1587
|
<div class="seg2 f"><button id=rBeam class="${allBeam?'on':''}">Beam</button><button id=rCol class="${allCol?'on':''}">Column</button></div>
|
|
1495
|
-
<div class="seg2 mtype" id=mTypeM style="margin-top:6px"
|
|
1588
|
+
<div class="seg2 mtype" id=mTypeM style="margin-top:6px" data-tip="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>
|
|
1496
1589
|
${(!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>`:''}
|
|
1497
1590
|
<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>
|
|
1498
1591
|
<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>
|
|
@@ -1503,7 +1596,7 @@ function panel(){
|
|
|
1503
1596
|
<div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
|
|
1504
1597
|
<div class=seg2 style="margin-top:0"><button id=geoEL class="${geoMode==='el'?'on':''}">Extend / Trim all</button></div>
|
|
1505
1598
|
<div class=hint style="margin-top:6px">${geoMode==='el'?'Click a <b>target line</b> — the nearest end of <b>every</b> selected member snaps to where it meets that line.':'<b>Extend/Trim</b> (E) every selected member\'s nearest end to one line. <b>Esc</b> cancels.'}</div>
|
|
1506
|
-
<div class=row><button class=ghostw id=swapEnds
|
|
1599
|
+
<div class=row><button class=ghostw id=swapEnds data-tip="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>
|
|
1507
1600
|
<div class="row f"><button class=danger id=del>Delete selected (${arr.length})</button></div>`;
|
|
1508
1601
|
// wiring — every commit applies to the whole selection; a blank field is a no-op (leaves each member's own value).
|
|
1509
1602
|
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();}});};
|
|
@@ -1536,7 +1629,7 @@ function panel(){
|
|
|
1536
1629
|
const tosFld=(id,label,o)=>{const def=o.tosDef!==false,v=def?defaultTOS:o.tos;
|
|
1537
1630
|
return `<div class=elabrow><span class=elab>${label}</span><label class=defck><input type=checkbox id=${id}_ck ${def?'checked':''}>default</label></div><input id=${id} inputmode=decimal placeholder="5 3/4" · 1'-0 1/4"" value="${esc(fmtFtIn(v))}"${def?' disabled':''}><span class=edec>${esc(fmtDecIn(v))}</span>`;};
|
|
1538
1631
|
const nin=(id,v)=>`<input id=${id} class="f combo" data-src=conntypes placeholder="connection / note" value="${esc(v||'')}" autocomplete=off>`;
|
|
1539
|
-
const dFld=(id,o)=>{const hasPrev=o.detail&&previewFor(o.detail);return `<div class=elab>Connection detail</div><div style="display:flex;gap:6px"><input id=${id} class=combo data-src=details placeholder="e.g. 5-S504" value="${esc(o.detail||'')}" style="flex:1" autocomplete=off>${hasPrev?`<button id=${id}_open class=ghost
|
|
1632
|
+
const dFld=(id,o)=>{const hasPrev=o.detail&&previewFor(o.detail);return `<div class=elab>Connection detail</div><div style="display:flex;gap:6px"><input id=${id} class=combo data-src=details placeholder="e.g. 5-S504" value="${esc(o.detail||'')}" style="flex:1" autocomplete=off>${hasPrev?`<button id=${id}_open class=ghost data-tip="Open detail ${esc(o.detail)}">⤢</button>`:''}<button id=${id}_pk class="ghost${(picking&&pickKind==='detail'&&pickEnd===o)?' on':''}" data-tip="Pick a detail callout from the drawing">⌖</button></div>`;};
|
|
1540
1633
|
// Per-end connection-library picker (sets o.conn = a connections[] row id). When set, the row's
|
|
1541
1634
|
// type/detail# are the source of truth (shown read-only below); the free-text note/detail dim to a
|
|
1542
1635
|
// fallback. Readonly combo → pick only, no free typing; the connrows source shows every row.
|
|
@@ -1558,7 +1651,7 @@ function panel(){
|
|
|
1558
1651
|
${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')}
|
|
1559
1652
|
<div class=elab style="margin-top:7px;opacity:.7">Anchor kit</div>
|
|
1560
1653
|
${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')}
|
|
1561
|
-
<div class="row f"><button class="ghostw" id=bpRemove
|
|
1654
|
+
<div class="row f"><button class="ghostw" id=bpRemove data-tip="Delete this column's base plate" style="color:#fca5a5;border-color:#7f1d1d">Remove base plate</button></div>`:'';
|
|
1562
1655
|
// This BEAM's shear-plate joints — one params block per detailed END (start/end). Mirrors bpSect but
|
|
1563
1656
|
// per-end (a beam can be detailed at both ends), and adds the clearance + web-side + stiffener controls.
|
|
1564
1657
|
const spjs=col?[]:[0,1].map(e=>({e,j:(C.joints||[]).find(j=>j&&j.kind==='shear-plate'&&j.main===m.id&&j.at==='end'+e)})).filter(x=>x.j);
|
|
@@ -1580,16 +1673,16 @@ function panel(){
|
|
|
1580
1673
|
<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>
|
|
1581
1674
|
<div class=hint style="margin:4px 0 0">Bolt length auto-sizes from the grip (AISC) → shown on the bolt callout.</div>
|
|
1582
1675
|
<div class=elabrow><span class=elab>Opposite stiffener</span><label class=defck><input type=checkbox id="spck_e${e}"${st?' checked':''}>add</label></div>
|
|
1583
|
-
<div class="row f"><button class="ghostw" id="sprm_e${e}"
|
|
1676
|
+
<div class="row f"><button class="ghostw" id="sprm_e${e}" data-tip="Delete this end's shear plate" style="color:#fca5a5;border-color:#7f1d1d">Remove shear plate</button></div>`;};
|
|
1584
1677
|
const spSect=spjs.length?`<div class=hint style="margin:8px 0 0">${spAllAuto?'Auto-added — millimetres; empty = engine default. <b>Clearance</b> = the gap (≈½–¾") so the beam clears the support; <b>Web side</b> picks which face of the web the plate laps. Clear all via <b>Clear shear plates</b> in the toolbar (AI-tuned plates are kept).':'Tune this end's shear plate — millimetres; empty = engine default. <b>Clearance</b> = the gap (≈½–¾") so the beam clears the support; <b>Web side</b> picks which face of the web the plate laps.'}</div>${spjs.map(spBlock).join('')}`:'';
|
|
1585
1678
|
const _cps=(!col&&copesByMember[m.id])?copesByMember[m.id]:[]; // auto cope(s) on this beam (from the rendered scene)
|
|
1586
1679
|
const copeSect=_cps.length?`<div class=divrow><hr><span class=sect style="margin:0">Cope (auto)</span><hr></div><div class=hint style="margin:0 0 2px">${_cps.map(esc).join(' · ')} — auto, clash-driven so the beam clears the support.</div>`:'';
|
|
1587
1680
|
p.innerHTML=`<h3>Member ${esc(m.id)}</h3>
|
|
1588
|
-
<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':''}"
|
|
1681
|
+
<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':''}" data-tip="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>
|
|
1589
1682
|
<div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
|
|
1590
|
-
<div class="seg2 mtype" id=mTypeS style="margin-top:6px"
|
|
1683
|
+
<div class="seg2 mtype" id=mTypeS style="margin-top:6px" data-tip="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>
|
|
1591
1684
|
<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>
|
|
1592
|
-
<div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''}
|
|
1685
|
+
<div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} data-tip="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
|
|
1593
1686
|
${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>`:''}
|
|
1594
1687
|
${elev}
|
|
1595
1688
|
${bpSect}${spSect}${copeSect}
|
|
@@ -1599,7 +1692,7 @@ function panel(){
|
|
|
1599
1692
|
<div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
|
|
1600
1693
|
<div class=seg2 style="margin-top:0"><button id=geoEL class="${geoMode==='el'?'on':''}">Extend / Trim</button><button id=geoSplit class="${geoMode==='split'?'on':''}">Split</button></div>
|
|
1601
1694
|
<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>
|
|
1602
|
-
<div class=row><button class=ghostw id=swapEnds
|
|
1695
|
+
<div class=row><button class=ghostw id=swapEnds data-tip="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>
|
|
1603
1696
|
<div class="row f"><button class=danger id=del>Delete member</button></div>`;
|
|
1604
1697
|
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();}});
|
|
1605
1698
|
document.getElementById('pickProf').onclick=()=>{if(picking&&pickKind==='profile'){picking=false;}else{picking=true;pickKind='profile';pickEnd=null;}render();};
|
|
@@ -1740,6 +1833,160 @@ function snapMark(x,y,kind){kind=kind||lastSnapKind;snapClear();
|
|
|
1740
1833
|
else{const r=7/zoom;el=document.createElementNS(ns,'path');el.setAttribute('d',`M ${x-r} ${y-r} L ${x+r} ${y-r} L ${x-r} ${y+r} L ${x+r} ${y+r} Z`);} // on-line ("nearest") = HOURGLASS/clepsydra (AutoCAD Nearest glyph); circle is reserved for a future Center snap
|
|
1741
1834
|
el.id='snapMark';el.setAttribute('class','snapmk');svg.appendChild(el);document.body.classList.add('snapping');} // marker up → hide the native crosshair (see .snapping CSS)
|
|
1742
1835
|
function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();const g=document.getElementById('snapExtLine');if(g)g.remove();document.body.classList.remove('snapping');}
|
|
1836
|
+
// ===== Right-click property labels (Properties popup → text-chip labels on the 2D/3D canvas) =====
|
|
1837
|
+
// One registry drives BOTH the popup rows and the rendered labels: {key,label,get(m),fmt}. get() returns
|
|
1838
|
+
// the raw value, or `undefined` when the property doesn't apply to that member's role (shown as "—",
|
|
1839
|
+
// checkbox disabled). fmt() turns a raw value into the display string ('' = nothing to label). Values
|
|
1840
|
+
// go through the same imperial formatters the side pane uses; the contract stays canonical metric.
|
|
1841
|
+
function _effTos(o){return o?(o.tosDef!==false?defaultTOS:o.tos):undefined;} // beam end / column top → effective TOS (inches), default-aware
|
|
1842
|
+
function _lenFt(m){return len(m.wp[0],m.wp[1])/FT;}
|
|
1843
|
+
const PROP_DEFS=[
|
|
1844
|
+
{key:'id', label:'Mark', get:m=>m.id, fmt:v=>v==null?'':String(v)},
|
|
1845
|
+
{key:'profile', label:'Profile', get:m=>m.profile, fmt:v=>v==null?'':String(v)},
|
|
1846
|
+
{key:'type', label:'Type', get:m=>MTYPE_LABEL[memberTypeOf(m)], fmt:v=>v==null?'':String(v)},
|
|
1847
|
+
{key:'role', label:'Role', get:m=>m.role==='column'?'Column':'Beam', fmt:v=>v||''},
|
|
1848
|
+
{key:'length', label:'Length', get:m=>_lenFt(m), fmt:v=>v==null?'':v.toFixed(1)+' ft'},
|
|
1849
|
+
{key:'weight', label:'Weight', get:m=>{const w=_wt(m.profile);return w==null?undefined:_lenFt(m)*w;}, fmt:v=>v==null?'':Math.round(v).toLocaleString()+' lb'},
|
|
1850
|
+
{key:'position', label:'Position', get:m=>m.role==='column'?undefined:posDefault(m), fmt:v=>v?v[0].toUpperCase()+v.slice(1):''},
|
|
1851
|
+
{key:'verified', label:'Verified', get:m=>m.verified===true?'Yes':'No', fmt:v=>v||''},
|
|
1852
|
+
{key:'tos_start', label:'TOS (start)', get:m=>m.role==='column'?undefined:_effTos(m.ends&&m.ends[0]), fmt:v=>v==null?'':fmtFtIn(v)},
|
|
1853
|
+
{key:'tos_end', label:'TOS (end)', get:m=>m.role==='column'?undefined:_effTos(m.ends&&m.ends[1]), fmt:v=>v==null?'':fmtFtIn(v)},
|
|
1854
|
+
{key:'col_tos', label:'Top (TOS)', get:m=>m.role==='column'?_effTos(m.col):undefined, fmt:v=>v==null?'':fmtFtIn(v)},
|
|
1855
|
+
{key:'col_bos', label:'Bottom (BOS)', get:m=>m.role==='column'?(m.col?m.col.bos:undefined):undefined, fmt:v=>v==null?'':fmtFtIn(v)},
|
|
1856
|
+
{key:'note_start',label:'Note (start)', get:m=>m.role==='column'?undefined:((m.ends&&m.ends[0])?(m.ends[0].note||''):undefined), fmt:v=>v==null?'':String(v)},
|
|
1857
|
+
{key:'note_end', label:'Note (end)', get:m=>m.role==='column'?undefined:((m.ends&&m.ends[1])?(m.ends[1].note||''):undefined), fmt:v=>v==null?'':String(v)},
|
|
1858
|
+
{key:'col_note', label:'Note', get:m=>m.role==='column'?(m.col?(m.col.note||''):undefined):undefined, fmt:v=>v==null?'':String(v)},
|
|
1859
|
+
{key:'detail_start',label:'Detail (start)',get:m=>m.role==='column'?undefined:((m.ends&&m.ends[0])?(m.ends[0].detail||''):undefined), fmt:v=>v==null?'':String(v)},
|
|
1860
|
+
{key:'detail_end',label:'Detail (end)', get:m=>m.role==='column'?undefined:((m.ends&&m.ends[1])?(m.ends[1].detail||''):undefined), fmt:v=>v==null?'':String(v)},
|
|
1861
|
+
{key:'col_detail',label:'Detail', get:m=>m.role==='column'?(m.col?(m.col.detail||''):undefined):undefined, fmt:v=>v==null?'':String(v)},
|
|
1862
|
+
];
|
|
1863
|
+
const PROP_KEYS=new Set(PROP_DEFS.map(d=>d.key));
|
|
1864
|
+
// normalise persisted/incoming prop_labels to known keys/placement so a corrupt draft can't desync the UI
|
|
1865
|
+
function sanitizePropLabels(x){const o=(x&&typeof x==='object'&&!Array.isArray(x))?x:{};
|
|
1866
|
+
return {props:Array.isArray(o.props)?o.props.filter(k=>PROP_KEYS.has(k)):[],
|
|
1867
|
+
placement:['start','mid','end'].includes(o.placement)?o.placement:'mid',
|
|
1868
|
+
selected_only:o.selected_only===true,
|
|
1869
|
+
ids:Array.isArray(o.ids)?o.ids.filter(v=>typeof v==='string'):[]};}
|
|
1870
|
+
C.prop_labels=sanitizePropLabels(C.prop_labels); // normalise the contract's incoming value now (PROP_KEYS is initialised above; runs before main's bootstrap render + restoreSaved override)
|
|
1871
|
+
// the "Label: value" lines a member contributes for the currently-checked props (registry order; skips N/A + empty)
|
|
1872
|
+
function propLabelLinesFor(m){const pl=C.prop_labels;if(!pl||!pl.props.length)return [];ensureMeta(m);
|
|
1873
|
+
const out=[];for(const def of PROP_DEFS){if(!pl.props.includes(def.key))continue;const raw=def.get(m);if(raw===undefined)continue;const t=def.fmt(raw);if(t==='')continue;out.push(def.label+': '+t);}return out;}
|
|
1874
|
+
// members that actually get a label, honouring the plan-wide-vs-selected-only scope
|
|
1875
|
+
function propLabelMembers(){const pl=C.prop_labels;if(!pl||!pl.props.length)return [];
|
|
1876
|
+
let base=P.members;if(pl.selected_only){const ids=new Set(pl.ids||[]);base=P.members.filter(m=>ids.has(m.id));}
|
|
1877
|
+
return base.filter(m=>propLabelLinesFor(m).length>0);}
|
|
1878
|
+
// one popup row's aggregate across the current selection: a shared value, "Varies", or N/A (disabled)
|
|
1879
|
+
function propAggRow(def,arr){let anyApplicable=false,anyNonEmpty=false;const vals=[];
|
|
1880
|
+
for(const m of arr){ensureMeta(m);const raw=def.get(m);if(raw===undefined)continue;anyApplicable=true;const t=def.fmt(raw);vals.push(t);if(t!=='')anyNonEmpty=true;}
|
|
1881
|
+
if(!anyApplicable||!anyNonEmpty)return {state:'na'};
|
|
1882
|
+
const nonEmpty=[...new Set(vals.filter(t=>t!==''))];
|
|
1883
|
+
if(nonEmpty.length===1&&vals.every(t=>t===nonEmpty[0]))return {state:'val',text:nonEmpty[0]};
|
|
1884
|
+
return {state:'varies'};}
|
|
1885
|
+
|
|
1886
|
+
// ---- 2D SVG chips (rendered inside render(); zoom-constant via data-ax/ay/off, rescaled in updateBadges) ----
|
|
1887
|
+
let propLabelsHidden=0;
|
|
1888
|
+
// A member's labels hide when its on-screen length < this (px) — the density guard (§5.5). Kept as one
|
|
1889
|
+
// constant so renderPropLabels (initial paint) and updateBadges (live on zoom) agree.
|
|
1890
|
+
const PLABEL_MIN_PX=24;
|
|
1891
|
+
function renderPropLabels(){const pl=C.prop_labels;propLabelsHidden=0;if(!pl||!pl.props.length)return '';
|
|
1892
|
+
const ms=propLabelMembers();if(!ms.length)return '';
|
|
1893
|
+
let hidden=0,s='';
|
|
1894
|
+
// Emit chips for EVERY labelled member (carrying its display length in data-mlen); the zoom threshold
|
|
1895
|
+
// only sets each chip's initial visibility. updateBadges re-applies it live as the user zooms — so
|
|
1896
|
+
// zooming in reveals a hidden label and zooming out hides it (they're in the DOM either way).
|
|
1897
|
+
for(const m of ms){const lines=propLabelLinesFor(m);if(!lines.length)continue;
|
|
1898
|
+
const a=m.wp[0],b=m.wp[1],mlen=len(a,b);const show=mlen*zoom>=PLABEL_MIN_PX;if(!show)hidden++;
|
|
1899
|
+
const anc=pl.placement==='start'?a:pl.placement==='end'?b:[(a[0]+b[0])/2,(a[1]+b[1])/2];
|
|
1900
|
+
const n=lines.length,hide=show?'':';display:none';
|
|
1901
|
+
lines.forEach((txt,i)=>{const off=(i-(n-1)/2)*15,tw=txt.length,w=(tw*6.4+12)/zoom,h=15/zoom,cy=anc[1]+off/zoom;
|
|
1902
|
+
s+=`<rect class=plchip data-ax="${anc[0]}" data-ay="${anc[1]}" data-off="${off}" data-tw="${tw}" data-mlen="${mlen}" x="${anc[0]-w/2}" y="${cy-h/2}" width="${w}" height="${h}" rx="${3/zoom}" style="${hide}"/>`
|
|
1903
|
+
+`<text class=pltx data-ax="${anc[0]}" data-ay="${anc[1]}" data-off="${off}" data-mlen="${mlen}" x="${anc[0]}" y="${cy}" style="font-size:${11/zoom}px${hide}">${esc(txt)}</text>`;});}
|
|
1904
|
+
propLabelsHidden=hidden;
|
|
1905
|
+
return `<g class="pllabels">${s}</g>`;}
|
|
1906
|
+
// fixed-screen corner note when the zoom threshold is hiding labels (an HTML pill, outside the zoomed SVG)
|
|
1907
|
+
function updatePropHint(){let el=document.getElementById('plHint');
|
|
1908
|
+
const show=!view3d&&propLabelsHidden>0;
|
|
1909
|
+
if(!show){if(el)el.style.display='none';return;}
|
|
1910
|
+
if(!el){el=document.createElement('div');el.id='plHint';el.style.cssText='position:fixed;z-index:20;pointer-events:none;background:var(--panel);color:var(--mut);border:1px solid var(--line);border-radius:6px;padding:3px 9px;font:11px system-ui;box-shadow:0 2px 8px rgba(0,0,0,.4)';document.body.appendChild(el);}
|
|
1911
|
+
const st=document.getElementById('stage').getBoundingClientRect();
|
|
1912
|
+
el.textContent='Labels hidden — zoom in';el.style.display='block';
|
|
1913
|
+
el.style.left=(st.left+st.width/2-70)+'px';el.style.top=(st.bottom-30)+'px';}
|
|
1914
|
+
|
|
1915
|
+
// ---- 3D bridge: hand the label spec to the 3D view (it owns projection/placement over member geometry) ----
|
|
1916
|
+
function refreshPropLabels3d(){const V=window.Steel3DView;if(!V||!V.setPropLabels)return;
|
|
1917
|
+
const pl=C.prop_labels;
|
|
1918
|
+
if(!pl||!pl.props.length){V.setPropLabels(null);return;}
|
|
1919
|
+
V.setPropLabels({labels:propLabelMembers().map(m=>({id:m.id,lines:propLabelLinesFor(m)})),placement:pl.placement});}
|
|
1920
|
+
|
|
1921
|
+
// ---- The floating Properties popup ----
|
|
1922
|
+
let propPopPinned=false;
|
|
1923
|
+
function propPopOpen(){const el=document.getElementById('propPop');return !!(el&&el.classList.contains('open'));}
|
|
1924
|
+
function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
|
|
1925
|
+
el=document.createElement('div');el.id='propPop';el.setAttribute('role','dialog');el.setAttribute('aria-label','Member properties');
|
|
1926
|
+
el.innerHTML=`<div class=pph><b id=ppTitle>Properties</b><span class=pcount id=ppLabeled></span>`
|
|
1927
|
+
+`<button class=pin id=ppPin data-tip="Pin — keep open across selections">📌</button>`
|
|
1928
|
+
+`<button id=ppClear data-tip="Uncheck every label">Clear all</button>`
|
|
1929
|
+
+`<button id=ppClose data-tip="Close (Esc)">✕</button></div>`
|
|
1930
|
+
+`<div class=ppsearch><input id=ppSearch placeholder="Search properties…" autocomplete=off aria-label="Search properties"></div>`
|
|
1931
|
+
+`<div class=ppmeta id=ppMeta></div><div class=ppscope id=ppScope></div>`
|
|
1932
|
+
+`<div class=pplist id=ppList></div>`
|
|
1933
|
+
+`<div class=ppfoot><div class=seg2 id=ppPlace><button data-pl=start>Start</button><button data-pl=mid>Middle</button><button data-pl=end>End</button></div>`
|
|
1934
|
+
+`<label><input type=checkbox id=ppSel>Selected only</label></div>`;
|
|
1935
|
+
document.body.appendChild(el);
|
|
1936
|
+
const list=el.querySelector('#ppList');
|
|
1937
|
+
list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row||row.classList.contains('dis'))return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);});
|
|
1938
|
+
list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row)togglePropKey(row.dataset.k,cb.checked);});
|
|
1939
|
+
el.querySelector('#ppSearch').addEventListener('input',renderPropPop);
|
|
1940
|
+
el.querySelector('#ppClear').onclick=()=>{C.prop_labels.props=[];refreshPropLabels();};
|
|
1941
|
+
el.querySelector('#ppClose').onclick=()=>closePropPop(true);
|
|
1942
|
+
el.querySelector('#ppPin').onclick=()=>{propPopPinned=!propPopPinned;if(propPopPinned)dockPropPop();renderPropPop();};
|
|
1943
|
+
el.querySelector('#ppPlace').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;C.prop_labels.placement=b.dataset.pl;refreshPropLabels();});
|
|
1944
|
+
el.querySelector('#ppSel').addEventListener('change',e=>{const pl=C.prop_labels;pl.selected_only=e.target.checked;if(pl.selected_only)pl.ids=selArr().map(m=>m.id);refreshPropLabels();});
|
|
1945
|
+
// focus trap + Esc (behaves as a lightweight non-modal dialog)
|
|
1946
|
+
el.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();closePropPop(true);return;}
|
|
1947
|
+
if(e.key!=='Tab')return;const f=[...el.querySelectorAll('button,input,[tabindex]')].filter(n=>!n.disabled&&n.offsetParent!==null);
|
|
1948
|
+
if(!f.length)return;const first=f[0],last=f[f.length-1];
|
|
1949
|
+
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
|
|
1950
|
+
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}});
|
|
1951
|
+
return el;}
|
|
1952
|
+
function togglePropKey(k,on){const pl=C.prop_labels,has=pl.props.includes(k);if(on===undefined)on=!has;
|
|
1953
|
+
if(on&&!has)pl.props.push(k);else if(!on&&has)pl.props=pl.props.filter(x=>x!==k);
|
|
1954
|
+
if(on&&pl.selected_only&&selArr().length)pl.ids=selArr().map(m=>m.id); // Selected-only: re-target the pinned set to the current selection when adding a label (so checking a prop after re-selecting labels what's actually selected, not a stale set)
|
|
1955
|
+
refreshPropLabels();}
|
|
1956
|
+
// rebuild the popup contents against the current selection (chrome + rows), preserving row focus
|
|
1957
|
+
function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el.classList.contains('open'))return;
|
|
1958
|
+
const arr=selArr(),pl=C.prop_labels,q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
|
|
1959
|
+
el.querySelector('#ppTitle').textContent='Properties ('+arr.length+' selected)';
|
|
1960
|
+
el.querySelector('#ppLabeled').textContent=pl.props.length?pl.props.length+' labeled':'';
|
|
1961
|
+
el.querySelector('#ppScope').textContent=pl.selected_only?'Labels apply to the selected members':'Labels apply to all members on this plan';
|
|
1962
|
+
el.querySelectorAll('#ppPlace button').forEach(b=>b.classList.toggle('on',b.dataset.pl===pl.placement));
|
|
1963
|
+
el.querySelector('#ppSel').checked=pl.selected_only;
|
|
1964
|
+
const ae=document.activeElement,fk=(ae&&ae.closest&&ae.closest('.pprow'))?ae.closest('.pprow').dataset.k:null;
|
|
1965
|
+
let shown=0;const rows=PROP_DEFS.map(def=>{
|
|
1966
|
+
if(q&&!def.label.toLowerCase().includes(q))return '';
|
|
1967
|
+
shown++;const r=propAggRow(def,arr),checked=pl.props.includes(def.key),dis=r.state==='na';
|
|
1968
|
+
const val=r.state==='val'?esc(r.text):r.state==='varies'?'Varies':'—',vc=r.state==='varies'?' varies':'';
|
|
1969
|
+
return `<div class="pprow${dis?' dis':''}" data-k="${def.key}"${dis?' data-tip="Nothing to label — no value in the selection"':''}>`
|
|
1970
|
+
+`<input type=checkbox ${checked?'checked':''}${dis?' disabled':''} aria-label="${esc(def.label)}">`
|
|
1971
|
+
+`<span class=pn>${esc(def.label)}</span><span class="pv${vc}">${val}</span></div>`;}).join('');
|
|
1972
|
+
el.querySelector('#ppList').innerHTML=rows||'<div class=ppempty>No properties match your search.</div>';
|
|
1973
|
+
el.querySelector('#ppMeta').textContent=shown+' of '+PROP_DEFS.length+' shown';
|
|
1974
|
+
if(fk){const cb=el.querySelector('.pprow[data-k="'+fk+'"] input');if(cb)cb.focus();}}
|
|
1975
|
+
function dockPropPop(){const el=propPopEl(),st=document.getElementById(view3d?'stage3d':'stage').getBoundingClientRect();
|
|
1976
|
+
el.style.left=Math.max(4,st.right-el.offsetWidth-12)+'px';el.style.top=(st.top+12)+'px';}
|
|
1977
|
+
function openPropLabels(x,y){if(!selArr().length)return;const el=propPopEl();const sr=el.querySelector('#ppSearch');
|
|
1978
|
+
sr.value=''; // clear any prior filter BEFORE rendering rows, so reopening never shows a filtered list under a blank search box
|
|
1979
|
+
el.classList.add('open');renderPropPop();
|
|
1980
|
+
if(propPopPinned){dockPropPop();}
|
|
1981
|
+
else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
|
|
1982
|
+
setTimeout(()=>sr.focus(),0);}
|
|
1983
|
+
function closePropPop(force){const el=document.getElementById('propPop');if(!el)return;if(propPopPinned&&!force)return;el.classList.remove('open');
|
|
1984
|
+
const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
|
|
1985
|
+
document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
|
|
1986
|
+
// after every render the checked labels + the popup rows stay in sync with the (possibly changed) selection/geometry
|
|
1987
|
+
function syncPropLabelsAfterRender(){updatePropHint();refreshPropLabels3d();if(propPopOpen())renderPropPop();}
|
|
1988
|
+
function refreshPropLabels(){scheduleSave();render();} // render() → renderPropLabels() (2D) + syncPropLabelsAfterRender() (hint/3D/popup)
|
|
1989
|
+
|
|
1743
1990
|
// --- Tekla-style snap override (right-click): restrict snapping to ONE type for the current operation.
|
|
1744
1991
|
// 2D: right-click anywhere on the canvas (set it before grabbing an endpoint — a drag captures the pointer).
|
|
1745
1992
|
// 3D: right-CLICK while the Dimension tool is armed (right-DRAG stays orbit/pan). The override clears when
|
|
@@ -1768,11 +2015,17 @@ function openSnapMenu(x,y,is3d){const m=snapMenuEl();m._is3d=!!is3d;
|
|
|
1768
2015
|
m.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';m.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';
|
|
1769
2016
|
m.focus();}
|
|
1770
2017
|
document.addEventListener('pointerdown',e=>{if(snapMenuOpen()&&!e.target.closest('#snapMenu'))closeSnapMenu();},true);
|
|
1771
|
-
|
|
2018
|
+
// Right-click routes by intent (spec §2a — the two conditions are mutually exclusive, so exactly one opens):
|
|
2019
|
+
// a selection in plain select mode → the Properties popup; otherwise (no selection, or a placing/measuring
|
|
2020
|
+
// mode armed) → the snap-override menu. This gates the previously-unconditional 2D snap menu.
|
|
2021
|
+
document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDefault();
|
|
2022
|
+
if(mode==='sel'&&!dimMode&&!csaxisMode&&!dimSplitMode&&!geoMode&&!cmTool&&!picking&&selArr().length){openPropLabels(e.clientX,e.clientY);return;}
|
|
2023
|
+
openSnapMenu(e.clientX,e.clientY,false);});
|
|
1772
2024
|
document.getElementById('stage3d').addEventListener('contextmenu',e=>{e.preventDefault();const V=window.Steel3DView;
|
|
1773
|
-
if(
|
|
1774
|
-
if(V.
|
|
1775
|
-
|
|
2025
|
+
if(V&&V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click — no menu
|
|
2026
|
+
if(V&&V.dimToolOn&&V.dimToolOn()){openSnapMenu(e.clientX,e.clientY,true);return;} // dim tool armed → snap-override menu (unchanged)
|
|
2027
|
+
if(mode==='sel'&&!cmTool&&!picking&&selArr().length){openPropLabels(e.clientX,e.clientY);return;} // a selection → Properties popup
|
|
2028
|
+
});
|
|
1776
2029
|
document.getElementById('snapStat').onclick=()=>{snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);updSnapStat();};
|
|
1777
2030
|
// --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
|
|
1778
2031
|
// buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
|
|
@@ -2079,7 +2332,7 @@ function axisGlyphSvg(o,dir,preview){
|
|
|
2079
2332
|
+tag(lx,'#ef4444','X')+tag(ly,'#22c55e','Y')+`</g>`;}
|
|
2080
2333
|
// header chip + the More-menu "Reset to global" enabled state — reflect the current frame.
|
|
2081
2334
|
function updCS(){const s=document.getElementById('csStat');
|
|
2082
|
-
if(s){const f=P&&P.frame;s.innerHTML='Axes <b>'+(f?(Math.round(Math.atan2(f.u[1],f.u[0])*180/Math.PI)+'° local'):'Global')+'</b>';s.classList.toggle('local',!!f);s.
|
|
2335
|
+
if(s){const f=P&&P.frame;s.innerHTML='Axes <b>'+(f?(Math.round(Math.atan2(f.u[1],f.u[0])*180/Math.PI)+'° local'):'Global')+'</b>';s.classList.toggle('local',!!f);s.dataset.tip=f?'Local coordinate frame active — ortho draw & X/Y dimensions follow it. Reset via the ⋯ menu.':'Global axes (default). Set a local frame from the ⋯ menu for skewed framing.';}
|
|
2083
2336
|
const r=document.getElementById('csResetB');if(r)r.disabled=!(P&&P.frame);}
|
|
2084
2337
|
// themed transient toast (baseline tokens — never a native alert)
|
|
2085
2338
|
function toast(msg){let t=document.getElementById('toast');
|
|
@@ -2362,10 +2615,13 @@ function moreOpen(){return moreMenu.classList.contains('open');}
|
|
|
2362
2615
|
function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)closeMore();}
|
|
2363
2616
|
function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
|
|
2364
2617
|
moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
|
|
2365
|
-
moreMenu.addEventListener('click',e=>{if(e.target.closest('button')&&!e.target.closest('.msnap')&&!e.target.closest('.
|
|
2618
|
+
moreMenu.addEventListener('click',e=>{if(e.target.closest('button')&&!e.target.closest('.msnap')&&!e.target.closest('.msec-hdr')&&!e.target.closest('.ins-in-menu'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu; the snap toggles, section headers, and the Insert picker keep the menu open (settings, not one-shot actions)
|
|
2366
2619
|
// "Snapping" is collapsible to save menu space — the header expands the running-snap switches below it
|
|
2367
|
-
|
|
2368
|
-
|
|
2620
|
+
// Every ⋯ section is a collapse/expand accordion (Snapping + Display + Detailing + …); state persists in localStorage.
|
|
2621
|
+
{const SEC_KEY='steelMoreSections';let openSecs;try{openSecs=new Set(JSON.parse(localStorage.getItem(SEC_KEY)||'[]'));}catch(e){openSecs=new Set();}
|
|
2622
|
+
function setSec(hdr,open){const body=hdr.nextElementSibling;if(!body)return;body.classList.toggle('open',open);hdr.setAttribute('aria-expanded',String(open));const k=hdr.dataset.sec;if(k){open?openSecs.add(k):openSecs.delete(k);try{localStorage.setItem(SEC_KEY,JSON.stringify([...openSecs]));}catch(e){}}}
|
|
2623
|
+
document.querySelectorAll('#moreMenu .msec-hdr').forEach(hdr=>{setSec(hdr,openSecs.has(hdr.dataset.sec)); // restore
|
|
2624
|
+
hdr.onclick=e=>{e.stopPropagation();setSec(hdr,!hdr.nextElementSibling.classList.contains('open'));};});}
|
|
2369
2625
|
// --- Running-snaps: one source of truth (snapEnabled) rendered onto TWO surfaces — the ⋯ menu switches
|
|
2370
2626
|
// (aria-checked) + the quick-access snap bar (aria-pressed). toggleSnap() is the only writer; both surfaces
|
|
2371
2627
|
// call it, so they can't drift. The transient right-click override is separate and never writes here. ---
|
|
@@ -2472,7 +2728,7 @@ const view3dApi={
|
|
|
2472
2728
|
onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
|
|
2473
2729
|
beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
|
|
2474
2730
|
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)
|
|
2475
|
-
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'
|
|
2731
|
+
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert detail…';}}, // armed → cancel target
|
|
2476
2732
|
onInsertPlace:(pick,pending)=>{if(!pending||!pending.name){toast('Pick a detail to insert first');return;} // Slice 4: place the queued detail at the pick, select it, record the create intent
|
|
2477
2733
|
const id='det'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);const sheet=(P&&P.sheet)||'';
|
|
2478
2734
|
const place={id,detailName:pending.name,sheet,anchorId:pick.anchorId||null,pos:pick.point,u:pick.u,n:pick.n,rotZ:0,size:1000,opacity:1};
|
|
@@ -2609,7 +2865,7 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
2609
2865
|
if(!groups.length){host.style.display='none';return;}
|
|
2610
2866
|
const hint=document.createElement('div');hint.className='lhint';hint.textContent='click hide/show · dbl-click isolate · Ctrl/Shift multi';host.appendChild(hint);
|
|
2611
2867
|
const addRow=(g,indent,draggable)=>{const row=document.createElement('div');row.className='lrow'+(indent?' typed':'');row.dataset.key=g.key;
|
|
2612
|
-
if(draggable){const dh=document.createElement('span');dh.className='drag-handle';dh.textContent='⠿';dh.
|
|
2868
|
+
if(draggable){const dh=document.createElement('span');dh.className='drag-handle';dh.textContent='⠿';dh.dataset.tip='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
|
|
2613
2869
|
const sw=document.createElement('span');sw.className='lsw';sw.style.background=g.color;
|
|
2614
2870
|
row.append(sw,document.createTextNode(g.label));
|
|
2615
2871
|
row.addEventListener('click',()=>{if(row._dragging)return;clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
|
|
@@ -2620,7 +2876,7 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
2620
2876
|
// getState()→'on'|'off'|'mixed' drives the master glyph; onToggle() runs the master action (refresh follows).
|
|
2621
2877
|
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;
|
|
2622
2878
|
const chev=Object.assign(document.createElement('span'),{className:'cat-chevron',textContent:collapsedCats.has(cat)?'▶':'▼'});
|
|
2623
|
-
const tog=Object.assign(document.createElement('span'),{className:'cat-tog'
|
|
2879
|
+
const tog=Object.assign(document.createElement('span'),{className:'cat-tog'});tog.dataset.tip=opts.toggleTitle||('Show / hide all '+label.toLowerCase());if(opts.empty||!opts.onToggle)tog.style.display='none';
|
|
2624
2880
|
const lab=Object.assign(document.createElement('span'),{className:'cat-label',textContent:label});
|
|
2625
2881
|
const cnt=Object.assign(document.createElement('span'),{className:'cat-count',textContent:'('+count+')'});
|
|
2626
2882
|
hdr.append(chev,tog,lab,cnt);
|
|
@@ -2698,7 +2954,7 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
2698
2954
|
// the grid's colour, not the cyan dim overlays.
|
|
2699
2955
|
if(typeof P!=='undefined'&&P&&P.grid){
|
|
2700
2956
|
host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
|
|
2701
|
-
const grow=document.createElement('div');grow.className='lrow dim';grow.
|
|
2957
|
+
const grow=document.createElement('div');grow.className='lrow dim';grow.dataset.tip='Show / hide the structural grid (2D + 3D)';
|
|
2702
2958
|
const gsw=document.createElement('span');gsw.className='lsw';gsw.style.borderColor='#64748b';
|
|
2703
2959
|
grow.append(gsw,document.createTextNode('Grid lines'));
|
|
2704
2960
|
grow.classList.toggle('dimoff',!gridOn());
|
|
@@ -2718,10 +2974,10 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
2718
2974
|
else for(const c of clips){
|
|
2719
2975
|
// Three separate zones (no fighting): swatch/label = SELECT (reveals its 3D drag handles), On/Off pill = ENABLE, × = DELETE.
|
|
2720
2976
|
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
|
|
2721
|
-
const sw=document.createElement('span');sw.className='lsw';sw.style.background=c.kind==='box'?'#93c5fd':'#3b82f6';sw.
|
|
2722
|
-
const lab=document.createElement('span');lab.className='clab';lab.textContent=c.label;lab.
|
|
2723
|
-
const tog=document.createElement('button');tog.className='cpill'+(c.enabled?' on':'');tog.textContent=c.enabled?'On':'Off';tog.
|
|
2724
|
-
const x=document.createElement('span');x.className='lx';x.textContent='×';x.
|
|
2977
|
+
const sw=document.createElement('span');sw.className='lsw';sw.style.background=c.kind==='box'?'#93c5fd':'#3b82f6';sw.dataset.tip='Select — show its drag handles in 3D'; // box = lighter blue, plane = brand blue
|
|
2978
|
+
const lab=document.createElement('span');lab.className='clab';lab.textContent=c.label;lab.dataset.tip='Click to select · double-click to rename';
|
|
2979
|
+
const tog=document.createElement('button');tog.className='cpill'+(c.enabled?' on':'');tog.textContent=c.enabled?'On':'Off';tog.dataset.tip='Enable / disable this clip';
|
|
2980
|
+
const x=document.createElement('span');x.className='lx';x.textContent='×';x.dataset.tip='Delete this clip';
|
|
2725
2981
|
row.append(sw,lab,tog,x);
|
|
2726
2982
|
sw.addEventListener('click',e=>{e.stopPropagation();clipSelect(c.id,e);}); // Ctrl/Shift = multi-select (same as parts/dims)
|
|
2727
2983
|
let clipClickT=null;
|
|
@@ -2755,11 +3011,24 @@ function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3D
|
|
|
2755
3011
|
document.querySelectorAll('#m3dLegend .cat-hdr').forEach(updateCatTog);} // refresh the type-category master toggles too
|
|
2756
3012
|
let bar3dWired=false;
|
|
2757
3013
|
function seg3dActive(sel,attr,val){document.querySelectorAll(sel+' button').forEach(b=>b.classList.toggle('on',b.getAttribute(attr)===val));}
|
|
3014
|
+
// Reflect the live projection / display mode into the Camera + Display dropdowns: tick the active menu item AND label the trigger button, so the current mode shows without opening the menu.
|
|
3015
|
+
function reflectProj(){const V=window.Steel3DView;if(!V)return;const p=V.projection();seg3dActive('#m3dProj','data-proj',p);const b=document.getElementById('m3dProjBtn');if(b)b.textContent=(p==='ortho'?'Ortho':'Persp')+' ▾';}
|
|
3016
|
+
function reflectMode(){const V=window.Steel3DView;if(!V)return;const m=V.mode();seg3dActive('#m3dMode','data-mode',m);const b=document.getElementById('m3dModeBtn');if(b)b.textContent=(m==='wire'?'Wire':m==='xray'?'X-ray':'Solid')+' ▾';}
|
|
2758
3017
|
function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
2759
|
-
|
|
2760
|
-
document.
|
|
3018
|
+
// Camera + Display dropdowns — open/close like Clip/Work/Plane; picking an item sets the mode, ticks it, and relabels the trigger (reflectProj/reflectMode).
|
|
3019
|
+
const projBtn=document.getElementById('m3dProjBtn'),projMenu=document.getElementById('m3dProj');
|
|
3020
|
+
function projMenuOutside(e){if(!projMenu.contains(e.target)&&e.target!==projBtn)projMenuClose();}
|
|
3021
|
+
function projMenuClose(){projMenu.classList.remove('open');document.removeEventListener('mousedown',projMenuOutside,true);}
|
|
3022
|
+
projBtn.onclick=e=>{e.stopPropagation();if(projMenu.classList.contains('open'))projMenuClose();else{projMenu.classList.add('open');document.addEventListener('mousedown',projMenuOutside,true);}};
|
|
3023
|
+
projMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{window.Steel3DView.setProjection(b.dataset.proj);reflectProj();projMenuClose();});
|
|
3024
|
+
const modeBtn=document.getElementById('m3dModeBtn'),modeMenu=document.getElementById('m3dMode');
|
|
3025
|
+
function modeMenuOutside(e){if(!modeMenu.contains(e.target)&&e.target!==modeBtn)modeMenuClose();}
|
|
3026
|
+
function modeMenuClose(){modeMenu.classList.remove('open');document.removeEventListener('mousedown',modeMenuOutside,true);}
|
|
3027
|
+
modeBtn.onclick=e=>{e.stopPropagation();if(modeMenu.classList.contains('open'))modeMenuClose();else{modeMenu.classList.add('open');document.addEventListener('mousedown',modeMenuOutside,true);}};
|
|
3028
|
+
modeMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{window.Steel3DView.setDisplayMode(b.dataset.mode);reflectMode();modeMenuClose();});
|
|
2761
3029
|
document.getElementById('m3dFit').onclick=()=>window.Steel3DView.frameAll();
|
|
2762
|
-
document.getElementById('
|
|
3030
|
+
document.getElementById('m3dFitSel').onclick=()=>window.Steel3DView.frameSelection(); // Zoom to selected (Alt+Z already bound in steel-3d-view.js)
|
|
3031
|
+
document.getElementById('m3dRef').onchange=e=>window.Steel3DView.setRefLine(e.target.checked); // View ▾ menu checkbox
|
|
2763
3032
|
const d3=window.Steel3DView;
|
|
2764
3033
|
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)
|
|
2765
3034
|
document.querySelectorAll('#m3dDimAxis button').forEach(b=>b.onclick=()=>{d3.setDimAxis(b.dataset.d3axis);seg3dActive('#m3dDimAxis','data-d3axis',b.dataset.d3axis);});
|
|
@@ -2775,8 +3044,16 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
|
2775
3044
|
else{clipMenu.classList.add('open');document.addEventListener('mousedown',clipMenuOutside,true);}};
|
|
2776
3045
|
clipMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{clipMenuClose();const a=b.dataset.clip;
|
|
2777
3046
|
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();});
|
|
2778
|
-
// Labels: a
|
|
2779
|
-
document.getElementById('m3dLabels').
|
|
3047
|
+
// Labels: a checkbox in the View ▾ menu (mirrors Ref line) — show each member's mark/id in the 3D view.
|
|
3048
|
+
document.getElementById('m3dLabels').onchange=e=>d3.setLabelsOn(e.target.checked);
|
|
3049
|
+
// View menu: groups the Ref line + Labels toggles (same open/close discipline as Clip / Work area / Plane).
|
|
3050
|
+
const viewBtn=document.getElementById('m3dView'),viewMenu=document.getElementById('m3dViewMenu');
|
|
3051
|
+
function viewMenuOutside(e){if(!viewMenu.contains(e.target)&&e.target!==viewBtn)viewMenuClose();}
|
|
3052
|
+
function viewMenuClose(){viewMenu.classList.remove('open');document.removeEventListener('mousedown',viewMenuOutside,true);}
|
|
3053
|
+
viewBtn.onclick=e=>{e.stopPropagation();
|
|
3054
|
+
if(viewMenu.classList.contains('open'))viewMenuClose();
|
|
3055
|
+
else{document.getElementById('m3dRef').checked=!!d3.refLine();document.getElementById('m3dLabels').checked=!!d3.labelsOn(); // reflect live state on open
|
|
3056
|
+
viewMenu.classList.add('open');document.addEventListener('mousedown',viewMenuOutside,true);}};
|
|
2780
3057
|
// Insert a detail: the ▾ menu lists the detail library + an "add image" option; picking one arms a
|
|
2781
3058
|
// placement pick (onInsertPlace drops it). While armed the button is a cancel target (onInsertModeChange).
|
|
2782
3059
|
// The menu is built with DOM nodes (textContent) — the detail names are user text, never innerHTML.
|
|
@@ -2786,10 +3063,10 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
|
2786
3063
|
function insMenuBuild(){const names=Object.keys(C.custom_details||{}).sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
|
|
2787
3064
|
const frag=document.createDocumentFragment();
|
|
2788
3065
|
const lab=document.createElement('div');lab.className='mlabel';lab.textContent='Place a detail';frag.appendChild(lab);
|
|
2789
|
-
if(names.length){for(const n of names){const b=document.createElement('button');b.textContent=n;b.
|
|
3066
|
+
if(names.length){for(const n of names){const b=document.createElement('button');b.textContent=n;b.dataset.tip='Place '+n;b.onclick=()=>{insMenuClose();closeMore();armInsert(n);};frag.appendChild(b);}}
|
|
2790
3067
|
else{const note=document.createElement('div');note.style.cssText='padding:4px 10px;color:var(--mut);font-size:12px';note.textContent='No saved details yet — add one below.';frag.appendChild(note);}
|
|
2791
3068
|
frag.appendChild(document.createElement('hr'));
|
|
2792
|
-
const add=document.createElement('button');add.textContent='+ Add an image…';add.onclick=()=>{insMenuClose();document.getElementById('insFile').click();};frag.appendChild(add);
|
|
3069
|
+
const add=document.createElement('button');add.textContent='+ Add an image…';add.onclick=()=>{insMenuClose();closeMore();document.getElementById('insFile').click();};frag.appendChild(add);
|
|
2793
3070
|
insMenu.replaceChildren(frag);}
|
|
2794
3071
|
insBtn.onclick=e=>{e.stopPropagation();
|
|
2795
3072
|
if(d3.insertMode()){d3.setInsertMode(false);return;} // armed → cancel the pick
|
|
@@ -2836,6 +3113,9 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
|
|
|
2836
3113
|
document.getElementById('zoombar').style.display=on?'none':''; // zoom slider is 2D-only
|
|
2837
3114
|
document.getElementById('planSel').style.display=on?'none':''; // plan selector is 2D-only (3D shows the whole model)
|
|
2838
3115
|
document.getElementById('m3dBar').style.display=on?'flex':'none';
|
|
3116
|
+
document.body.classList.toggle('v3d',on); // 3D-only chrome (e.g. ⋯ → Detailing → Insert detail) keys off this
|
|
3117
|
+
document.getElementById('dimB').style.display=on?'none':''; // 2D Dimension button — 2D only
|
|
3118
|
+
document.getElementById('dim3dWrap').style.display=on?'inline-flex':'none'; // 3D Dimension cluster (moved to header) — 3D only
|
|
2839
3119
|
document.getElementById('m3dCube').style.display=on?'block':'none';
|
|
2840
3120
|
document.getElementById('m3dAxes').style.display=on?'block':'none';
|
|
2841
3121
|
document.getElementById('snapBar').classList.toggle('s3d',on); // in 3D the snap bar shifts clear of the world-axis triad (bottom-right); see #snapBar.s3d
|
|
@@ -2854,13 +3134,15 @@ async function setView(on){
|
|
|
2854
3134
|
await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
|
|
2855
3135
|
window.Steel3DView.setSelection(selIds);
|
|
2856
3136
|
wire3DBar();build3DLegend();
|
|
2857
|
-
|
|
3137
|
+
reflectProj();reflectMode(); // reflect persisted projection + display mode into the Camera/Display dropdown triggers
|
|
2858
3138
|
}catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
|
|
2859
3139
|
applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
|
|
2860
3140
|
toast('Could not open 3D view: '+((e&&e.message)||e));
|
|
2861
3141
|
}
|
|
2862
3142
|
}else{if(cmTool&&window.Steel3DView&&window.Steel3DView.cmClear3d)window.Steel3DView.cmClear3d(); // 3D→2D: drop the mm-space pick the same way (tool stays armed)
|
|
2863
3143
|
applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();}
|
|
3144
|
+
if(propPopOpen()){if(propPopPinned)dockPropPop();else closePropPop(true);} // the popup is anchored to one canvas — re-dock if pinned, else dismiss on the view switch
|
|
3145
|
+
refreshPropLabels3d();updatePropHint(); // push the label set to whichever view is now live (3D shows them; 2D hides the 3D host) + refresh the corner note
|
|
2864
3146
|
}
|
|
2865
3147
|
document.getElementById('vt2d').onclick=()=>{if(view3d)setView(false);};
|
|
2866
3148
|
document.getElementById('vt3d').onclick=()=>{if(!view3d)setView(true);};
|
|
@@ -2907,7 +3189,7 @@ function openDetails(){const g=document.getElementById('detailsGrid');const uniq
|
|
|
2907
3189
|
g.innerHTML=uniq.length?uniq.map(t=>{const custom=C.custom_details[t]!=null;const sheet=custom?'':(sheetOf(t)||'');const pv=previewFor(t),b=custom?null:bubbleFor(t);
|
|
2908
3190
|
const prev=pv?(b?`<img class=dthumb data-t="${esc(t)}" alt="${esc(t)}">`:`<img src="data:image/jpeg;base64,${pv}" alt="${esc(t)}">`):`<div class=dprev>Preview pending<br>from sheet ${esc(sheet)}</div>`;
|
|
2909
3191
|
const sub=custom?'Custom detail':('Sheet '+esc(sheet)+(b?' · detail '+esc(numOf(t)):''));
|
|
2910
|
-
return `<div class=dcard><div class=dprevwrap${pv?` data-det="${esc(t)}"`:''}>${prev}</div><div class=dmeta><input class=dname data-old="${esc(t)}" value="${esc(t)}"
|
|
3192
|
+
return `<div class=dcard><div class=dprevwrap${pv?` data-det="${esc(t)}"`:''}>${prev}</div><div class=dmeta><input class=dname data-old="${esc(t)}" value="${esc(t)}" data-tip="Rename (updates every plan)" autocomplete=off><div class=ds>${sub}${custom?` · <a href="#" class=ddel data-del="${esc(t)}">delete</a>`:''}</div></div></div>`;}).join('')
|
|
2911
3193
|
:'<div class=hint>No detail callouts detected. Use + New detail to add your own.</div>';
|
|
2912
3194
|
g.querySelectorAll('img.dthumb').forEach(im=>detThumb(im.dataset.t,im));
|
|
2913
3195
|
g.querySelectorAll('.dprevwrap[data-det]').forEach(c=>c.onclick=()=>openPreview(c.dataset.det));
|
|
@@ -2938,7 +3220,7 @@ function updBpBtn(){const b=document.getElementById('bpBtn');if(!b)return;
|
|
|
2938
3220
|
const colSet=new Set(colMemberIds()), n=autoBasePlates().length;
|
|
2939
3221
|
const covered=new Set((C.joints||[]).filter(j=>j&&j.kind==='base-plate'&&colSet.has(j.main)).map(j=>j.main)).size; // only CURRENT columns (ignore joints orphaned by a deleted column)
|
|
2940
3222
|
b.disabled=colSet.size===0; // no columns → nothing to detail (empty-state)
|
|
2941
|
-
b.
|
|
3223
|
+
b.dataset.tip=colSet.size===0?'No columns in this model to detail base plates.':'Auto-detail base plates on every column (a plate + anchor kit + weld, shown in 3D). Tune sizes per the schedule via the AI or per-column params.';
|
|
2942
3224
|
b.textContent=((colSet.size>0&&covered>=colSet.size&&n>0)?'Clear base plates':'Base plates')+(n?' ('+n+')':'');} // label flips once all current columns are covered, so the destructive 2nd click reads clearly
|
|
2943
3225
|
// C.joints is model-global but now rides the snapshot (snapshot()/apply() carry it), so every joint
|
|
2944
3226
|
// mutation goes through edit() — auto-detail, the inspector, and Remove are all undoable (Ctrl+Z/Y).
|
|
@@ -2983,7 +3265,7 @@ function updSpBtn(){const b=document.getElementById('spBtn');if(!b)return;
|
|
|
2983
3265
|
const elig=spEligibleEnds(),n=autoShearPlates().length;
|
|
2984
3266
|
const covered=elig.filter(({beam,endIdx})=>spJointOf(beam.id,endIdx)).length; // eligible ends already detailed (auto OR user)
|
|
2985
3267
|
b.disabled=elig.length===0; // no eligible beam ends → nothing to detail (empty-state, disabled-not-hidden)
|
|
2986
|
-
b.
|
|
3268
|
+
b.dataset.tip=elig.length===0?'No eligible beam ends in this model to detail shear plates.':'Auto-detail bolted shear (fin) plates on eligible beam ends (a fin plate + bolt group + weld, shown in 3D). Tune sizes per the schedule via the AI or per-end params.';
|
|
2987
3269
|
b.textContent=((elig.length>0&&covered>=elig.length&&n>0)?'Clear shear plates':'Shear plates')+(n?' ('+n+')':'');} // label flips to Clear once all ELIGIBLE ends are covered (moment ends are legitimately excluded)
|
|
2988
3270
|
function toggleShearPlates(){
|
|
2989
3271
|
const elig=spEligibleEnds();
|
|
@@ -3007,7 +3289,7 @@ function renderConnTable(){const tb=document.getElementById('connBody');if(!tb)r
|
|
|
3007
3289
|
+`<td><input class="combo ftab-inp" data-src=details data-f=detail value="${esc(r.detail||'')}" placeholder="e.g. 50S504" autocomplete=off></td>`
|
|
3008
3290
|
+`<td><input class=ftab-inp data-f=target value="${esc((r.targets&&r.targets[connPlat])||'')}" placeholder="e.g. 146" autocomplete=off></td>`
|
|
3009
3291
|
+`<td class=csrc>${srcChip(r.source||'user')}</td>`
|
|
3010
|
-
+`<td><button class="danger cdel"
|
|
3292
|
+
+`<td><button class="danger cdel" data-tip="Remove this row">✕</button></td></tr>`).join('');
|
|
3011
3293
|
tb.querySelectorAll('tr[data-id]').forEach(tr=>{const id=tr.dataset.id;const row=connRowById(id);if(!row)return;
|
|
3012
3294
|
tr.querySelector('[data-f=type]').onchange=e=>edit(()=>{row.type=e.target.value.trim();});
|
|
3013
3295
|
tr.querySelector('[data-f=detail]').onchange=e=>edit(()=>{row.detail=e.target.value.trim();});
|
|
@@ -3177,7 +3459,7 @@ function updConf(){const el=document.getElementById('confStat');if(!el)return;co
|
|
|
3177
3459
|
else{const gap=tgt-sc;
|
|
3178
3460
|
tg.innerHTML=' · <span style="color:var(--mut)">target '+tgt+'%</span>'+(gap>0?' <span style="color:'+col+'">(−'+gap+'%)</span>':'');}
|
|
3179
3461
|
}
|
|
3180
|
-
el.
|
|
3462
|
+
el.dataset.tip='Confidence '+(sc==null?'—':sc+'%')+(tgt!=null?' vs target '+tgt+'%':'')+' — '+s.overall.tons.toFixed(1)+' t scored · '+s.overall.rfiCount+' RFI. Click for the report.';}
|
|
3181
3463
|
// --- the report modal ---
|
|
3182
3464
|
let confBand='all',confCat='all',confExpand=null;
|
|
3183
3465
|
function openConf(){confExpand=null;renderConf();document.getElementById('confModal').style.display='flex';}
|
|
@@ -3188,7 +3470,7 @@ function _countsHtml(c){const out=['verified','high','med','low','rfi'].filter(k
|
|
|
3188
3470
|
function renderConf(){const s=scoreContractJS();const cat=s.byCategory;
|
|
3189
3471
|
const ct=document.getElementById('confTarget');if(ct&&document.activeElement!==ct){ // value = the per-read OVERRIDE only; the app default shows as a placeholder (empty = using default)
|
|
3190
3472
|
ct.value=typeof C.target_confidence==='number'?C.target_confidence:'';ct.placeholder=TARGET_CONF!=null?String(TARGET_CONF):'70';}
|
|
3191
|
-
const card=(key,label,cs,stub)=>'<div class="ccard'+(stub?' stub':' click')+(confCat===key?' on':'')+'" data-cat="'+key+'"'+(stub?'
|
|
3473
|
+
const card=(key,label,cs,stub)=>'<div class="ccard'+(stub?' stub':' click')+(confCat===key?' on':'')+'" data-cat="'+key+'"'+(stub?' data-tip="not scored yet"':'')+'>'+
|
|
3192
3474
|
'<div class=cc-label>'+label+'</div>'+
|
|
3193
3475
|
'<div class=cc-score style="color:'+(stub?'var(--mut)':bandColorForPct(cs.score))+'">'+(stub||cs.score==null?'—':cs.score+'%')+'</div>'+
|
|
3194
3476
|
_bar(stub?null:cs.score)+
|
|
@@ -3466,7 +3748,7 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
|
|
|
3466
3748
|
if(src.read_at){try{const d=new Date(src.read_at);if(!isNaN(d.getTime()))parts.push(d.toLocaleDateString());}catch{/* skip unparseable date */}}
|
|
3467
3749
|
if(!parts.length)return;
|
|
3468
3750
|
const el=document.getElementById('srcStat');if(!el)return;
|
|
3469
|
-
el.textContent=parts.join(' · ');el.
|
|
3751
|
+
el.textContent=parts.join(' · ');el.dataset.tip=parts.join(' · ');el.style.display=''; // title = full provenance (the chip itself truncates with ellipsis)
|
|
3470
3752
|
})();
|
|
3471
3753
|
// --- Ask AI: send instruction + optional screenshots → POST /api/contract-request ---
|
|
3472
3754
|
// The server records a tweak-contract request; the terminal AI picks it up async and PUTs
|
|
@@ -3518,7 +3800,7 @@ function askAiRenderThumbs() {
|
|
|
3518
3800
|
img.src = snap.dataUrl; // full dataURL — safe, not user text, no textContent needed
|
|
3519
3801
|
img.alt = '';
|
|
3520
3802
|
const rmBtn = document.createElement('button');
|
|
3521
|
-
rmBtn.
|
|
3803
|
+
rmBtn.dataset.tip = 'Remove';
|
|
3522
3804
|
rmBtn.textContent = '✕';
|
|
3523
3805
|
rmBtn.onclick = () => { askAiSnapshots.splice(idx, 1); askAiRenderThumbs(); };
|
|
3524
3806
|
const nameEl = document.createElement('div');
|