@floless/app 0.54.1 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +598 -169
- package/dist/schemas/steel.takeoff.v1.schema.json +18 -0
- package/dist/skills/floless-app-new-workflow/SKILL.md +115 -0
- package/dist/skills/floless-app-new-workflow/references/build-and-verify.md +117 -0
- package/dist/skills/floless-app-onboarding/SKILL.md +2 -1
- package/dist/skills/floless-app-steel-model/SKILL.md +8 -4
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +84 -1
- package/dist/web/app.css +155 -0
- package/dist/web/app.js +6 -1
- package/dist/web/aware.js +247 -1
- package/dist/web/index.html +42 -0
- package/dist/web/steel-3d-view.js +149 -9
- package/dist/web/steel-editor.html +163 -8
- package/dist/web/vendor/README.md +15 -4
- package/dist/web/vendor/three-bvh-csg.module.js +7619 -0
- package/package.json +1 -1
|
@@ -198,9 +198,11 @@
|
|
|
198
198
|
#m3dLegend .lrow:hover{background:#33415580}
|
|
199
199
|
#m3dLegend .lrow.off{opacity:.4} #m3dLegend .lrow.off .lsw{filter:grayscale(1)}
|
|
200
200
|
#m3dLegend .lsw{width:11px;height:11px;border-radius:2px;flex:none}
|
|
201
|
+
#m3dLegend .lsec{color:#475569;font-size:10px;letter-spacing:.06em;text-transform:uppercase;margin:6px 0 2px;padding:0 4px}
|
|
202
|
+
#m3dLegend .ldiv{height:1px;background:var(--line);margin:5px 2px}
|
|
201
203
|
#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))}
|
|
202
204
|
</style>
|
|
203
|
-
<script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/"}}</script>
|
|
205
|
+
<script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/","three-bvh-csg":"./vendor/three-bvh-csg.module.js"}}</script>
|
|
204
206
|
<script type="module" src="./steel-3d-view.js"></script>
|
|
205
207
|
</head><body>
|
|
206
208
|
<header>
|
|
@@ -238,6 +240,8 @@
|
|
|
238
240
|
<div class=mlabel>Reference</div>
|
|
239
241
|
<button id=detailsBtn>Details</button>
|
|
240
242
|
<button id=connBtn title="Connection library — map each connection type to its design detail# and per-platform component id; reference a row from each member end">Connections</button>
|
|
243
|
+
<button id=bpBtn title="Auto-detail base plates on every column (a plate + anchor kit + weld, shown in 3D). Tune sizes per the schedule via the AI or per-column params.">Base plates</button>
|
|
244
|
+
<button id=spBtn title="Auto-detail bolted shear (fin) plates on eligible beam ends (a fin plate + bolt group + weld, shown in 3D). Tune sizes per the schedule via the AI or per-end params.">Shear plates</button>
|
|
241
245
|
<button id=framesBtn>Frames</button>
|
|
242
246
|
<hr>
|
|
243
247
|
<div class=mlabel>Session</div>
|
|
@@ -504,12 +508,20 @@ function syncDefaults(){for(const m of P.members){ensureMeta(m);
|
|
|
504
508
|
else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
|
|
505
509
|
const autofillTOS=syncDefaults;
|
|
506
510
|
// profs is per-plan — computed in setPlan()
|
|
507
|
-
|
|
511
|
+
// Undo snapshot. Per-plan: members/dims/frame + THIS plan's connection joints, SLICED by member ownership
|
|
512
|
+
// (j.main ∈ this plan's member ids). Each plan's undo only ever touches its own joints, never another sheet's
|
|
513
|
+
// — this is what makes connection undo cross-plan-safe on top of the per-plan undo stacks (undo=P.undo).
|
|
514
|
+
// INVARIANT it leans on: a base-plate column's id is unique across plans (holds for real models — base plates
|
|
515
|
+
// live on one foundation sheet; the reader's ids are unique within the set that gets detailed). The one
|
|
516
|
+
// unsupported edge is two DIFFERENT sheets each detailing a column that happens to share an id — then the
|
|
517
|
+
// slice would over-match. Tracked as a reader-side id-uniqueness hardening. dims3d rides its own persistence.
|
|
518
|
+
function snapshot(){const pm=new Set((P.members||[]).map(m=>m.id));return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null,joints:(C.joints||[]).filter(j=>j&&pm.has(j.main))});}
|
|
508
519
|
function refreshDims3d(){if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();} // repaint the 3D dim lines/labels from C.dims3d
|
|
509
520
|
function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
|
|
510
521
|
function apply(json){const d=JSON.parse(json);
|
|
511
522
|
if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
|
|
512
|
-
else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;if('frame' in d)P.frame=d.frame||null;
|
|
523
|
+
else{const pmOld=new Set((P.members||[]).map(m=>m.id));P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;if('frame' in d)P.frame=d.frame||null;
|
|
524
|
+
if('joints' in d){const own=new Set([...pmOld,...P.members.map(m=>m.id)]);C.joints=(C.joints||[]).filter(j=>!(j&&own.has(j.main))).concat(d.joints||[]);}} // restore THIS plan's joint slice: drop joints owned by this plan (old ∪ restored member ids), re-add the snapshot's — OTHER sheets' joints stay untouched. Legacy member-only snapshots lack 'joints' → left alone. dims3d rides its own path.
|
|
513
525
|
updCS();
|
|
514
526
|
selIds=new Set([...selIds].filter(id=>byId(id)));
|
|
515
527
|
selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
|
|
@@ -529,7 +541,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
|
|
|
529
541
|
else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
|
|
530
542
|
else el.textContent=msg||'Auto-save on';}
|
|
531
543
|
function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
|
|
532
|
-
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d,
|
|
544
|
+
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, joints:C.joints,
|
|
533
545
|
plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details,dims:p.dims,frame:p.frame||null}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
|
|
534
546
|
// --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
|
|
535
547
|
// localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
|
|
@@ -561,6 +573,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
|
|
|
561
573
|
if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
|
|
562
574
|
C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;if(Array.isArray(s.dims))p.dims=s.dims;if('frame' in s)p.frame=s.frame||null;p.autofilled=true;}});
|
|
563
575
|
if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
|
|
576
|
+
if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
|
|
564
577
|
if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
|
|
565
578
|
function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
|
|
566
579
|
function colorFor(p){if(C.profile_colors[p])return C.profile_colors[p];let i=profs.indexOf(p);return PAL[((i%PAL.length)+PAL.length)%PAL.length];}
|
|
@@ -786,7 +799,7 @@ function render(){
|
|
|
786
799
|
s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
|
|
787
800
|
s+=renderDims();
|
|
788
801
|
if(P.frame)s+=axisGlyphSvg(P.frame.o,P.frame.u,false); // local-axes glyph at the origin (only when a frame is set; removed on reset)
|
|
789
|
-
svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS(); updConnBtn();
|
|
802
|
+
svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS(); updConnBtn(); updBpBtn(); updSpBtn();
|
|
790
803
|
if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
|
|
791
804
|
}
|
|
792
805
|
function updDup(){const n=redundantDups().length;
|
|
@@ -953,6 +966,37 @@ function panel(){
|
|
|
953
966
|
: `<div class="sect f">${dotS}Start</div>${tosFld('tosA','TOS',m.ends[0])}${cFld('cnA',m.ends[0])}${fb(m.ends[0],nin('ntA',m.ends[0].note)+dFld('dtA',m.ends[0]))}
|
|
954
967
|
<div class=divrow><hr><button class=ghost id=matchEnds>Match → both ends</button><hr></div>
|
|
955
968
|
<div class=sect>${dotE}End</div>${tosFld('tosB','TOS',m.ends[1])}${cFld('cnB',m.ends[1])}${fb(m.ends[1],nin('ntB',m.ends[1].note)+dFld('dtB',m.ends[1]))}`;
|
|
969
|
+
const bpj=col?(C.joints||[]).find(j=>j&&j.kind==='base-plate'&&j.main===m.id):null; // this column's base-plate joint, if detailed
|
|
970
|
+
const pFld=(key,label,ph,unit)=>`<div class=elab>${label}</div><input id=bp_${key} inputmode=decimal placeholder="${esc(ph)}" value="${bpj&&bpj.params&&bpj.params[key]!=null?esc(String(bpj.params[key])):''}" autocomplete=off>${unit?`<span class=edec>${unit}</span>`:''}`;
|
|
971
|
+
const bpSect=bpj?`<div class=divrow><hr><span class=sect style="margin:0">Base plate · mm</span><hr></div>
|
|
972
|
+
<div class=hint style="margin:0 0 6px">Tune this column's base plate — millimetres; empty = engine default ("auto" plate size = derived from the column profile). The AI fills these from a schedule; tweak here.</div>
|
|
973
|
+
${pFld('plateWidth','Plate width "N"','auto','mm')}${pFld('plateDepth','Plate depth "B"','auto','mm')}${pFld('thickness','Thickness','25','mm')}${pFld('weldLeg','Weld leg','8','mm')}
|
|
974
|
+
<div class=elab style="margin-top:7px;opacity:.7">Anchor kit</div>
|
|
975
|
+
${pFld('boltCols','Bolt columns','2','')}${pFld('boltRows','Bolt rows','2','')}${pFld('boltDia','Bolt ⌀','24','mm')}
|
|
976
|
+
<div class="row f"><button class="ghostw" id=bpRemove title="Delete this column's base plate" style="color:#fca5a5;border-color:#7f1d1d">Remove base plate</button></div>`:'';
|
|
977
|
+
// This BEAM's shear-plate joints — one params block per detailed END (start/end). Mirrors bpSect but
|
|
978
|
+
// per-end (a beam can be detailed at both ends), and adds the clearance + web-side + stiffener controls.
|
|
979
|
+
const spjs=col?[]:[0,1].map(e=>({e,j:(C.joints||[]).find(j=>j&&j.kind==='shear-plate'&&j.main===m.id&&j.at==='end'+e)})).filter(x=>x.j);
|
|
980
|
+
const spAllAuto=spjs.length>0&&spjs.every(x=>x.j.source==='auto');
|
|
981
|
+
// DOM ids key on the END INDEX (0/1), NEVER the joint id (a free-string schema field) — so a crafted
|
|
982
|
+
// joints[].id can't inject into an attribute position; the handlers below close over the real joint.
|
|
983
|
+
const spF=(e,j,key,label,ph,unit)=>{const v=j&&j.params&&j.params[key]!=null?String(j.params[key]):'';
|
|
984
|
+
return `<div class=elab>${label}</div><input id="spf_e${e}_${key}" inputmode=decimal placeholder="${esc(ph)}" value="${esc(v)}" autocomplete=off>${unit?`<span class=edec>${unit}</span>`:''}`;};
|
|
985
|
+
const spBlock=({e,j})=>{const dot=e===1?dotE:dotS,lbl=e===1?'End':'Start',st=!!(j.params&&j.params.stiffener);
|
|
986
|
+
return `<div class=divrow><hr><span class=sect style="margin:0">${dot}${lbl} · Shear plate · mm</span><hr></div>
|
|
987
|
+
${spF(e,j,'plateWidth','Plate width','auto','mm')}${spF(e,j,'plateHeight','Plate height','auto','mm')}${spF(e,j,'plateThickness','Thickness','10','mm')}${spF(e,j,'weldLeg','Weld leg','6','mm')}${spF(e,j,'clearance','Clearance (beam gap)','13','mm')}
|
|
988
|
+
<div class=elabrow><span class=elab>Web side</span></div>
|
|
989
|
+
<div class=seg2 style="margin-top:0"><button id="spside_e${e}_n" class="${(j.params&&j.params.webSide<0)?'':'on'}">Near</button><button id="spside_e${e}_f" class="${(j.params&&j.params.webSide<0)?'on':''}">Far</button></div>
|
|
990
|
+
<div class=elab style="margin-top:7px;opacity:.7">Bolts</div>
|
|
991
|
+
${spF(e,j,'boltCols','Bolt columns','1','')}${spF(e,j,'boltRows','Bolt rows','3','')}${spF(e,j,'boltDia','Bolt ⌀','20','mm')}${spF(e,j,'boltPitch','Bolt spacing','70','mm')}
|
|
992
|
+
<div class=elabrow><span class=elab>Bolt grade</span></div>
|
|
993
|
+
<div class=seg2 style="margin-top:0"><button id="spg_e${e}_325" class="${(j.params&&j.params.boltGrade==='A490')?'':'on'}">A325</button><button id="spg_e${e}_490" class="${(j.params&&j.params.boltGrade==='A490')?'on':''}">A490</button></div>
|
|
994
|
+
<div class=hint style="margin:4px 0 0">Bolt length auto-sizes from the grip (AISC) → shown on the bolt callout.</div>
|
|
995
|
+
<div class=elabrow><span class=elab>Opposite stiffener</span><label class=defck><input type=checkbox id="spck_e${e}"${st?' checked':''}>add</label></div>
|
|
996
|
+
<div class="row f"><button class="ghostw" id="sprm_e${e}" title="Delete this end's shear plate" style="color:#fca5a5;border-color:#7f1d1d">Remove shear plate</button></div>`;};
|
|
997
|
+
const spSect=spjs.length?`<div class=hint style="margin:8px 0 0">${spAllAuto?'Auto-added — millimetres; empty = engine default. <b>Clearance</b> = the gap (≈½–¾") so the beam clears the support; <b>Web side</b> picks which face of the web the plate laps. Clear all via <b>Clear shear plates</b> in the toolbar (AI-tuned plates are kept).':'Tune this end's shear plate — millimetres; empty = engine default. <b>Clearance</b> = the gap (≈½–¾") so the beam clears the support; <b>Web side</b> picks which face of the web the plate laps.'}</div>${spjs.map(spBlock).join('')}`:'';
|
|
998
|
+
const _cps=(!col&&copesByMember[m.id])?copesByMember[m.id]:[]; // auto cope(s) on this beam (from the rendered scene)
|
|
999
|
+
const copeSect=_cps.length?`<div class=divrow><hr><span class=sect style="margin:0">Cope (auto)</span><hr></div><div class=hint style="margin:0 0 2px">${_cps.map(esc).join(' · ')} — auto, clash-driven so the beam clears the support.</div>`:'';
|
|
956
1000
|
p.innerHTML=`<h3>Member ${esc(m.id)}</h3>
|
|
957
1001
|
<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>
|
|
958
1002
|
<div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
|
|
@@ -960,6 +1004,7 @@ function panel(){
|
|
|
960
1004
|
<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>
|
|
961
1005
|
${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>`:''}
|
|
962
1006
|
${elev}
|
|
1007
|
+
${bpSect}${spSect}${copeSect}
|
|
963
1008
|
${col?'':`<div class=divrow><hr><span class=sect style="margin:0">Profile position</span><hr></div>
|
|
964
1009
|
<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>
|
|
965
1010
|
<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>`}
|
|
@@ -991,6 +1036,16 @@ function panel(){
|
|
|
991
1036
|
wireTos('tosC',m.col);
|
|
992
1037
|
document.getElementById('ntC').onchange=e=>edit(()=>{m.col.note=e.target.value;});
|
|
993
1038
|
wireDet('dtC',m.col);wireConn('cnC',m.col);
|
|
1039
|
+
if(bpj){const MAXCOUNT=20, MAXDIM=100000; // bolt grid is capped (scene parts = rows×cols, so a mistyped 1e5 can't explode the renderer); dims sanity-bounded
|
|
1040
|
+
const wireBp=(key,isInt)=>{const i=document.getElementById('bp_'+key);if(!i)return;i.onchange=e=>{
|
|
1041
|
+
const raw=e.target.value.trim().replace(',','.'); // accept a comma decimal (EU locale); Number() (unlike parseFloat) rejects trailing junk like "24mm" instead of silently truncating
|
|
1042
|
+
let n=raw===''?NaN:Number(raw);if(isInt)n=Math.round(n);
|
|
1043
|
+
edit(()=>{if(!bpj.params)bpj.params={};
|
|
1044
|
+
if(isFinite(n)&&n>0)bpj.params[key]=Math.min(isInt?MAXCOUNT:MAXDIM,n);
|
|
1045
|
+
else delete bpj.params[key]; // empty / invalid / ≤0 → drop the override so the engine falls back to its default
|
|
1046
|
+
bpj.source='user';});};}; // through edit() → the param change is undoable; editing also makes the plate user-owned (survives the auto-detail "Clear")
|
|
1047
|
+
['plateWidth','plateDepth','thickness','boltDia','weldLeg'].forEach(k=>wireBp(k,false));['boltCols','boltRows'].forEach(k=>wireBp(k,true));
|
|
1048
|
+
{const rm=document.getElementById('bpRemove');if(rm)rm.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(j=>j!==bpj);});}} // Remove is undoable
|
|
994
1049
|
}else{
|
|
995
1050
|
wireTos('tosA',m.ends[0]);
|
|
996
1051
|
document.getElementById('ntA').onchange=e=>edit(()=>{m.ends[0].note=e.target.value;});
|
|
@@ -999,6 +1054,23 @@ function panel(){
|
|
|
999
1054
|
document.getElementById('ntB').onchange=e=>edit(()=>{m.ends[1].note=e.target.value;});
|
|
1000
1055
|
wireDet('dtB',m.ends[1]);wireConn('cnB',m.ends[1]);
|
|
1001
1056
|
document.getElementById('matchEnds').onclick=()=>edit(()=>{m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail,conn:m.ends[0].conn,connVerified:m.ends[0].connVerified};});
|
|
1057
|
+
// shear-plate inspector wiring (per detailed end) — every mutation rides edit() → undoable; editing
|
|
1058
|
+
// makes the plate user-owned (survives the auto-detail "Clear"). Bolt grid capped (rows×cols = parts).
|
|
1059
|
+
for(const {e,j} of spjs){const jid=j.id,MAXC=20,MAXD=100000; // jid is a JS closure var (the find key), never placed in the DOM
|
|
1060
|
+
const wsp=(key,isInt)=>{const i=document.getElementById('spf_e'+e+'_'+key);if(!i)return;i.onchange=ev=>{
|
|
1061
|
+
const raw=(ev.target.value||'').trim().replace(',','.');let n=raw===''?NaN:Number(raw);if(isInt)n=Math.round(n);
|
|
1062
|
+
edit(()=>{const jj=(C.joints||[]).find(x=>x&&x.id===jid);if(!jj)return;if(!jj.params)jj.params={};
|
|
1063
|
+
if(isFinite(n)&&n>0)jj.params[key]=Math.min(isInt?MAXC:MAXD,n);else delete jj.params[key];
|
|
1064
|
+
jj.source='user';});};};
|
|
1065
|
+
['plateWidth','plateHeight','plateThickness','weldLeg','clearance','boltDia','boltPitch'].forEach(k=>wsp(k,false));['boltCols','boltRows'].forEach(k=>wsp(k,true));
|
|
1066
|
+
{const setSide=(s)=>edit(()=>{const jj=(C.joints||[]).find(x=>x&&x.id===jid);if(!jj)return;if(!jj.params)jj.params={};jj.params.webSide=s;jj.source='user';}); // which face of the web the plate laps
|
|
1067
|
+
const n=document.getElementById('spside_e'+e+'_n');if(n)n.onclick=()=>setSide(1);
|
|
1068
|
+
const f=document.getElementById('spside_e'+e+'_f');if(f)f.onclick=()=>setSide(-1);}
|
|
1069
|
+
{const setGrade=(g)=>edit(()=>{const jj=(C.joints||[]).find(x=>x&&x.id===jid);if(!jj)return;if(!jj.params)jj.params={};jj.params.boltGrade=g;jj.source='user';}); // AISC bolt grade (length auto-sizes from grip in the engine)
|
|
1070
|
+
const a=document.getElementById('spg_e'+e+'_325');if(a)a.onclick=()=>setGrade('A325');
|
|
1071
|
+
const b=document.getElementById('spg_e'+e+'_490');if(b)b.onclick=()=>setGrade('A490');}
|
|
1072
|
+
{const ck=document.getElementById('spck_e'+e);if(ck)ck.onchange=ev=>edit(()=>{const jj=(C.joints||[]).find(x=>x&&x.id===jid);if(!jj)return;if(!jj.params)jj.params={};jj.params.stiffener=ev.target.checked;jj.source='user';});}
|
|
1073
|
+
{const rm=document.getElementById('sprm_e'+e);if(rm)rm.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);});}} // Remove is undoable
|
|
1002
1074
|
}
|
|
1003
1075
|
document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}}); // also drop any dims a mixed marquee selected
|
|
1004
1076
|
{const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
|
|
@@ -1339,9 +1411,12 @@ moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.
|
|
|
1339
1411
|
moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu
|
|
1340
1412
|
// --- 2D|3D view toggle. 3D is rendered by Steel3DView (Three.js, loaded as a module → window). It
|
|
1341
1413
|
// fetches the SAME scene the bake uses (/api/contract/:id/scene) so 2D and 3D show one contract. ---
|
|
1414
|
+
// Coped beams (auto, clash-driven) — cache the cut labels per member as the scene is fetched so the
|
|
1415
|
+
// member Inspector can show a read-only "Cope (auto)" note (a coped beam otherwise looks like an error).
|
|
1416
|
+
let copesByMember={};
|
|
1342
1417
|
const view3dApi={
|
|
1343
1418
|
// POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
|
|
1344
|
-
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;}, //
|
|
1419
|
+
fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');const cm={};for(const el of ((j.scene&&j.scene.elements)||[])){if(el&&el.kind==='cut'&&el.member){(cm[el.member]||(cm[el.member]=[])).push((el.meta&&el.meta.label)||'Cope');}}copesByMember=cm;return j.scene;}, // also cache cope labels per member for the Inspector
|
|
1345
1420
|
onSelect:(id,additive)=>{selDimIds.clear();sel3dDimId=null;if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D pick → shared selection (clears 2D + 3D dim selection so Delete is unambiguous)
|
|
1346
1421
|
onSelectMany:(ids)=>{selDimIds.clear();sel3dDimId=null;selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D box-select → shared selection
|
|
1347
1422
|
getMembers:()=>P.members, // raw members (wp) for snap geometry
|
|
@@ -1370,16 +1445,27 @@ function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView
|
|
|
1370
1445
|
// Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
|
|
1371
1446
|
// double-click isolate — mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
|
|
1372
1447
|
let leg3dClickT=null;
|
|
1448
|
+
// Connection-part group keys, in assembly reading order (plate → fasteners → weld). Used to split the
|
|
1449
|
+
// legend into a MEMBERS list and a CONNECTIONS section so the steel parts don't blur into one long list.
|
|
1450
|
+
const CONN_ORDER=['base-plate','washer-plate','anchor','nut','washer','weld'];
|
|
1373
1451
|
function build3DLegend(){const host=document.getElementById('m3dLegend');if(!host||!window.Steel3DView)return;
|
|
1374
1452
|
const groups=window.Steel3DView.getGroups();host.replaceChildren();
|
|
1375
1453
|
if(!groups.length){host.style.display='none';return;}
|
|
1376
1454
|
const hint=document.createElement('div');hint.className='lhint';hint.textContent='click: hide/show · dbl-click: isolate';host.appendChild(hint);
|
|
1377
|
-
|
|
1455
|
+
const addRow=g=>{const row=document.createElement('div');row.className='lrow';row.dataset.key=g.key;
|
|
1378
1456
|
const sw=document.createElement('span');sw.className='lsw';sw.style.background=g.color;
|
|
1379
1457
|
row.append(sw,document.createTextNode(g.label));
|
|
1380
1458
|
row.addEventListener('click',()=>{clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
|
|
1381
1459
|
row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);window.Steel3DView.soloToggle(g.key);refresh3DLegend();});
|
|
1382
|
-
host.appendChild(row);}
|
|
1460
|
+
host.appendChild(row);};
|
|
1461
|
+
const members=groups.filter(g=>!CONN_ORDER.includes(g.key));
|
|
1462
|
+
const conns=groups.filter(g=>CONN_ORDER.includes(g.key)).sort((a,b)=>CONN_ORDER.indexOf(a.key)-CONN_ORDER.indexOf(b.key));
|
|
1463
|
+
for(const g of members)addRow(g);
|
|
1464
|
+
if(conns.length){
|
|
1465
|
+
if(members.length){host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));}
|
|
1466
|
+
host.appendChild(Object.assign(document.createElement('div'),{className:'lsec',textContent:'Connections'}));
|
|
1467
|
+
for(const g of conns)addRow(g);
|
|
1468
|
+
}
|
|
1383
1469
|
host.style.display='flex';refresh3DLegend();}
|
|
1384
1470
|
function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3DView.groupState(),hidden=new Set(st.hidden);
|
|
1385
1471
|
document.querySelectorAll('#m3dLegend .lrow').forEach(r=>{const k=r.dataset.key;r.classList.toggle('off',hidden.has(k)||(st.solo!==null&&st.solo!==k));});}
|
|
@@ -1492,6 +1578,75 @@ function openConnLib(){if(!Array.isArray(C.connections))C.connections=[];renderC
|
|
|
1492
1578
|
function closeConnLib(){document.getElementById('connModal').style.display='none';}
|
|
1493
1579
|
function connLibOpen(){return document.getElementById('connModal').style.display==='flex';}
|
|
1494
1580
|
function updConnBtn(){const b=document.getElementById('connBtn');if(b)b.textContent='Connections'+((C.connections||[]).length?' ('+C.connections.length+')':'');}
|
|
1581
|
+
// --- Auto-detail base plates: one click adds a base-plate joint (engine defaults) to every column
|
|
1582
|
+
// that lacks one; once all columns are detailed, clicking again clears the AUTO ones (user-placed/
|
|
1583
|
+
// AI-tuned base plates are kept). The engine renders the plate + anchor kit + weld in 3D. ---
|
|
1584
|
+
function colMemberIds(){const ids=[];for(const m of (P.members||[]))if(m&&m.role==='column'&&m.id)ids.push(m.id);return ids;} // ACTIVE plan's columns — auto-detail + its undo are per-plan (a joint belongs to its column's sheet). Base-plate columns sit on one foundation sheet, so in practice this still reads as "every column".
|
|
1585
|
+
function autoBasePlates(){const cs=new Set(colMemberIds());return (C.joints||[]).filter(j=>j&&j.kind==='base-plate'&&j.source==='auto'&&cs.has(j.main));} // active plan's auto base plates (matches per-plan detail/clear)
|
|
1586
|
+
function bpJointId(mid){const ids=new Set((C.joints||[]).map(j=>j&&j.id));let id='bp-'+mid,k=2;while(ids.has(id))id='bp-'+mid+'-'+(k++);return id;} // unique vs ANY existing joint id (no collision with a user-placed joint)
|
|
1587
|
+
function updBpBtn(){const b=document.getElementById('bpBtn');if(!b)return;
|
|
1588
|
+
const colSet=new Set(colMemberIds()), n=autoBasePlates().length;
|
|
1589
|
+
const covered=new Set((C.joints||[]).filter(j=>j&&j.kind==='base-plate'&&colSet.has(j.main)).map(j=>j.main)).size; // only CURRENT columns (ignore joints orphaned by a deleted column)
|
|
1590
|
+
b.disabled=colSet.size===0; // no columns → nothing to detail (empty-state)
|
|
1591
|
+
b.title=colSet.size===0?'No columns in this model to detail base plates.':'Auto-detail base plates on every column (a plate + anchor kit + weld, shown in 3D). Tune sizes per the schedule via the AI or per-column params.';
|
|
1592
|
+
b.textContent=((colSet.size>0&&covered>=colSet.size&&n>0)?'Clear base plates':'Base plates')+(n?' ('+n+')':'');} // label flips once all current columns are covered, so the destructive 2nd click reads clearly
|
|
1593
|
+
// C.joints is model-global but now rides the snapshot (snapshot()/apply() carry it), so every joint
|
|
1594
|
+
// mutation goes through edit() — auto-detail, the inspector, and Remove are all undoable (Ctrl+Z/Y).
|
|
1595
|
+
function toggleBasePlates(){
|
|
1596
|
+
const have=new Set((C.joints||[]).filter(j=>j&&j.kind==='base-plate').map(j=>j.main));
|
|
1597
|
+
const missing=colMemberIds().filter(id=>!have.has(id));
|
|
1598
|
+
if(missing.length){ // detail the columns that don't have a base plate yet
|
|
1599
|
+
edit(()=>{if(!Array.isArray(C.joints))C.joints=[];for(const id of missing)C.joints.push({id:bpJointId(id),kind:'base-plate',main:id,at:'base',source:'auto',params:{}});});
|
|
1600
|
+
}else if(autoBasePlates().length){ // all active-plan columns detailed → clear the auto ones on THIS sheet (keep user/AI + other sheets)
|
|
1601
|
+
const cs=new Set(colMemberIds());
|
|
1602
|
+
edit(()=>{C.joints=(C.joints||[]).filter(j=>!(j&&j.kind==='base-plate'&&j.source==='auto'&&cs.has(j.main)));});
|
|
1603
|
+
} // else: nothing to add or clear (no-op) — edit()→render() refreshes the button in the active branches
|
|
1604
|
+
}
|
|
1605
|
+
{const _b=document.getElementById('bpBtn');if(_b)_b.onclick=toggleBasePlates;}
|
|
1606
|
+
// --- Auto-detail shear plates: one click adds a shear-plate joint to every ELIGIBLE beam end that lacks
|
|
1607
|
+
// one; once all eligible ends are detailed, clicking again clears the AUTO ones (user/AI-tuned kept). An
|
|
1608
|
+
// end is eligible when its connection note/row reads shear/simple, or it frames into a support and isn't
|
|
1609
|
+
// marked moment. main = the beam, at = end0|end1, secondaries = [support] (geometry inferred). Per-plan,
|
|
1610
|
+
// undoable (rides snapshot()/apply() exactly like base plates). The engine renders the fin plate + bolt
|
|
1611
|
+
// group + weld (+ optional stiffener) in 3D. ---
|
|
1612
|
+
function spConnText(end){const r=end&&end.conn?connRowById(end.conn):null;return ((r&&r.type)||'')+' '+((end&&end.note)||'');}
|
|
1613
|
+
function spIsMoment(t){return /moment|fixed|\bmf\b|fully\s*weld/i.test(t||'');}
|
|
1614
|
+
function spIsShear(t){return /shear|fin|tab|clip|simple|pin|web/i.test(t||'');}
|
|
1615
|
+
function spDist(a,b){return Math.hypot(a[0]-b[0],a[1]-b[1]);}
|
|
1616
|
+
function spDistToSeg(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy;if(L2<1e-9)return spDist(p,a);
|
|
1617
|
+
let t=((p[0]-a[0])*vx+(p[1]-a[1])*vy)/L2;t=Math.max(0,Math.min(1,t));return spDist(p,[a[0]+t*vx,a[1]+t*vy]);}
|
|
1618
|
+
function spSupportTol(){const k=(P.pt_per_ft&&P.pt_per_ft>0)?P.pt_per_ft:12;return Math.max(6,0.75*k);} // ~9in at the plan's scale, min 6 display-pts: "the beam end meets this member"
|
|
1619
|
+
function spInferSupport(beam,endIdx){if(!beam||!Array.isArray(beam.wp)||beam.wp.length<2)return null;const pt=beam.wp[endIdx];if(!pt)return null;
|
|
1620
|
+
let best=null,bd=Infinity;for(const m of (P.members||[])){if(!m||m.id===beam.id||!Array.isArray(m.wp)||!m.wp.length)continue;
|
|
1621
|
+
const d=(m.role==='column')?spDist(pt,m.wp[0]):(m.wp.length>=2?spDistToSeg(pt,m.wp[0],m.wp[1]):spDist(pt,m.wp[0]));if(d<bd){bd=d;best=m;}}
|
|
1622
|
+
return (best&&bd<=spSupportTol())?best:null;}
|
|
1623
|
+
function spEndEligible(beam,endIdx){if(!beam||beam.role==='column'||!beam.ends)return false;
|
|
1624
|
+
if(beam.mf===true||/^MF/i.test(beam.profile||''))return false; // a moment-frame girder (resolved via Frames → m.mf, or an unresolved MF mark) takes a MOMENT connection, not a shear tab
|
|
1625
|
+
const end=beam.ends[endIdx];if(!end)return false;
|
|
1626
|
+
const t=spConnText(end);if(spIsMoment(t))return false;if(spIsShear(t))return true;return !!spInferSupport(beam,endIdx);} // explicit moment → no; explicit shear → yes; else propose only where the end frames into a support
|
|
1627
|
+
function spEligibleEnds(){const out=[];for(const m of (P.members||[])){if(!m||m.role==='column')continue;for(const e of [0,1])if(spEndEligible(m,e))out.push({beam:m,endIdx:e});}return out;}
|
|
1628
|
+
function spBeamIds(){const s=new Set();for(const m of (P.members||[]))if(m&&m.role!=='column'&&m.id)s.add(m.id);return s;} // ACTIVE plan's beams — auto-detail + its undo are per-plan
|
|
1629
|
+
function spJointOf(beamId,endIdx){return (C.joints||[]).find(j=>j&&j.kind==='shear-plate'&&j.main===beamId&&j.at==='end'+endIdx);}
|
|
1630
|
+
function autoShearPlates(){const ids=spBeamIds();return (C.joints||[]).filter(j=>j&&j.kind==='shear-plate'&&j.source==='auto'&&ids.has(j.main));} // active plan's auto shear plates
|
|
1631
|
+
function spJointId(beamId,endIdx){const ids=new Set((C.joints||[]).map(j=>j&&j.id));let id='sp-'+beamId+'-e'+endIdx,k=2;while(ids.has(id))id='sp-'+beamId+'-e'+endIdx+'-'+(k++);return id;} // unique vs ANY existing joint id
|
|
1632
|
+
function updSpBtn(){const b=document.getElementById('spBtn');if(!b)return;
|
|
1633
|
+
const elig=spEligibleEnds(),n=autoShearPlates().length;
|
|
1634
|
+
const covered=elig.filter(({beam,endIdx})=>spJointOf(beam.id,endIdx)).length; // eligible ends already detailed (auto OR user)
|
|
1635
|
+
b.disabled=elig.length===0; // no eligible beam ends → nothing to detail (empty-state, disabled-not-hidden)
|
|
1636
|
+
b.title=elig.length===0?'No eligible beam ends in this model to detail shear plates.':'Auto-detail bolted shear (fin) plates on eligible beam ends (a fin plate + bolt group + weld, shown in 3D). Tune sizes per the schedule via the AI or per-end params.';
|
|
1637
|
+
b.textContent=((elig.length>0&&covered>=elig.length&&n>0)?'Clear shear plates':'Shear plates')+(n?' ('+n+')':'');} // label flips to Clear once all ELIGIBLE ends are covered (moment ends are legitimately excluded)
|
|
1638
|
+
function toggleShearPlates(){
|
|
1639
|
+
const elig=spEligibleEnds();
|
|
1640
|
+
const missing=elig.filter(({beam,endIdx})=>!spJointOf(beam.id,endIdx));
|
|
1641
|
+
if(missing.length){ // detail the eligible ends that don't have a shear plate yet
|
|
1642
|
+
edit(()=>{if(!Array.isArray(C.joints))C.joints=[];for(const {beam,endIdx} of missing){const sup=spInferSupport(beam,endIdx);
|
|
1643
|
+
C.joints.push({id:spJointId(beam.id,endIdx),kind:'shear-plate',main:beam.id,at:'end'+endIdx,source:'auto',secondaries:sup?[sup.id]:[],params:{}});}});
|
|
1644
|
+
}else if(autoShearPlates().length){ // all eligible ends detailed → clear the auto ones on THIS sheet (keep user/AI + other sheets)
|
|
1645
|
+
const ids=spBeamIds();
|
|
1646
|
+
edit(()=>{C.joints=(C.joints||[]).filter(j=>!(j&&j.kind==='shear-plate'&&j.source==='auto'&&ids.has(j.main)));});
|
|
1647
|
+
} // else: nothing to add or clear (no-op)
|
|
1648
|
+
}
|
|
1649
|
+
{const _s=document.getElementById('spBtn');if(_s)_s.onclick=toggleShearPlates;}
|
|
1495
1650
|
function renderConnTable(){const tb=document.getElementById('connBody');if(!tb)return;
|
|
1496
1651
|
document.getElementById('connCompHead').textContent=(PLAT_LABEL[connPlat]||connPlat)+' id';
|
|
1497
1652
|
const rows=C.connections||[];
|
|
@@ -7,9 +7,20 @@ Pinned, committed (no runtime CDN) so the steel 3D editor renders offline and th
|
|
|
7
7
|
| `three.module.js` | `three/build/three.module.js` (re-exports `./three.core.js`) | **0.184.0** |
|
|
8
8
|
| `three.core.js` | `three/build/three.core.js` (the implementation `three.module.js` imports) | **0.184.0** |
|
|
9
9
|
| `OrbitControls.js` | `three/examples/jsm/controls/OrbitControls.js` | **0.184.0** |
|
|
10
|
+
| `three-bvh-csg.module.js` | esbuild bundle of `three-bvh-csg` (+ its `three-mesh-bvh` dep), `three` external | three-bvh-csg **0.0.18** / three-mesh-bvh **0.9.10** |
|
|
10
11
|
|
|
11
|
-
`OrbitControls.js`
|
|
12
|
-
(`"three"` → `./vendor/three.module.js`, `"three/addons/"` → `./vendor
|
|
12
|
+
`OrbitControls.js` and `three-bvh-csg.module.js` import the bare specifier `'three'`; `steel-editor.html`
|
|
13
|
+
provides an import map (`"three"` → `./vendor/three.module.js`, `"three/addons/"` → `./vendor/`,
|
|
14
|
+
`"three-bvh-csg"` → `./vendor/three-bvh-csg.module.js`) that resolves them. The CSG bundle powers the steel
|
|
15
|
+
editor's beam **copes** (`steel-3d-view.js` subtracts a `cut` part from the coped member's solid).
|
|
13
16
|
|
|
14
|
-
To update: re-download
|
|
15
|
-
and bump the table.
|
|
17
|
+
To update three: re-download the three files at the same version from `https://cdn.jsdelivr.net/npm/three@<ver>/…`
|
|
18
|
+
and bump the table. To rebuild the CSG bundle (standalone, offline, `three` external):
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm i three-mesh-bvh@<ver> three-bvh-csg@<ver> esbuild
|
|
22
|
+
echo "export { Brush, Evaluator, SUBTRACTION, ADDITION, INTERSECTION } from 'three-bvh-csg';" > entry.js
|
|
23
|
+
npx esbuild entry.js --bundle --format=esm --external:three --legal-comments=none --outfile=three-bvh-csg.module.js
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
They ship to users via `server/build/bundle.mjs` (`cpSync(web → dist/web)`).
|