@floless/app 0.36.0 → 0.37.1
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 +2 -2
- package/dist/web/steel-3d-view.js +12 -9
- package/dist/web/steel-editor.html +159 -38
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -52856,7 +52856,7 @@ function appVersion() {
|
|
|
52856
52856
|
return resolveVersion({
|
|
52857
52857
|
isSea: isSea2(),
|
|
52858
52858
|
sqVersionXml: readSqVersionXml(),
|
|
52859
|
-
define: true ? "0.
|
|
52859
|
+
define: true ? "0.37.1" : void 0,
|
|
52860
52860
|
pkgVersion: readPkgVersion()
|
|
52861
52861
|
});
|
|
52862
52862
|
}
|
|
@@ -52866,7 +52866,7 @@ function resolveChannel(s) {
|
|
|
52866
52866
|
return "dev";
|
|
52867
52867
|
}
|
|
52868
52868
|
function appChannel() {
|
|
52869
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
52869
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.37.1" : void 0 });
|
|
52870
52870
|
}
|
|
52871
52871
|
|
|
52872
52872
|
// oauth-presets.ts
|
|
@@ -464,9 +464,9 @@ function addEndpoint(p, mat, id, end) {
|
|
|
464
464
|
}
|
|
465
465
|
function sizeEndpoints() {
|
|
466
466
|
if (!epGroup) return;
|
|
467
|
-
const r = pxToWorld(EP_PX); // small + screen-constant (shrinks as you zoom in, persp AND ortho)
|
|
468
467
|
const active = dragEp || hoverEp;
|
|
469
468
|
for (const c of epGroup.children) {
|
|
469
|
+
const r = pxToWorldAt(EP_PX, c.position); // per-dot depth → stays ~EP_PX on screen even zoomed in far from the pivot
|
|
470
470
|
const isActive = active && c.userData.epId === active.id && c.userData.epEnd === active.end;
|
|
471
471
|
c.scale.setScalar(isActive ? r * 1.6 : r); // the targeted end node pops
|
|
472
472
|
}
|
|
@@ -474,7 +474,7 @@ function sizeEndpoints() {
|
|
|
474
474
|
if (epRing) {
|
|
475
475
|
let dot = null;
|
|
476
476
|
if (active) dot = epGroup.children.find((c) => c.userData.epId === active.id && c.userData.epEnd === active.end);
|
|
477
|
-
if (dot) { epRing.position.copy(dot.position); epRing.quaternion.copy(camera.quaternion); epRing.scale.setScalar(
|
|
477
|
+
if (dot) { epRing.position.copy(dot.position); epRing.quaternion.copy(camera.quaternion); epRing.scale.setScalar(pxToWorldAt(EP_PX, dot.position) * 2.4); epRing.visible = true; }
|
|
478
478
|
else epRing.visible = false;
|
|
479
479
|
}
|
|
480
480
|
}
|
|
@@ -582,20 +582,23 @@ const members = () => (api && api.getMembers && api.getMembers()) || [];
|
|
|
582
582
|
const geoMode = () => (api && api.geoMode && api.geoMode()) || null;
|
|
583
583
|
// world units per screen pixel at the target plane — drives screen-CONSTANT marker/dot sizes, for
|
|
584
584
|
// BOTH perspective (distance) and ortho (frustum height / zoom), so dots stay small when zoomed in.
|
|
585
|
-
|
|
585
|
+
// world units that map to `px` screen pixels AT a world point. In perspective the scale uses that
|
|
586
|
+
// point's OWN distance to the camera (not the orbit target), so a dot far from the pivot keeps its
|
|
587
|
+
// size when you zoom in heavily; in ortho it's the frustum height / zoom.
|
|
588
|
+
function pxToWorldAt(px, pos) {
|
|
586
589
|
const h = (canvasEl && canvasEl.clientHeight) || 1;
|
|
587
|
-
if (camera === orthoCam) return ((orthoCam.top - orthoCam.bottom) / (orthoCam.zoom || 1)) / h;
|
|
588
|
-
|
|
590
|
+
if (camera === orthoCam) return px * ((orthoCam.top - orthoCam.bottom) / (orthoCam.zoom || 1)) / h;
|
|
591
|
+
const dist = pos ? camera.position.distanceTo(pos) : camera.position.distanceTo(controls.target);
|
|
592
|
+
return px * (2 * Math.tan(THREE.MathUtils.degToRad(perspCam.fov) / 2) * dist) / h;
|
|
589
593
|
}
|
|
590
|
-
const
|
|
591
|
-
const markerSize = () => pxToWorld(8); // snap marker ~8px radius
|
|
594
|
+
const markerSize = () => pxToWorldAt(8, marker && marker.position); // snap marker ~8px at its own depth
|
|
592
595
|
|
|
593
596
|
function onDown(e) {
|
|
594
597
|
if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
|
|
595
598
|
downXY = [e.clientX, e.clientY];
|
|
596
599
|
let id = null; try { id = pickAt(e.clientX, e.clientY); } catch { id = null; }
|
|
597
600
|
// Geometry-edit mode (Trim/Extend or Split armed in the inspector): a click does the edit, no drag.
|
|
598
|
-
if (geoMode() && selIds.size === 1) { e.stopPropagation(); pending = { geo: geoMode(), selId: [...selIds][0], targetId: id }; return; }
|
|
601
|
+
if (geoMode() && (geoMode() === 'el' ? selIds.size >= 1 : selIds.size === 1)) { e.stopPropagation(); pending = { geo: geoMode(), selId: [...selIds][0], targetId: id }; return; } // Split: single; Extend/Trim: any selection
|
|
599
602
|
// An end node (the yellow/magenta dot) under the cursor → grab just that endpoint to stretch the member.
|
|
600
603
|
let ep = null; try { ep = pickEndpoint(e.clientX, e.clientY); } catch { ep = null; }
|
|
601
604
|
if (ep) { e.stopPropagation(); controls.enabled = false; startEndpointGrab(ep, e); return; }
|
|
@@ -739,7 +742,7 @@ function onUp(e) {
|
|
|
739
742
|
}
|
|
740
743
|
if (p.geo) { // Trim/Extend or Split — a click (no drag) performs the edit
|
|
741
744
|
if (moved > DRAG_TOL_PX) return;
|
|
742
|
-
if (p.geo === 'el') { if (p.targetId && p.targetId
|
|
745
|
+
if (p.geo === 'el') { if (p.targetId && !selIds.has(p.targetId) && api && api.onTrimExtend) api.onTrimExtend(p.targetId); return; } // target line ≠ a selected member; trims ALL selected
|
|
743
746
|
if (p.geo === 'split') { // cut the selected member at the clicked point on its centreline
|
|
744
747
|
const m = members().find((x) => x.id === p.selId); if (!m) return;
|
|
745
748
|
const ppf = api.ptPerFt();
|
|
@@ -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)}
|
|
@@ -175,15 +187,26 @@
|
|
|
175
187
|
<span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
|
|
176
188
|
<button id=undoB title="Undo (Ctrl+Z)">↶</button>
|
|
177
189
|
<button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
|
|
178
|
-
<button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
|
|
179
|
-
<button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
|
|
180
190
|
<button id=mAdd title="Toggle add-member mode">Add member</button>
|
|
181
|
-
<button id=detailsBtn>Details</button>
|
|
182
|
-
<button id=framesBtn>Frames</button>
|
|
183
|
-
<button id=revertB title="Discard all saved edits and reload the detected contract">Revert</button>
|
|
184
|
-
<button id=exp>Export contract</button>
|
|
185
|
-
<button id=reloadB title="Reload from server — picks up any AI writebacks">↻ Reload</button>
|
|
186
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>
|
|
187
210
|
</header>
|
|
188
211
|
<main>
|
|
189
212
|
<div id=stagewrap>
|
|
@@ -409,6 +432,14 @@ function posDefault(m){
|
|
|
409
432
|
const z1=(m.ends&&m.ends[1]&&m.ends[1].tos!=null)?m.ends[1].tos:defaultTOS;
|
|
410
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;
|
|
411
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;}
|
|
412
443
|
// best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
|
|
413
444
|
// 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
|
|
414
445
|
let defaultTOS=198, addProfile='';
|
|
@@ -476,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;
|
|
|
476
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};}
|
|
477
508
|
function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
|
|
478
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.
|
|
479
|
-
|
|
480
|
-
|
|
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;}
|
|
481
516
|
function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(JSON.stringify(m));
|
|
482
517
|
const a=m.wp[0].slice(),b=m.wp[1].slice(),q=pt.slice(),mk=()=>({tos:null,note:'',tosDef:true,detail:''});
|
|
483
518
|
m.wp=[a,q];if(m.ends)m.ends=[m.ends[0],mk()]; // first half keeps its start end, fresh cut end
|
|
@@ -535,6 +570,10 @@ const svg=document.getElementById('svg');
|
|
|
535
570
|
function toSvg(e){const p=svg.createSVGPoint();p.x=e.clientX;p.y=e.clientY;return p.matrixTransform(svg.getScreenCTM().inverse());}
|
|
536
571
|
// --- zoom + pan ---
|
|
537
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;
|
|
538
577
|
let zoom=1; const stage=document.getElementById('stage');
|
|
539
578
|
// The editor runs in an iframe; keyboard shortcuts (Ctrl+D etc.) only reach our handler when the
|
|
540
579
|
// iframe has focus. The SVG canvas isn't focusable, so clicking it wouldn't grab focus and a real
|
|
@@ -557,12 +596,12 @@ function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot
|
|
|
557
596
|
selIds=ns;mode='sel';setMode();pushUndo(pv);render();}
|
|
558
597
|
function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'<','>':'>','&':'&','"':'"'}[c]));}
|
|
559
598
|
function render(){
|
|
560
|
-
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
|
|
561
600
|
let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
|
|
562
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]}"/>`;
|
|
563
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})"`:'';
|
|
564
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}/>`;}
|
|
565
|
-
{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
|
|
566
605
|
if((mode==='add'||(picking&&pickKind==='profile'))&&P.labels) for(const lb of P.labels){const w=Math.max(40,lb.text.length*11);
|
|
567
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>`;}
|
|
568
607
|
if(picking&&pickKind==='detail'&&P.details) for(const d of P.details){const w=Math.max(34,d.text.length*8);
|
|
@@ -581,11 +620,12 @@ function updDup(){const n=redundantDups().length;
|
|
|
581
620
|
document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
|
|
582
621
|
const b=document.getElementById('dupB');if(b.dataset.flash)return; // don't clobber transient "none" feedback
|
|
583
622
|
b.disabled=false;b.classList.toggle('alert',n>0);b.textContent=n>0?('Duplicates ('+n+')'):'Re-check duplicates';}
|
|
584
|
-
// 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.
|
|
585
625
|
function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
|
|
586
626
|
svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
|
|
587
627
|
svg.querySelectorAll('text.numtx').forEach(t=>{t.setAttribute('x',(+t.dataset.bx)+ox(t));t.style.fontSize=F+'px';});
|
|
588
|
-
svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',
|
|
628
|
+
svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',epR()));}
|
|
589
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]);});}
|
|
590
630
|
function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
|
|
591
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]);}}
|
|
@@ -594,6 +634,10 @@ function stats(){
|
|
|
594
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;}
|
|
595
635
|
document.getElementById('wt').textContent=(w/2000).toFixed(1);document.getElementById('wtlb').textContent=Math.round(w).toLocaleString();document.getElementById('rc').textContent=rfi;
|
|
596
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;}
|
|
597
641
|
function panel(){
|
|
598
642
|
const p=document.getElementById('panel');
|
|
599
643
|
if(mode==='add'){
|
|
@@ -619,15 +663,71 @@ function panel(){
|
|
|
619
663
|
const _rc=document.getElementById('resetCols');if(_rc)_rc.onclick=()=>{C.profile_colors={};scheduleSave();render();};
|
|
620
664
|
return;}
|
|
621
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;
|
|
622
669
|
const totalL=arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT,0).toFixed(1);
|
|
623
|
-
const
|
|
624
|
-
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;
|
|
625
693
|
p.innerHTML=`<h3>${arr.length} members selected</h3>
|
|
626
|
-
<div class=row
|
|
627
|
-
<div class=
|
|
628
|
-
<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 · Shortcut: P">⇄ Swap start ↔ end (all) <span style="opacity:.55">(P)</span></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).
|
|
629
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)
|
|
630
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};});}}
|
|
631
731
|
return;}
|
|
632
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);
|
|
633
733
|
const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
|
|
@@ -656,6 +756,7 @@ function panel(){
|
|
|
656
756
|
<div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
|
|
657
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>
|
|
658
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 · Shortcut: P">⇄ Swap start ↔ end <span style="opacity:.55">(P)</span></button></div>
|
|
659
760
|
<div class="row f"><button class=danger id=del>Delete member</button></div>`;
|
|
660
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();}});
|
|
661
762
|
document.getElementById('pickProf').onclick=()=>{if(picking&&pickKind==='profile'){picking=false;}else{picking=true;pickKind='profile';pickEnd=null;}render();};
|
|
@@ -665,6 +766,7 @@ function panel(){
|
|
|
665
766
|
const _gel=document.getElementById('geoEL'),_gsp=document.getElementById('geoSplit');
|
|
666
767
|
if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};
|
|
667
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)
|
|
668
770
|
{const setPos=v=>edit(()=>{m.position=v;}); // profile placement on the reference line (Tekla-style); persists + re-extrudes the 3D
|
|
669
771
|
const _pt=document.getElementById('posTop'),_pm=document.getElementById('posMid'),_pb=document.getElementById('posBot');
|
|
670
772
|
if(_pt)_pt.onclick=()=>setPos('top');if(_pm)_pm.onclick=()=>setPos('middle');if(_pb)_pb.onclick=()=>setPos('bottom');}
|
|
@@ -690,9 +792,9 @@ function panel(){
|
|
|
690
792
|
document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();});
|
|
691
793
|
{const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
|
|
692
794
|
}
|
|
693
|
-
function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const
|
|
694
|
-
P.members.push(ensureMeta({id,profile:
|
|
695
|
-
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();}
|
|
696
798
|
// --- marquee hit-test (crossing selection): does segment ab touch rect [x0,y0,x1,y1]? ---
|
|
697
799
|
function inRect(p,r){return p[0]>=r[0]&&p[0]<=r[2]&&p[1]>=r[1]&&p[1]<=r[3];}
|
|
698
800
|
function ccw(a,b,c){return (c[1]-a[1])*(b[0]-a[0])-(b[1]-a[1])*(c[0]-a[0]);}
|
|
@@ -727,17 +829,18 @@ function toast(msg){let t=document.getElementById('toast');
|
|
|
727
829
|
t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
|
|
728
830
|
// --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
|
|
729
831
|
svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
730
|
-
if(geoMode&&selIds.size===1){const id=[...selIds][0],m=byId(id);
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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]];}
|
|
739
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];}
|
|
740
|
-
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
|
|
741
844
|
return;}
|
|
742
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);
|
|
743
846
|
drag={type:'end',h,id,anchor:m.wp[1-h].slice(),pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
|
|
@@ -809,6 +912,7 @@ document.getElementById('undoB').onclick=doUndo;
|
|
|
809
912
|
document.getElementById('redoB').onclick=doRedo;
|
|
810
913
|
addEventListener('keydown',e=>{
|
|
811
914
|
const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
|
|
915
|
+
if(e.key==='Escape'&&moreOpen()){closeMore();moreBtn.focus();return;}
|
|
812
916
|
if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
|
|
813
917
|
if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
|
|
814
918
|
if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
|
|
@@ -816,21 +920,31 @@ addEventListener('keydown',e=>{
|
|
|
816
920
|
if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
|
|
817
921
|
if(e.key==='Escape'&&confOpen()){closeConf();return;}
|
|
818
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)
|
|
819
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;}
|
|
820
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;}
|
|
821
|
-
if(!inForm&&selIds.size
|
|
822
|
-
if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;}
|
|
823
|
-
if(kk==='
|
|
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==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
|
|
929
|
+
if(kk==='s'&&selIds.size===1){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}} // Split — single only
|
|
824
930
|
if(!(e.ctrlKey||e.metaKey))return;const k=e.key.toLowerCase();
|
|
825
931
|
if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
|
|
826
932
|
else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
|
|
827
933
|
else if(k==='d'&&selIds.size){e.preventDefault();doDup();}
|
|
828
|
-
else if(k==='a'&&!inForm){e.preventDefault();selIds=new Set(P.members.map(m=>m.id));render();}
|
|
934
|
+
else if(k==='a'&&!inForm){e.preventDefault();selIds=new Set(P.members.map(m=>m.id));render();}
|
|
935
|
+
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)
|
|
829
936
|
// zoom bar
|
|
830
937
|
document.getElementById('zIn').onclick=()=>zoomCentered(zoom*1.25);
|
|
831
938
|
document.getElementById('zOut').onclick=()=>zoomCentered(zoom/1.25);
|
|
832
939
|
document.getElementById('zFit').onclick=fitToWindow;
|
|
833
940
|
document.getElementById('zRange').oninput=e=>zoomCentered(+e.target.value/100);
|
|
941
|
+
// --- "More" overflow menu: click-toggles a themed popup; closes on item-click, click-outside, or Esc. ---
|
|
942
|
+
const moreBtn=document.getElementById('moreBtn'),moreMenu=document.getElementById('moreMenu');
|
|
943
|
+
function moreOpen(){return moreMenu.classList.contains('open');}
|
|
944
|
+
function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)closeMore();}
|
|
945
|
+
function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
|
|
946
|
+
moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
|
|
947
|
+
moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu
|
|
834
948
|
// --- 2D|3D view toggle. 3D is rendered by Steel3DView (Three.js, loaded as a module → window). It
|
|
835
949
|
// fetches the SAME scene the bake uses (/api/contract/:id/scene) so 2D and 3D show one contract. ---
|
|
836
950
|
const view3dApi={
|
|
@@ -847,7 +961,7 @@ const view3dApi={
|
|
|
847
961
|
onElevateMember:(id,dIn)=>{edit(()=>{const m=byId(id);if(!m)return;ensureMeta(m); // 3D Alt-drag → raise/lower T.O.S by dIn inches
|
|
848
962
|
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
|
|
849
963
|
else m.ends.forEach(e=>{e.tos=(e.tos!=null?e.tos:defaultTOS)+dIn;e.tosDef=false;});});},
|
|
850
|
-
onTrimExtend:(
|
|
964
|
+
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
|
|
851
965
|
onSplit:(id,wp)=>{const m=byId(id);if(m){doSplit(m,wp);sync3D();}}, // 3D Split → cut at the clicked point on the member
|
|
852
966
|
};
|
|
853
967
|
// Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
|
|
@@ -1012,6 +1126,13 @@ function rfiReason(m){if(!m.profile)return 'No profile assigned';
|
|
|
1012
1126
|
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;
|
|
1013
1127
|
applyZoom(Math.max(.3,Math.min(2.5,Math.min(stage.clientWidth/(w*2.2),stage.clientHeight/(h*2.2)))));
|
|
1014
1128
|
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;}
|
|
1129
|
+
// Frame the whole current selection (Tekla "Zoom selected"); falls back to fit-all when nothing's picked — mirrors the 3D view's frameSelection().
|
|
1130
|
+
function zoomToSelection(){const arr=selArr();if(!arr.length){fitToWindow();return;}
|
|
1131
|
+
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
|
|
1132
|
+
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];}
|
|
1133
|
+
const w=Math.max(x1-x0,40),h=Math.max(y1-y0,40);
|
|
1134
|
+
applyZoom(Math.min(stage.clientWidth/(w*1.35),stage.clientHeight/(h*1.35)));
|
|
1135
|
+
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;}
|
|
1015
1136
|
function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
|
|
1016
1137
|
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>'+
|
|
1017
1138
|
list.map((m,i)=>{ensureMeta(m);const L=(len(m.wp[0],m.wp[1])/FT).toFixed(1);
|
|
@@ -1244,7 +1365,7 @@ try{stage.focus({preventScroll:true});}catch(_){} // grab keyboard focus on op
|
|
|
1244
1365
|
if(src.read_at){try{const d=new Date(src.read_at);if(!isNaN(d.getTime()))parts.push(d.toLocaleDateString());}catch{/* skip unparseable date */}}
|
|
1245
1366
|
if(!parts.length)return;
|
|
1246
1367
|
const el=document.getElementById('srcStat');if(!el)return;
|
|
1247
|
-
el.textContent=parts.join(' · ');el.style.display='';
|
|
1368
|
+
el.textContent=parts.join(' · ');el.title=parts.join(' · ');el.style.display=''; // title = full provenance (the chip itself truncates with ellipsis)
|
|
1248
1369
|
})();
|
|
1249
1370
|
// --- Ask AI: send instruction + optional screenshots → POST /api/contract-request ---
|
|
1250
1371
|
// The server records a tweak-contract request; the terminal AI picks it up async and PUTs
|