@floless/app 0.35.1 → 0.37.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.
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
.elabrow{display:flex;align-items:center;justify-content:space-between;margin:8px 0 2px}
|
|
60
60
|
.elabrow .elab{margin:0}
|
|
61
61
|
.defck{display:inline-flex;align-items:center;gap:4px;font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);cursor:pointer}
|
|
62
|
-
.defck input{width:auto;margin:0;cursor:pointer}
|
|
62
|
+
.defck input{width:auto;margin:0;cursor:pointer;accent-color:var(--brand)}
|
|
63
63
|
input:disabled{opacity:.55;cursor:default}
|
|
64
64
|
.badge{display:block;background:var(--brand);color:#fff;text-align:center;font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:5px;border-radius:6px;margin-bottom:10px}
|
|
65
65
|
.pick{color:var(--brand)!important}
|
|
@@ -113,6 +113,18 @@
|
|
|
113
113
|
#comboPop{position:fixed;z-index:30;background:var(--panel);border:1px solid #475569;border-radius:6px;max-height:240px;overflow:auto;box-shadow:0 8px 24px rgba(0,0,0,.5);display:none}
|
|
114
114
|
#comboPop .opt{padding:6px 10px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap}
|
|
115
115
|
#comboPop .opt:hover,#comboPop .opt.active{background:#334155}
|
|
116
|
+
/* "More" overflow menu — themed popup (reuses the #comboPop look); flat rows keep each button's id + handler. */
|
|
117
|
+
#moreWrap{position:relative;display:inline-flex}
|
|
118
|
+
#moreMenu{position:absolute;right:0;top:calc(100% + 6px);min-width:210px;background:var(--panel);border:1px solid #475569;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px 0;z-index:30;display:none}
|
|
119
|
+
#moreMenu.open{display:block}
|
|
120
|
+
#moreMenu .mlabel{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);padding:8px 12px 2px}
|
|
121
|
+
#moreMenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
|
|
122
|
+
#moreMenu button{display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap}
|
|
123
|
+
#moreMenu button:hover{background:#334155}
|
|
124
|
+
#moreMenu button.mdanger{color:#fca5a5}
|
|
125
|
+
#moreMenu button.mdanger:hover{background:#7f1d1d;color:#fecaca}
|
|
126
|
+
button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}
|
|
127
|
+
#srcStat{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
116
128
|
#detailNewForm input#dnName{margin:2px 0 10px}
|
|
117
129
|
#dnDrop{border:1px dashed #475569;border-radius:8px;background:#0b1220;min-height:88px;display:flex;align-items:center;justify-content:center;gap:12px;text-align:center;color:var(--mut);cursor:pointer;padding:12px;font-size:12px}
|
|
118
130
|
#dnDrop:hover{border-color:var(--brand);color:var(--text)} #dnDrop.has{border-style:solid;border-color:var(--brand)}
|
|
@@ -144,6 +156,21 @@
|
|
|
144
156
|
#viewToggle .seg.on{background:var(--panel);color:var(--text);box-shadow:inset 0 -2px 0 var(--brand)}
|
|
145
157
|
#stagewrap{position:relative}
|
|
146
158
|
#stage3d{position:absolute;inset:0;width:100%;height:100%;display:none;outline:none}
|
|
159
|
+
/* yellow = START (end 1), magenta = END (end 2) — also the drag handles. Same colours in 3D. */
|
|
160
|
+
circle.handle.ep-start{fill:#facc15;stroke:#0b1220;stroke-width:2}
|
|
161
|
+
circle.handle.ep-end{fill:#f472b6;stroke:#0b1220;stroke-width:2}
|
|
162
|
+
.epdot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:middle}
|
|
163
|
+
/* 3D-only floating chrome (parity with AWARE viewer-3d): toolbar TL, legend BL, ViewCube BR */
|
|
164
|
+
#m3dBar{position:absolute;top:12px;left:12px;display:none;gap:6px;align-items:center;z-index:6}
|
|
165
|
+
#m3dBar .seg-group,#m3dBar>button{box-shadow:0 4px 14px rgba(0,0,0,.45)}
|
|
166
|
+
#m3dBar>button{font-size:12px;padding:5px 9px}
|
|
167
|
+
#m3dLegend{position:absolute;left:12px;bottom:64px;display:none;flex-direction:column;gap:1px;max-height:40%;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 10px;z-index:6;box-shadow:0 4px 14px rgba(0,0,0,.45);font-size:12px}
|
|
168
|
+
#m3dLegend .lhint{color:var(--mut);font-size:10px;margin-bottom:4px}
|
|
169
|
+
#m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap}
|
|
170
|
+
#m3dLegend .lrow:hover{background:#33415580}
|
|
171
|
+
#m3dLegend .lrow.off{opacity:.4} #m3dLegend .lrow.off .lsw{filter:grayscale(1)}
|
|
172
|
+
#m3dLegend .lsw{width:11px;height:11px;border-radius:2px;flex:none}
|
|
173
|
+
#m3dCube{position:absolute;right:12px;bottom:12px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))}
|
|
147
174
|
</style>
|
|
148
175
|
<script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/"}}</script>
|
|
149
176
|
<script type="module" src="./steel-3d-view.js"></script>
|
|
@@ -160,20 +187,39 @@
|
|
|
160
187
|
<span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
|
|
161
188
|
<button id=undoB title="Undo (Ctrl+Z)">↶</button>
|
|
162
189
|
<button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
|
|
163
|
-
<button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
|
|
164
|
-
<button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
|
|
165
190
|
<button id=mAdd title="Toggle add-member mode">Add member</button>
|
|
166
|
-
<button id=detailsBtn>Details</button>
|
|
167
|
-
<button id=framesBtn>Frames</button>
|
|
168
|
-
<button id=revertB title="Discard all saved edits and reload the detected contract">Revert</button>
|
|
169
|
-
<button id=exp>Export contract</button>
|
|
170
|
-
<button id=reloadB title="Reload from server — picks up any AI writebacks">↻ Reload</button>
|
|
171
191
|
<button id=askAiBtn>Ask AI ▸</button>
|
|
192
|
+
<div id=moreWrap>
|
|
193
|
+
<button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
|
|
194
|
+
<div id=moreMenu role=menu>
|
|
195
|
+
<div class=mlabel>Review</div>
|
|
196
|
+
<button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
|
|
197
|
+
<button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
|
|
198
|
+
<hr>
|
|
199
|
+
<div class=mlabel>Reference</div>
|
|
200
|
+
<button id=detailsBtn>Details</button>
|
|
201
|
+
<button id=framesBtn>Frames</button>
|
|
202
|
+
<hr>
|
|
203
|
+
<div class=mlabel>Session</div>
|
|
204
|
+
<button id=reloadB title="Reload from server — picks up any AI writebacks">↻ Reload</button>
|
|
205
|
+
<button id=exp>Export contract</button>
|
|
206
|
+
<hr>
|
|
207
|
+
<button id=revertB class=mdanger title="Discard all saved edits and reload the detected contract">Revert</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
172
210
|
</header>
|
|
173
211
|
<main>
|
|
174
212
|
<div id=stagewrap>
|
|
175
213
|
<div id=stage><svg id=svg></svg></div>
|
|
176
214
|
<canvas id=stage3d tabindex=0 aria-label="3D model"></canvas>
|
|
215
|
+
<div id=m3dBar role=group aria-label="3D view controls">
|
|
216
|
+
<div class=seg-group id=m3dProj title="Camera projection"><button data-proj=persp class=on>Persp</button><button data-proj=ortho>Ortho</button></div>
|
|
217
|
+
<div class=seg-group id=m3dMode title="Display mode"><button data-mode=solid class=on>Solid</button><button data-mode=wire>Wire</button><button data-mode=xray>X-ray</button></div>
|
|
218
|
+
<button id=m3dFit title="Fit all to view">Fit</button>
|
|
219
|
+
<button id=m3dRef title="Show the reference line (work-line between the end points) for every member">Ref line</button>
|
|
220
|
+
</div>
|
|
221
|
+
<div id=m3dLegend></div>
|
|
222
|
+
<div id=m3dCube title="Click a face for that view · drag the scene with the right button to orbit"></div>
|
|
177
223
|
<div id=zoombar>
|
|
178
224
|
<button id=zOut title="Zoom out">−</button>
|
|
179
225
|
<input id=zRange type=range min=10 max=400 step=1 value=100>
|
|
@@ -375,6 +421,25 @@ const fmtDecIn=v=>(v==null||isNaN(v))?'—':((Math.round(v*100)/100)+'"');
|
|
|
375
421
|
function ensureMeta(m){if(!m.role)m.role='beam';
|
|
376
422
|
if(!m.ends){const c=/(^|[^A-Z])MF/i.test(m.profile||'')?'moment':'';m.ends=[{tos:null,note:c,tosDef:true,detail:''},{tos:null,note:c,tosDef:true,detail:''}];}
|
|
377
423
|
if(!m.col)m.col={bos:null,tos:null,note:(/(^|[^A-Z])MF/i.test(m.profile||'')?'moment':''),tosDef:true,detail:''};return m;}
|
|
424
|
+
// Effective profile position on the reference line (the work-line between the end dots), à la Tekla.
|
|
425
|
+
// Explicit m.position wins; else a flat beam → top (top-of-steel), a steep beam (brace) / column → middle.
|
|
426
|
+
// MIRROR of server/contract-to-scene.ts depthPosition (same 0.5774 ≈ 30° threshold, mm units).
|
|
427
|
+
function posDefault(m){
|
|
428
|
+
if(m.position==='top'||m.position==='middle'||m.position==='bottom')return m.position;
|
|
429
|
+
if(m.role==='column')return 'middle';
|
|
430
|
+
const k=304.8/((P&&P.pt_per_ft>0)?P.pt_per_ft:1);
|
|
431
|
+
const z0=(m.ends&&m.ends[0]&&m.ends[0].tos!=null)?m.ends[0].tos:defaultTOS;
|
|
432
|
+
const z1=(m.ends&&m.ends[1]&&m.ends[1].tos!=null)?m.ends[1].tos:defaultTOS;
|
|
433
|
+
const dz=Math.abs((z0||0)-(z1||0))*25.4, horiz=Math.hypot(m.wp[1][0]-m.wp[0][0],m.wp[1][1]-m.wp[0][1])*k;
|
|
434
|
+
return dz>horiz*0.5774?'middle':'top';}
|
|
435
|
+
// Swap a member's start (yellow, end 1) and end (magenta, end 2) handles — reverse its direction.
|
|
436
|
+
// Beam: reverse wp + the per-end metadata (each end's TOS/note/detail follows its point). Column:
|
|
437
|
+
// the two handles are bottom (BOS) and top (TOS), so swap those. Used by 2D + 3D via the same panel.
|
|
438
|
+
function swapMemberEnds(m){
|
|
439
|
+
if(Array.isArray(m.wp)&&m.wp.length>=2)m.wp=[m.wp[1].slice(),m.wp[0].slice()];
|
|
440
|
+
if(Array.isArray(m.ends)&&m.ends.length>=2)m.ends=[m.ends[1],m.ends[0]];
|
|
441
|
+
if(m.col){const b=(m.col.bos!=null?m.col.bos:0),t=(m.col.tos!=null?m.col.tos:defaultTOS);m.col.bos=t;m.col.tos=b;m.col.tosDef=false;}
|
|
442
|
+
return m;}
|
|
378
443
|
// best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
|
|
379
444
|
// 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
|
|
380
445
|
let defaultTOS=198, addProfile='';
|
|
@@ -385,10 +450,10 @@ const autofillTOS=syncDefaults;
|
|
|
385
450
|
// profs is per-plan — computed in setPlan()
|
|
386
451
|
function snapshot(){return JSON.stringify(P.members);}
|
|
387
452
|
function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
|
|
388
|
-
function apply(json){P.members=JSON.parse(json);selIds=new Set([...selIds].filter(id=>byId(id)));scheduleSave();render();}
|
|
453
|
+
function apply(json){P.members=JSON.parse(json);selIds=new Set([...selIds].filter(id=>byId(id)));scheduleSave();render();sync3D();}
|
|
389
454
|
function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
|
|
390
455
|
function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
|
|
391
|
-
function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();}
|
|
456
|
+
function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();sync3D();}
|
|
392
457
|
// --- auto-save: edits persist to this browser (localStorage). Raster stays embedded; only members + TOS are stored. ---
|
|
393
458
|
const LSKEY = 'steeltakeoff:edits:v1:' + APP_ID;
|
|
394
459
|
function dataSig(){return C.plans.map(p=>p.sheet+':'+((p.segments||[]).length)).join('|');} // ignore stored edits if the dataset changed
|
|
@@ -442,8 +507,12 @@ function projPt(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy||1;
|
|
|
442
507
|
let t=((p[0]-a[0])*vx+(p[1]-a[1])*vy)/L2;t=Math.max(0,Math.min(1,t));return {pt:[a[0]+t*vx,a[1]+t*vy],t};}
|
|
443
508
|
function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
|
|
444
509
|
// nearest end → intersection: extends when the line is past the end, trims when it crosses the member. ponytail: keeps the larger piece on a deep overshoot — undo+drag if that's wrong.
|
|
445
|
-
|
|
446
|
-
|
|
510
|
+
// Multi: trim/extend the nearest end of EVERY given member to the (tA,tB) target line; members
|
|
511
|
+
// parallel to the target (no intersection) are skipped. One undo for the whole batch.
|
|
512
|
+
function snapEndMulti(ms,tA,tB){const pv=snapshot();let n=0;
|
|
513
|
+
for(const m of ms){const I=lineX(m.wp[0],m.wp[1],tA,tB);if(!I)continue;
|
|
514
|
+
const h=len(m.wp[0],I)<=len(m.wp[1],I)?0:1;m.wp[h]=I;m.rfi=(WT[m.profile]==null);n++;}
|
|
515
|
+
if(n){pushUndo(pv);render();sync3D();}return n;}
|
|
447
516
|
function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(JSON.stringify(m));
|
|
448
517
|
const a=m.wp[0].slice(),b=m.wp[1].slice(),q=pt.slice(),mk=()=>({tos:null,note:'',tosDef:true,detail:''});
|
|
449
518
|
m.wp=[a,q];if(m.ends)m.ends=[m.ends[0],mk()]; // first half keeps its start end, fresh cut end
|
|
@@ -501,6 +570,10 @@ const svg=document.getElementById('svg');
|
|
|
501
570
|
function toSvg(e){const p=svg.createSVGPoint();p.x=e.clientX;p.y=e.clientY;return p.matrixTransform(svg.getScreenCTM().inverse());}
|
|
502
571
|
// --- zoom + pan ---
|
|
503
572
|
const ZMIN=0.1, ZMAX=4;
|
|
573
|
+
// start/end dot radius in CONTENT units. The member line is a content-unit stroke (scales with zoom),
|
|
574
|
+
// so a screen-constant dot gets dwarfed by the thick line at high zoom. This grows with zoom (the +3
|
|
575
|
+
// content term) while keeping a screen-px floor at low zoom (the 5/zoom term): ~7px at fit, ~17px at 400%.
|
|
576
|
+
const epR=()=>3+5/zoom;
|
|
504
577
|
let zoom=1; const stage=document.getElementById('stage');
|
|
505
578
|
// The editor runs in an iframe; keyboard shortcuts (Ctrl+D etc.) only reach our handler when the
|
|
506
579
|
// iframe has focus. The SVG canvas isn't focusable, so clicking it wouldn't grab focus and a real
|
|
@@ -523,12 +596,12 @@ function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot
|
|
|
523
596
|
selIds=ns;mode='sel';setMode();pushUndo(pv);render();}
|
|
524
597
|
function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'<','>':'>','&':'&','"':'"'}[c]));}
|
|
525
598
|
function render(){
|
|
526
|
-
if(geoMode&&selIds.size!==1){geoMode=null;document.body.classList.remove('geo');} //
|
|
599
|
+
if(geoMode&&(selIds.size<1||(geoMode==='split'&&selIds.size!==1))){geoMode=null;document.body.classList.remove('geo');} // Split needs exactly one member; Extend/Trim works on any selection
|
|
527
600
|
let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
|
|
528
601
|
for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
|
|
529
602
|
for(const m of P.members){const c=colorFor(m.profile);const on=selIds.has(m.id);const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
|
|
530
603
|
s+=`<line class="member${m.rfi?' rfi':''}${on?' sel':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
|
|
531
|
-
{const hsel=selArr();if(hsel.length>=1
|
|
604
|
+
{const hsel=selArr();if(hsel.length>=1){const HR=epR();for(const sm of hsel)for(let i=0;i<2;i++) s+=`<circle class="handle ${i===0?'ep-start':'ep-end'}" data-mid="${sm.id}" data-h="${i}" cx="${sm.wp[i][0]}" cy="${sm.wp[i][1]}" r="${HR}"/>`;}} // end 1 (start) yellow, end 2 (end) magenta · shown for every selected member · radius grows with zoom (epR) so it stays visible against the thick line
|
|
532
605
|
if((mode==='add'||(picking&&pickKind==='profile'))&&P.labels) for(const lb of P.labels){const w=Math.max(40,lb.text.length*11);
|
|
533
606
|
s+=`<rect class=lblhot data-prof="${esc(lb.text)}" x="${lb.disp[0]-w/2}" y="${lb.disp[1]-10}" width="${w}" height="20" rx="3"><title>${esc(lb.text)}</title></rect>`;}
|
|
534
607
|
if(picking&&pickKind==='detail'&&P.details) for(const d of P.details){const w=Math.max(34,d.text.length*8);
|
|
@@ -547,11 +620,12 @@ function updDup(){const n=redundantDups().length;
|
|
|
547
620
|
document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
|
|
548
621
|
const b=document.getElementById('dupB');if(b.dataset.flash)return; // don't clobber transient "none" feedback
|
|
549
622
|
b.disabled=false;b.classList.toggle('alert',n>0);b.textContent=n>0?('Duplicates ('+n+')'):'Re-check duplicates';}
|
|
550
|
-
// keep selection-number badges
|
|
623
|
+
// keep selection-number badges constant on screen while zooming; the end dots grow with zoom (epR) so
|
|
624
|
+
// they stay readable against the thick member line — both must track zoom live as the user zooms.
|
|
551
625
|
function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
|
|
552
626
|
svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
|
|
553
627
|
svg.querySelectorAll('text.numtx').forEach(t=>{t.setAttribute('x',(+t.dataset.bx)+ox(t));t.style.fontSize=F+'px';});
|
|
554
|
-
svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',
|
|
628
|
+
svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',epR()));}
|
|
555
629
|
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]);});}
|
|
556
630
|
function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
|
|
557
631
|
if(ln){ln.setAttribute('x1',m.wp[0][0]);ln.setAttribute('y1',m.wp[0][1]);ln.setAttribute('x2',m.wp[1][0]);ln.setAttribute('y2',m.wp[1][1]);}}
|
|
@@ -560,6 +634,10 @@ function stats(){
|
|
|
560
634
|
let w=0,rfi=0; for(const m of P.members){const wpf=WT[m.profile]; if(wpf==null)rfi++; else w+=len(m.wp[0],m.wp[1])/FT*wpf;}
|
|
561
635
|
document.getElementById('wt').textContent=(w/2000).toFixed(1);document.getElementById('wtlb').textContent=Math.round(w).toLocaleString();document.getElementById('rc').textContent=rfi;
|
|
562
636
|
}
|
|
637
|
+
// Aggregate one field across a multi-selection → the shared value, or VARIES when they differ. Drives the
|
|
638
|
+
// "Varies" placeholders + the indeterminate "default" checkbox in the multi-edit panel. get() must return a primitive.
|
|
639
|
+
const VARIES=Symbol('varies');
|
|
640
|
+
function agg(list,get){if(!list.length)return undefined;const f=get(list[0]);for(let i=1;i<list.length;i++)if(get(list[i])!==f)return VARIES;return f;}
|
|
563
641
|
function panel(){
|
|
564
642
|
const p=document.getElementById('panel');
|
|
565
643
|
if(mode==='add'){
|
|
@@ -585,17 +663,73 @@ function panel(){
|
|
|
585
663
|
const _rc=document.getElementById('resetCols');if(_rc)_rc.onclick=()=>{C.profile_colors={};scheduleSave();render();};
|
|
586
664
|
return;}
|
|
587
665
|
if(arr.length>1){
|
|
666
|
+
arr.forEach(ensureMeta);
|
|
667
|
+
const beams=arr.filter(m=>m.role!=='column'), cols=arr.filter(m=>m.role==='column');
|
|
668
|
+
const allBeam=cols.length===0, allCol=beams.length===0;
|
|
588
669
|
const totalL=arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT,0).toFixed(1);
|
|
589
|
-
const
|
|
590
|
-
const
|
|
670
|
+
const allKnown=arr.every(m=>WT[m.profile]!=null);
|
|
671
|
+
const totalW=allKnown?Math.round(arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT*WT[m.profile],0)):null;
|
|
672
|
+
const dupSet=new Set(redundantDups()), dupSel=arr.filter(m=>dupSet.has(m.id)).length;
|
|
673
|
+
const profAgg=agg(arr,m=>m.profile), allVerified=arr.every(m=>m.verified===true);
|
|
674
|
+
const VV=v=>v===VARIES, valOf=v=>VV(v)||v==null?'':esc(fmtFtIn(v)), decOf=v=>VV(v)||v==null?'':esc(fmtDecIn(v));
|
|
675
|
+
const exPH='5 3/4" · 1'-0 1/4"';
|
|
676
|
+
// multi-aware field renderers: show the shared value when all agree, else a "Varies" placeholder (typing applies to all).
|
|
677
|
+
const mTos=(id,label,os)=>{const defA=agg(os,o=>o.tosDef!==false),def=defA===true,vEff=def?defaultTOS:agg(os,o=>o.tosDef!==false?defaultTOS:o.tos);
|
|
678
|
+
return `<div class=elabrow><span class=elab>${label}</span><label class=defck><input type=checkbox id=${id}_ck ${def?'checked':''}>default</label></div>`+
|
|
679
|
+
`<input id=${id} inputmode=decimal placeholder="${(!def&&VV(vEff))?'Varies':exPH}" value="${def?esc(fmtFtIn(defaultTOS)):valOf(vEff)}"${def?' disabled':''}><span class=edec>${def?esc(fmtDecIn(defaultTOS)):decOf(vEff)}</span>`;};
|
|
680
|
+
const mFt=(id,label,os,get)=>{const a=agg(os,get);return `<div class=elab>${label}</div><input id=${id} inputmode=decimal placeholder="${VV(a)?'Varies':exPH}" value="${valOf(a)}"><span class=edec>${decOf(a)}</span>`;};
|
|
681
|
+
const mNote=(id,os)=>{const a=agg(os,o=>o.note||'');return `<input id=${id} class="f combo" data-src=conntypes placeholder="${VV(a)?'Varies':'connection / note'}" value="${VV(a)?'':esc(a||'')}" autocomplete=off>`;};
|
|
682
|
+
const mDet=(id,os)=>{const a=agg(os,o=>o.detail||'');return `<div class=elab>Connection detail</div><input id=${id} class=combo data-src=details placeholder="${VV(a)?'Varies':'e.g. 5-S504'}" value="${VV(a)?'':esc(a||'')}" autocomplete=off>`;};
|
|
683
|
+
const dotS='<span class=epdot style="background:#facc15"></span>',dotE='<span class=epdot style="background:#f472b6"></span>';
|
|
684
|
+
let elev='';
|
|
685
|
+
if(!allBeam&&!allCol) elev=`<div class="hint f" style="border:1px solid var(--line);border-radius:6px;padding:8px 10px">Mixed beam + column — select one type only to edit levels.</div>`;
|
|
686
|
+
else if(allCol){const cs=cols.map(m=>m.col);
|
|
687
|
+
elev=`<div class="sect f">Elevation</div>${mFt('bos',dotS+'Bottom (BOS)',cs,o=>o.bos)}${mTos('tosC',dotE+'Top (TOS)',cs)}${mNote('ntC',cs)}${mDet('dtC',cs)}`;}
|
|
688
|
+
else{const s0=beams.map(m=>m.ends[0]),s1=beams.map(m=>m.ends[1]);
|
|
689
|
+
elev=`<div class="sect f">${dotS}Start</div>${mTos('tosA','TOS',s0)}${mNote('ntA',s0)}${mDet('dtA',s0)}`+
|
|
690
|
+
`<div class=divrow><hr><button class=ghost id=matchEnds>Match → both ends (each)</button><hr></div>`+
|
|
691
|
+
`<div class=sect>${dotE}End</div>${mTos('tosB','TOS',s1)}${mNote('ntB',s1)}${mDet('dtB',s1)}`;}
|
|
692
|
+
const posA=!allCol?agg(beams,m=>posDefault(m)):null;
|
|
591
693
|
p.innerHTML=`<h3>${arr.length} members selected</h3>
|
|
592
|
-
<div class=row
|
|
593
|
-
<div class=
|
|
594
|
-
<div class=
|
|
694
|
+
<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>
|
|
695
|
+
<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>
|
|
696
|
+
<div class="seg2 f"><button id=rBeam class="${allBeam?'on':''}">Beam</button><button id=rCol class="${allCol?'on':''}">Column</button></div>
|
|
697
|
+
${(!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>`:''}
|
|
698
|
+
<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>
|
|
699
|
+
<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>
|
|
700
|
+
${elev}
|
|
701
|
+
${!allCol?`<div class=divrow><hr><span class=sect style="margin:0">Profile position</span><hr></div>
|
|
702
|
+
<div class=hint style="margin:0 0 6px">Where the section sits on the reference line (beams) — like Tekla.</div>
|
|
703
|
+
<div class=seg2 style="margin-top:0"><button id=posTop class="${posA==='top'?'on':''}">Top</button><button id=posMid class="${posA==='middle'?'on':''}">Middle</button><button id=posBot class="${posA==='bottom'?'on':''}">Bottom</button></div>`:''}
|
|
704
|
+
<div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
|
|
705
|
+
<div class=seg2 style="margin-top:0"><button id=geoEL class="${geoMode==='el'?'on':''}">Extend / Trim all</button></div>
|
|
706
|
+
<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>
|
|
707
|
+
<div class=row><button class=ghostw id=swapEnds title="Reverse every selected member: swap each one's start and end (yellow ↔ magenta / bottom ↔ top) handles">⇄ Swap start ↔ end (all)</button></div>
|
|
708
|
+
<div class="row f"><button class=danger id=del>Delete selected (${arr.length})</button></div>`;
|
|
709
|
+
// wiring — every commit applies to the whole selection; a blank field is a no-op (leaves each member's own value).
|
|
595
710
|
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();}});};
|
|
711
|
+
document.getElementById('rBeam').onclick=()=>edit(()=>{for(const m of selArr())if(m.role==='column'){m.role='beam';ensureMeta(m);}});
|
|
712
|
+
document.getElementById('rCol').onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column'){m.role='column';ensureMeta(m);}});
|
|
713
|
+
document.getElementById('verifyBtn').onclick=()=>edit(()=>{const v=!allVerified;for(const m of selArr())m.verified=v;});
|
|
714
|
+
{const _gel=document.getElementById('geoEL');if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};}
|
|
715
|
+
{const _sw=document.getElementById('swapEnds');if(_sw)_sw.onclick=()=>edit(()=>{for(const m of selArr())swapMemberEnds(m);});} // reverse start↔end for the whole selection (2D + 3D via render+sync3D)
|
|
596
716
|
document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});
|
|
717
|
+
{const sp=v=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.position=v;});
|
|
718
|
+
const _pt=document.getElementById('posTop'),_pm=document.getElementById('posMid'),_pb=document.getElementById('posBot');
|
|
719
|
+
if(_pt)_pt.onclick=()=>sp('top');if(_pm)_pm.onclick=()=>sp('middle');if(_pb)_pb.onclick=()=>sp('bottom');}
|
|
720
|
+
// elevation wiring: a blank commit is skipped (Varies = unchanged); the "default" checkbox shows indeterminate when mixed.
|
|
721
|
+
const wMTos=(id,os)=>{const ck=document.getElementById(id+'_ck');if(ck){if(agg(os,o=>o.tosDef!==false)===VARIES)ck.indeterminate=true;ck.onchange=e=>edit(()=>{for(const o of os){o.tosDef=e.target.checked;if(o.tosDef)o.tos=defaultTOS;}});}
|
|
722
|
+
const inp=document.getElementById(id);if(inp)inp.onchange=e=>{const t=e.target.value.trim();if(!t)return;edit(()=>{const v=parseLen(t);for(const o of os){o.tos=v;o.tosDef=false;}});};};
|
|
723
|
+
const wMText=(id,os,set)=>{const i=document.getElementById(id);if(i)i.onchange=e=>{const t=e.target.value.trim();if(!t)return;edit(()=>{for(const o of os)set(o,t);});};};
|
|
724
|
+
if(allCol){const cs=cols.map(m=>m.col);
|
|
725
|
+
{const i=document.getElementById('bos');if(i)i.onchange=e=>{const t=e.target.value.trim();if(!t)return;edit(()=>{const v=parseLen(t);for(const o of cs)o.bos=v;});}}
|
|
726
|
+
wMTos('tosC',cs);wMText('ntC',cs,(o,v)=>o.note=v);wMText('dtC',cs,(o,v)=>o.detail=v);}
|
|
727
|
+
else if(allBeam){const s0=beams.map(m=>m.ends[0]),s1=beams.map(m=>m.ends[1]);
|
|
728
|
+
wMTos('tosA',s0);wMText('ntA',s0,(o,v)=>o.note=v);wMText('dtA',s0,(o,v)=>o.detail=v);
|
|
729
|
+
wMTos('tosB',s1);wMText('ntB',s1,(o,v)=>o.note=v);wMText('dtB',s1,(o,v)=>o.detail=v);
|
|
730
|
+
{const me=document.getElementById('matchEnds');if(me)me.onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail};});}}
|
|
597
731
|
return;}
|
|
598
|
-
const m=ensureMeta(arr[0]), wpf=WT[m.profile], L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column');
|
|
732
|
+
const m=ensureMeta(arr[0]), wpf=WT[m.profile], L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column'), pos=posDefault(m);
|
|
599
733
|
const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
|
|
600
734
|
const mfSug=((m.mf||/^MF/i.test(m.profile))&&_lvl&&C.moment_frames)?[...new Set(C.moment_frames.flatMap(f=>f[_lvl]||[]))].filter(Boolean):[];
|
|
601
735
|
const ftFld=(id,label,v)=>`<div class=elab>${label}</div><input id=${id} inputmode=decimal placeholder="5 3/4" · 1'-0 1/4"" value="${esc(fmtFtIn(v))}"><span class=edec>${esc(fmtDecIn(v))}</span>`;
|
|
@@ -603,11 +737,12 @@ function panel(){
|
|
|
603
737
|
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>`;};
|
|
604
738
|
const nin=(id,v)=>`<input id=${id} class="f combo" data-src=conntypes placeholder="connection / note" value="${esc(v||'')}" autocomplete=off>`;
|
|
605
739
|
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 title="Open detail ${esc(o.detail)}">⤢</button>`:''}<button id=${id}_pk class="ghost${(picking&&pickKind==='detail'&&pickEnd===o)?' on':''}" title="Pick a detail callout from the drawing">⌖</button></div>`;};
|
|
740
|
+
const dotS='<span class=epdot style="background:#facc15"></span>',dotE='<span class=epdot style="background:#f472b6"></span>'; // start=yellow, end=magenta — matches the canvas end markers
|
|
606
741
|
const elev = col
|
|
607
|
-
? `<div class="sect f">Elevation</div>${ftFld('bos','Bottom (BOS)',m.col.bos)}${tosFld('tosC','Top (TOS)',m.col)}${nin('ntC',m.col.note)}${dFld('dtC',m.col)}`
|
|
608
|
-
: `<div class="sect f"
|
|
742
|
+
? `<div class="sect f">Elevation</div>${ftFld('bos',dotS+'Bottom (BOS)',m.col.bos)}${tosFld('tosC',dotE+'Top (TOS)',m.col)}${nin('ntC',m.col.note)}${dFld('dtC',m.col)}`
|
|
743
|
+
: `<div class="sect f">${dotS}Start</div>${tosFld('tosA','TOS',m.ends[0])}${nin('ntA',m.ends[0].note)}${dFld('dtA',m.ends[0])}
|
|
609
744
|
<div class=divrow><hr><button class=ghost id=matchEnds>Match → both ends</button><hr></div>
|
|
610
|
-
<div class=sect
|
|
745
|
+
<div class=sect>${dotE}End</div>${tosFld('tosB','TOS',m.ends[1])}${nin('ntB',m.ends[1].note)}${dFld('dtB',m.ends[1])}`;
|
|
611
746
|
p.innerHTML=`<h3>Member ${esc(m.id)}</h3>
|
|
612
747
|
<div class=row><label>Profile</label><div style="display:flex;gap:6px"><input id=pf class=combo data-src=profiles value="${esc(m.profile)}" style="flex:1" autocomplete=off><button id=pickProf class="ghost${(picking&&pickKind==='profile')?' on':''}" title="Pick profile by clicking a label in the drawing">⌖ pick</button></div>${(picking&&pickKind==='profile')?'<div class="hint" style="margin-top:4px;font-style:italic;color:var(--brand)">Click a profile label in the drawing…</div>':(picking&&pickKind==='detail')?'<div class="hint" style="margin-top:4px;font-style:italic;color:#a855f7">Click a detail callout in the drawing…</div>':''}</div>
|
|
613
748
|
<div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
|
|
@@ -615,9 +750,13 @@ function panel(){
|
|
|
615
750
|
<div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} title="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
|
|
616
751
|
${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>`:''}
|
|
617
752
|
${elev}
|
|
753
|
+
${col?'':`<div class=divrow><hr><span class=sect style="margin:0">Profile position</span><hr></div>
|
|
754
|
+
<div class=hint style="margin:0 0 6px">Where the section sits on the reference line (between the end dots) — like Tekla. Top = top-of-steel (dots on the top flange).</div>
|
|
755
|
+
<div class=seg2 style="margin-top:0"><button id=posTop class="${pos==='top'?'on':''}">Top</button><button id=posMid class="${pos==='middle'?'on':''}">Middle</button><button id=posBot class="${pos==='bottom'?'on':''}">Bottom</button></div>`}
|
|
618
756
|
<div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
|
|
619
757
|
<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>
|
|
620
758
|
<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>
|
|
759
|
+
<div class=row><button class=ghostw id=swapEnds title="Reverse the member: swap the start (${col?'bottom':'yellow'}) and end (${col?'top':'magenta'}) handles">⇄ Swap start ↔ end</button></div>
|
|
621
760
|
<div class="row f"><button class=danger id=del>Delete member</button></div>`;
|
|
622
761
|
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();}});
|
|
623
762
|
document.getElementById('pickProf').onclick=()=>{if(picking&&pickKind==='profile'){picking=false;}else{picking=true;pickKind='profile';pickEnd=null;}render();};
|
|
@@ -627,6 +766,10 @@ function panel(){
|
|
|
627
766
|
const _gel=document.getElementById('geoEL'),_gsp=document.getElementById('geoSplit');
|
|
628
767
|
if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};
|
|
629
768
|
if(_gsp)_gsp.onclick=()=>{geoMode=(geoMode==='split'?null:'split');setGeo();render();};
|
|
769
|
+
{const _sw=document.getElementById('swapEnds');if(_sw)_sw.onclick=()=>edit(()=>swapMemberEnds(m));} // reverse start↔end (2D + 3D via render+sync3D)
|
|
770
|
+
{const setPos=v=>edit(()=>{m.position=v;}); // profile placement on the reference line (Tekla-style); persists + re-extrudes the 3D
|
|
771
|
+
const _pt=document.getElementById('posTop'),_pm=document.getElementById('posMid'),_pb=document.getElementById('posBot');
|
|
772
|
+
if(_pt)_pt.onclick=()=>setPos('top');if(_pm)_pm.onclick=()=>setPos('middle');if(_pb)_pb.onclick=()=>setPos('bottom');}
|
|
630
773
|
const wireTos=(id,o)=>{const ck=document.getElementById(id+'_ck');if(ck)ck.onchange=e=>edit(()=>{o.tosDef=e.target.checked;if(o.tosDef)o.tos=defaultTOS;});
|
|
631
774
|
const inp=document.getElementById(id);if(inp)inp.onchange=e=>edit(()=>{o.tos=parseLen(e.target.value);o.tosDef=false;});};
|
|
632
775
|
const wireDet=(id,o)=>{const i=document.getElementById(id);if(i)i.onchange=e=>edit(()=>{o.detail=e.target.value.trim();});
|
|
@@ -649,9 +792,9 @@ function panel(){
|
|
|
649
792
|
document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();});
|
|
650
793
|
{const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
|
|
651
794
|
}
|
|
652
|
-
function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const
|
|
653
|
-
P.members.push(ensureMeta({id,profile:
|
|
654
|
-
if(
|
|
795
|
+
function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const prof=addProfile;const id='m'+Date.now();const pv=snapshot();
|
|
796
|
+
P.members.push(ensureMeta({id,profile:prof,wp:[sg.a.slice(),sg.b.slice()],angle:sg.o,rfi:(WT[prof]==null)}));
|
|
797
|
+
if(prof&&!profs.includes(prof)){profs.push(prof);profs.sort();}pushUndo(pv);render();sync3D();}
|
|
655
798
|
// --- marquee hit-test (crossing selection): does segment ab touch rect [x0,y0,x1,y1]? ---
|
|
656
799
|
function inRect(p,r){return p[0]>=r[0]&&p[0]<=r[2]&&p[1]>=r[1]&&p[1]<=r[3];}
|
|
657
800
|
function ccw(a,b,c){return (c[1]-a[1])*(b[0]-a[0])-(b[1]-a[1])*(c[0]-a[0]);}
|
|
@@ -686,17 +829,18 @@ function toast(msg){let t=document.getElementById('toast');
|
|
|
686
829
|
t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
|
|
687
830
|
// --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
|
|
688
831
|
svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
689
|
-
if(geoMode&&selIds.size===1){const id=[...selIds][0],m=byId(id);
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
832
|
+
if(geoMode==='split'&&selIds.size===1){const id=[...selIds][0],m=byId(id);
|
|
833
|
+
const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
|
|
834
|
+
const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]); // clicked on the member?
|
|
835
|
+
if(d>=16/zoom||raw.t<=0.03||raw.t>=0.97){geoMode=null;setGeo();snapClear();render();return;}
|
|
836
|
+
let cut=raw.pt; // snap the cut to a nearby endpoint/grid, kept ON the member
|
|
837
|
+
if(!e.altKey){buildSnap(m.id);const sn=snap(raw.pt[0],raw.pt[1]);if(sn.hit){const p2=projPt([sn.x,sn.y],m.wp[0],m.wp[1]);if(p2.t>0.02&&p2.t<0.98)cut=p2.pt;}}
|
|
838
|
+
snapClear();doSplit(m,cut);return;}
|
|
839
|
+
if(geoMode==='el'&&selIds.size>=1){ // extend/trim → pick ONE target line; ALL selected ends snap to it
|
|
840
|
+
let tgt=null;
|
|
841
|
+
if(t.classList.contains('member')&&!selIds.has(t.dataset.id)){const tm=byId(t.dataset.id);if(tm)tgt=[tm.wp[0],tm.wp[1]];}
|
|
698
842
|
else if(t.classList.contains('seg')){const sg=P.segments.find(s=>s.id===t.dataset.seg);if(sg)tgt=[sg.a,sg.b];}
|
|
699
|
-
if(tgt){geoMode=null;setGeo();
|
|
843
|
+
if(tgt){geoMode=null;setGeo();snapEndMulti(selArr(),tgt[0],tgt[1]);} // miss (empty/own line) keeps the mode armed
|
|
700
844
|
return;}
|
|
701
845
|
if(t.classList.contains('handle')){const id=t.dataset.mid||[...selIds][0],m=byId(id),h=+t.dataset.h;if(!m)return;buildSnap(id);
|
|
702
846
|
drag={type:'end',h,id,anchor:m.wp[1-h].slice(),pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
|
|
@@ -768,42 +912,83 @@ document.getElementById('undoB').onclick=doUndo;
|
|
|
768
912
|
document.getElementById('redoB').onclick=doRedo;
|
|
769
913
|
addEventListener('keydown',e=>{
|
|
770
914
|
const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
|
|
915
|
+
if(e.key==='Escape'&&moreOpen()){closeMore();moreBtn.focus();return;}
|
|
771
916
|
if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
|
|
772
917
|
if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
|
|
773
918
|
if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
|
|
774
919
|
if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
|
|
775
920
|
if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
|
|
776
921
|
if(e.key==='Escape'&&confOpen()){closeConf();return;}
|
|
777
|
-
if(e.key==='Home'){e.preventDefault();fitToWindow();return;}
|
|
922
|
+
if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
|
|
923
|
+
if(!view3d&&!inForm&&(((e.key==='z'||e.key==='Z')&&e.altKey)||(e.key===' '&&e.shiftKey))){e.preventDefault();zoomToSelection();return;} // 2D zoom-to-selected (Tekla Shift+Space / Alt+Z); 3D handles its own (steel-3d-view onKey)
|
|
778
924
|
if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
|
|
779
925
|
if((e.key==='Delete'||e.key==='Backspace')&&selIds.size&&!inForm){e.preventDefault();edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});return;}
|
|
780
|
-
if(!inForm&&selIds.size
|
|
781
|
-
if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;}
|
|
782
|
-
if(kk==='s'){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}}
|
|
926
|
+
if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
|
|
927
|
+
if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
|
|
928
|
+
if(kk==='s'&&selIds.size===1){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}} // Split — single only
|
|
783
929
|
if(!(e.ctrlKey||e.metaKey))return;const k=e.key.toLowerCase();
|
|
784
930
|
if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
|
|
785
931
|
else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
|
|
786
932
|
else if(k==='d'&&selIds.size){e.preventDefault();doDup();}
|
|
787
|
-
else if(k==='a'&&!inForm){e.preventDefault();selIds=new Set(P.members.map(m=>m.id));render();}
|
|
933
|
+
else if(k==='a'&&!inForm){e.preventDefault();selIds=new Set(P.members.map(m=>m.id));render();}
|
|
934
|
+
else if(k==='s'){e.preventDefault();persist();(window.flushContract?window.flushContract():Promise.resolve()).then(()=>toast('Saved ✓')).catch(()=>toast('Save failed'));}}); // edits autosave; Ctrl+S just flushes now (no browser "save page" dialog)
|
|
788
935
|
// zoom bar
|
|
789
936
|
document.getElementById('zIn').onclick=()=>zoomCentered(zoom*1.25);
|
|
790
937
|
document.getElementById('zOut').onclick=()=>zoomCentered(zoom/1.25);
|
|
791
938
|
document.getElementById('zFit').onclick=fitToWindow;
|
|
792
939
|
document.getElementById('zRange').oninput=e=>zoomCentered(+e.target.value/100);
|
|
940
|
+
// --- "More" overflow menu: click-toggles a themed popup; closes on item-click, click-outside, or Esc. ---
|
|
941
|
+
const moreBtn=document.getElementById('moreBtn'),moreMenu=document.getElementById('moreMenu');
|
|
942
|
+
function moreOpen(){return moreMenu.classList.contains('open');}
|
|
943
|
+
function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)closeMore();}
|
|
944
|
+
function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
|
|
945
|
+
moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
|
|
946
|
+
moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu
|
|
793
947
|
// --- 2D|3D view toggle. 3D is rendered by Steel3DView (Three.js, loaded as a module → window). It
|
|
794
948
|
// fetches the SAME scene the bake uses (/api/contract/:id/scene) so 2D and 3D show one contract. ---
|
|
795
949
|
const view3dApi={
|
|
796
950
|
// POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
|
|
797
951
|
fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');return j.scene;}, // endpoint shape: {ok, scene, skipped}
|
|
798
|
-
onSelect:(id,additive)=>{if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();}, // 3D pick → shared selection
|
|
952
|
+
onSelect:(id,additive)=>{if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();}, // 3D pick → shared selection
|
|
953
|
+
onSelectMany:(ids)=>{selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
|
|
799
954
|
getMembers:()=>P.members, // raw members (wp) for snap geometry
|
|
800
955
|
ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
|
|
801
956
|
defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
|
|
957
|
+
geoMode:()=>geoMode, // 'el' | 'split' | null — armed Trim/Extend/Split state
|
|
802
958
|
onMoveMember:(id,newWp)=>{edit(()=>{const m=byId(id);if(m)m.wp=newWp;});}, // 3D drag → write wp (undoable, autosaves, syncs 2D/chip/BOM)
|
|
959
|
+
onMoveEndpoint:(id,end,wp)=>{edit(()=>{const m=byId(id);if(m&&Array.isArray(m.wp)&&m.wp.length>=2)m.wp[end]=wp;});}, // 3D end-node drag → write just that endpoint
|
|
803
960
|
onElevateMember:(id,dIn)=>{edit(()=>{const m=byId(id);if(!m)return;ensureMeta(m); // 3D Alt-drag → raise/lower T.O.S by dIn inches
|
|
804
961
|
if(m.role==='column'){m.col.tos=(m.col.tos!=null?m.col.tos:defaultTOS)+dIn;m.col.bos=(m.col.bos!=null?m.col.bos:0)+dIn;m.col.tosDef=false;} // rigid raise (bos null→0, both shift) so the column isn't stretched
|
|
805
962
|
else m.ends.forEach(e=>{e.tos=(e.tos!=null?e.tos:defaultTOS)+dIn;e.tosDef=false;});});},
|
|
963
|
+
onTrimExtend:(targetId)=>{const tm=byId(targetId);if(tm){geoMode=null;setGeo();snapEndMulti(selArr(),tm.wp[0],tm.wp[1]);}}, // 3D Trim/Extend → every selected member's nearest end to the target line
|
|
964
|
+
onSplit:(id,wp)=>{const m=byId(id);if(m){doSplit(m,wp);sync3D();}}, // 3D Split → cut at the clicked point on the member
|
|
806
965
|
};
|
|
966
|
+
// Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
|
|
967
|
+
// changes go through render()'s setSelection — only geometry mutations need a rebuild.
|
|
968
|
+
function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{window.Steel3DView.setSelection(selIds);build3DLegend();}).catch(()=>{});}} // rebuild also refreshes the legend (an edit may add/remove a profile)
|
|
969
|
+
// Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
|
|
970
|
+
// double-click isolate — mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
|
|
971
|
+
let leg3dClickT=null;
|
|
972
|
+
function build3DLegend(){const host=document.getElementById('m3dLegend');if(!host||!window.Steel3DView)return;
|
|
973
|
+
const groups=window.Steel3DView.getGroups();host.replaceChildren();
|
|
974
|
+
if(!groups.length){host.style.display='none';return;}
|
|
975
|
+
const hint=document.createElement('div');hint.className='lhint';hint.textContent='click: hide/show · dbl-click: isolate';host.appendChild(hint);
|
|
976
|
+
for(const g of groups){const row=document.createElement('div');row.className='lrow';row.dataset.key=g.key;
|
|
977
|
+
const sw=document.createElement('span');sw.className='lsw';sw.style.background=g.color;
|
|
978
|
+
row.append(sw,document.createTextNode(g.label));
|
|
979
|
+
row.addEventListener('click',()=>{clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
|
|
980
|
+
row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);window.Steel3DView.soloToggle(g.key);refresh3DLegend();});
|
|
981
|
+
host.appendChild(row);}
|
|
982
|
+
host.style.display='flex';refresh3DLegend();}
|
|
983
|
+
function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3DView.groupState(),hidden=new Set(st.hidden);
|
|
984
|
+
document.querySelectorAll('#m3dLegend .lrow').forEach(r=>{const k=r.dataset.key;r.classList.toggle('off',hidden.has(k)||(st.solo!==null&&st.solo!==k));});}
|
|
985
|
+
let bar3dWired=false;
|
|
986
|
+
function seg3dActive(sel,attr,val){document.querySelectorAll(sel+' button').forEach(b=>b.classList.toggle('on',b.getAttribute(attr)===val));}
|
|
987
|
+
function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
988
|
+
document.querySelectorAll('#m3dProj button').forEach(b=>b.onclick=()=>{window.Steel3DView.setProjection(b.dataset.proj);seg3dActive('#m3dProj','data-proj',b.dataset.proj);});
|
|
989
|
+
document.querySelectorAll('#m3dMode button').forEach(b=>b.onclick=()=>{window.Steel3DView.setDisplayMode(b.dataset.mode);seg3dActive('#m3dMode','data-mode',b.dataset.mode);});
|
|
990
|
+
document.getElementById('m3dFit').onclick=()=>window.Steel3DView.frameAll();
|
|
991
|
+
document.getElementById('m3dRef').onclick=()=>{const on=!window.Steel3DView.refLine();window.Steel3DView.setRefLine(on);document.getElementById('m3dRef').classList.toggle('on',on);};}
|
|
807
992
|
function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
|
|
808
993
|
view3d=on;
|
|
809
994
|
const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
|
|
@@ -812,6 +997,10 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
|
|
|
812
997
|
document.getElementById('stage').style.display=on?'none':'';
|
|
813
998
|
document.getElementById('stage3d').style.display=on?'block':'none';
|
|
814
999
|
document.getElementById('zoombar').style.display=on?'none':''; // zoom slider is 2D-only
|
|
1000
|
+
document.getElementById('planSel').style.display=on?'none':''; // plan selector is 2D-only (3D shows the whole model)
|
|
1001
|
+
document.getElementById('m3dBar').style.display=on?'flex':'none';
|
|
1002
|
+
document.getElementById('m3dCube').style.display=on?'block':'none';
|
|
1003
|
+
if(!on)document.getElementById('m3dLegend').style.display='none'; // legend is shown by build3DLegend when entering 3D
|
|
815
1004
|
}
|
|
816
1005
|
async function setView(on){
|
|
817
1006
|
if(on){
|
|
@@ -820,8 +1009,10 @@ async function setView(on){
|
|
|
820
1009
|
if(!view3dReady){window.Steel3DView.init(document.getElementById('stage3d'),view3dApi);view3dReady=true;}
|
|
821
1010
|
applyViewState(true);
|
|
822
1011
|
window.Steel3DView.show();
|
|
823
|
-
await window.Steel3DView.rebuild();
|
|
1012
|
+
await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
|
|
824
1013
|
window.Steel3DView.setSelection(selIds);
|
|
1014
|
+
wire3DBar();build3DLegend();
|
|
1015
|
+
seg3dActive('#m3dProj','data-proj',window.Steel3DView.projection());seg3dActive('#m3dMode','data-mode',window.Steel3DView.mode()); // reflect persisted state
|
|
825
1016
|
}catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
|
|
826
1017
|
applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
|
|
827
1018
|
toast('Could not open 3D view: '+((e&&e.message)||e));
|
|
@@ -934,6 +1125,13 @@ function rfiReason(m){if(!m.profile)return 'No profile assigned';
|
|
|
934
1125
|
function zoomToMember(m){const a=m.wp[0],b=m.wp[1],w=Math.abs(a[0]-b[0])||40,h=Math.abs(a[1]-b[1])||40;
|
|
935
1126
|
applyZoom(Math.max(.3,Math.min(2.5,Math.min(stage.clientWidth/(w*2.2),stage.clientHeight/(h*2.2)))));
|
|
936
1127
|
const cx=(a[0]+b[0])/2,cy=(a[1]+b[1])/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
|
|
1128
|
+
// Frame the whole current selection (Tekla "Zoom selected"); falls back to fit-all when nothing's picked — mirrors the 3D view's frameSelection().
|
|
1129
|
+
function zoomToSelection(){const arr=selArr();if(!arr.length){fitToWindow();return;}
|
|
1130
|
+
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
|
|
1131
|
+
for(const m of arr)for(const p of m.wp){if(p[0]<x0)x0=p[0];if(p[0]>x1)x1=p[0];if(p[1]<y0)y0=p[1];if(p[1]>y1)y1=p[1];}
|
|
1132
|
+
const w=Math.max(x1-x0,40),h=Math.max(y1-y0,40);
|
|
1133
|
+
applyZoom(Math.min(stage.clientWidth/(w*1.35),stage.clientHeight/(h*1.35)));
|
|
1134
|
+
const cx=(x0+x1)/2,cy=(y0+y1)/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
|
|
937
1135
|
function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
|
|
938
1136
|
g.innerHTML=list.length?('<div class=hint style="margin-bottom:10px">'+list.length+' member'+(list.length>1?'s':'')+' have no resolved AISC size, so their weight is excluded from the BOM. Assign a profile to clear it — for <b>MF/BF</b> marks use the <b>Frames</b> schedule. Edits here update the contract.</div><table class=ftab><thead><tr><th>#</th><th>Profile / mark</th><th>Role</th><th>Length</th><th>Why it’s flagged</th><th></th></tr></thead><tbody>'+
|
|
939
1137
|
list.map((m,i)=>{ensureMeta(m);const L=(len(m.wp[0],m.wp[1])/FT).toFixed(1);
|
|
@@ -1144,6 +1342,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
|
|
|
1144
1342
|
undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
|
|
1145
1343
|
selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
|
|
1146
1344
|
defaultTOS=(P.default_tos!=null?P.default_tos:198);
|
|
1345
|
+
P.default_tos=defaultTOS; // make the default explicit so the 3D scene + bake use the SAME TOS the editor/dots do (contractToScene falls back to 0, not 198 — keeping them in sync stops the end dots floating off the steel)
|
|
1147
1346
|
if(!P.autofilled){autofillTOS();P.autofilled=true;}
|
|
1148
1347
|
const ds=new Set([...(P.details||[]).map(d=>d.text),...Object.keys(C.custom_details)]);document.getElementById('detailsBtn').textContent='Details ('+ds.size+')';
|
|
1149
1348
|
setMode();render();fitToWindow();
|
|
@@ -1165,7 +1364,7 @@ try{stage.focus({preventScroll:true});}catch(_){} // grab keyboard focus on op
|
|
|
1165
1364
|
if(src.read_at){try{const d=new Date(src.read_at);if(!isNaN(d.getTime()))parts.push(d.toLocaleDateString());}catch{/* skip unparseable date */}}
|
|
1166
1365
|
if(!parts.length)return;
|
|
1167
1366
|
const el=document.getElementById('srcStat');if(!el)return;
|
|
1168
|
-
el.textContent=parts.join(' · ');el.style.display='';
|
|
1367
|
+
el.textContent=parts.join(' · ');el.title=parts.join(' · ');el.style.display=''; // title = full provenance (the chip itself truncates with ellipsis)
|
|
1169
1368
|
})();
|
|
1170
1369
|
// --- Ask AI: send instruction + optional screenshots → POST /api/contract-request ---
|
|
1171
1370
|
// The server records a tweak-contract request; the terminal AI picks it up async and PUTs
|