@floless/app 0.43.0 → 0.45.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.
@@ -242,6 +242,9 @@
242
242
  <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>
243
243
  <button id=m3dFit title="Fit all to view">Fit</button>
244
244
  <button id=m3dRef title="Show the reference line (work-line between the end points) for every member">Ref line</button>
245
+ <button id=m3dDim title="3D Dimension tool (D) — click two snapped points to measure. Free (3D), or X/Y/Z to lock an axis; hold Alt for a vertical (Z) measurement.">⊢ Dimension</button>
246
+ <div class=seg-group id=m3dDimAxis title="Measurement axis" style="display:none"><button data-d3axis=free class=on title="Free 3D measurement (F)">Free</button><button data-d3axis=x title="Lock to the X axis (X)">X</button><button data-d3axis=y title="Lock to the Y axis (Y)">Y</button><button data-d3axis=z title="Lock to the Z / vertical axis (Z); hold Alt while picking for a quick vertical">Z</button></div>
247
+ <button id=m3dDimShow title="Toggle visibility of placed 3D dimensions" style="display:none">Hide dims</button>
245
248
  </div>
246
249
  <div id=m3dLegend></div>
247
250
  <div id=m3dCube title="Click a face for that view · drag the scene with the right button to orbit"></div>
@@ -345,6 +348,7 @@ async function boot() {
345
348
  PAL = (Array.isArray(C.palette) && C.palette.length) ? C.palette : DEFAULT_PAL; WT = C.weights || {};
346
349
  C.custom_details = C.custom_details || {};
347
350
  C.profile_colors = C.profile_colors || {};
351
+ if(!Array.isArray(C.dims3d))C.dims3d=[]; // model-global draft-only 3D dimensions
348
352
  main();
349
353
  // SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
350
354
  // We open our own EventSource to the same /api/events endpoint as the main app.
@@ -426,6 +430,7 @@ let dimMode=false, dimDraft=null, dimAxis='free', selDimIds=new Set(), dimsVisib
426
430
  let csaxisMode=false, csDraft=null; // Local coordinate system "set axes" tool: armed flag + in-progress origin [x,y] (null until click 1). P.frame={o,u} holds the committed local frame (null = global).
427
431
  let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off,rot}, and a counter for unique ids on rapid clicks
428
432
  let dimSplitMode=false; // "add split point" mode on a selected dim — each click inserts a point and splits the dim segment under it into two
433
+ let sel3dDimId=null; // the currently-selected 3D dimension id (model-global; 3D view highlights it, Delete removes it)
429
434
  let selIds=new Set();
430
435
  let undo=[], redo=[];
431
436
  const byId=id=>P.members.find(m=>m.id===id);
@@ -477,14 +482,16 @@ function syncDefaults(){for(const m of P.members){ensureMeta(m);
477
482
  else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
478
483
  const autofillTOS=syncDefaults;
479
484
  // profs is per-plan — computed in setPlan()
480
- function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null});}
485
+ function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null});} // per-plan; dims3d is model-global and rides its OWN persistence (NOT this plan-scoped snapshot — see refreshDims3d)
486
+ function refreshDims3d(){if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();} // repaint the 3D dim lines/labels from C.dims3d
481
487
  function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
482
488
  function apply(json){const d=JSON.parse(json);
483
489
  if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
484
- else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;if('frame' in d)P.frame=d.frame||null;} // 'frame' present → restore (incl. null = global); absent (legacy snapshot) leaves it alone
490
+ else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;if('frame' in d)P.frame=d.frame||null;} // 'frame' present → restore (incl. null = global); absent (legacy snapshot) leaves it alone. dims3d is NOT restored here — it's model-global and undo is per-plan.
485
491
  updCS();
486
492
  selIds=new Set([...selIds].filter(id=>byId(id)));
487
493
  selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
494
+ if(sel3dDimId&&!(C.dims3d||[]).some(x=>x.id===sel3dDimId))sel3dDimId=null; // drop a stale 3D-dim selection
488
495
  dimDraft=null;dimChainPrev=null;dimPrevClear(); // undo/redo changed geometry under the tool → abandon any in-progress dim placement / chain (else the chain head points at a removed segment)
489
496
  scheduleSave();render();sync3D();}
490
497
  function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
@@ -500,7 +507,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
500
507
  else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
501
508
  else el.textContent=msg||'Auto-save on';}
502
509
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
503
- custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence,
510
+ custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d,
504
511
  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);}}
505
512
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
506
513
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
@@ -531,6 +538,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
531
538
  if(d.profile_colors)C.profile_colors=d.profile_colors;
532
539
  if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
533
540
  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;}});
541
+ if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
534
542
  if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
535
543
  function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
536
544
  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];}
@@ -1242,10 +1250,11 @@ addEventListener('keydown',e=>{
1242
1250
  if(e.key==='Escape'&&dimMode){if(dimChainPrev){dimChainPrev=null;dimPrevClear();}else if(dimDraft){dimDraft=null;dimPrevClear();}else{dimMode=false;setDimMode();}render();return;} // Esc: end the chain → drop the in-progress dim → exit the tool
1243
1251
  if(e.key==='Escape'&&selDimIds.size){if(dimSplitMode){dimSplitMode=false;snapClear();render();}else{selDimIds.clear();render();}return;} // Esc on a selected dim: end split mode → else deselect
1244
1252
  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;}
1253
+ if((e.key==='Delete'||e.key==='Backspace')&&view3d&&sel3dDimId&&!selIds.size&&!inForm){e.preventDefault();view3dApi.onDeleteDim3d(sel3dDimId);return;} // 3D: Delete removes the selected 3D dim — only when no member is selected (selection is exclusive, so a stale dim id never shadows a member delete)
1245
1254
  if((e.key==='Delete'||e.key==='Backspace')&&(selDimIds.size||selIds.size)&&!inForm){e.preventDefault();edit(()=>{ // deletes everything selected — dims and/or members (a marquee can hold both)
1246
1255
  if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
1247
1256
  if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
1248
- if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'){e.preventDefault();dimMode=!dimMode;setDimMode();render();return;} // D — toggle the Dimension tool
1257
+ if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'&&!view3d){e.preventDefault();dimMode=!dimMode;setDimMode();render();return;} // D — toggle the Dimension tool
1249
1258
  if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
1250
1259
  if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
1251
1260
  if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
@@ -1277,8 +1286,8 @@ moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();
1277
1286
  const view3dApi={
1278
1287
  // POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
1279
1288
  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}
1280
- onSelect:(id,additive)=>{selDimIds.clear();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 (clears any 2D dim selection)
1281
- onSelectMany:(ids)=>{selDimIds.clear();selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
1289
+ 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)
1290
+ 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
1282
1291
  getMembers:()=>P.members, // raw members (wp) for snap geometry
1283
1292
  ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
1284
1293
  defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
@@ -1290,6 +1299,14 @@ const view3dApi={
1290
1299
  else m.ends.forEach(e=>{e.tos=(e.tos!=null?e.tos:defaultTOS)+dIn;e.tosDef=false;});});},
1291
1300
  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
1292
1301
  onSplit:(id,wp)=>{const m=byId(id);if(m){doSplit(m,wp);sync3D();}}, // 3D Split → cut at the clicked point on the member
1302
+ getDims3d:()=>C.dims3d||[],
1303
+ // dims3d is model-global: mutate + persist directly. NOT via edit() — a plan-scoped undo snapshot would corrupt dims3d across plans (undo on plan A could wipe dims added on plan B), and edit()'s sync3D would needlessly re-extrude every member mesh for a pure annotation. boot() guarantees C.dims3d is an array.
1304
+ onAddDim3d:(dim)=>{C.dims3d.push(dim);scheduleSave();refreshDims3d();},
1305
+ onDeleteDim3d:(id)=>{C.dims3d=(C.dims3d||[]).filter(x=>x.id!==id);if(sel3dDimId===id)sel3dDimId=null;scheduleSave();refreshDims3d();},
1306
+ onSelectDim3d:(id)=>{sel3dDimId=id||null;if(id){selIds=new Set();selDimIds.clear();}render();}, // selecting a 3D dim is exclusive: clears member + 2D-dim selection (and updates the 3D member highlight via render)
1307
+ selDim3d:()=>sel3dDimId,
1308
+ newDim3dId:()=>'d3'+Date.now()+'_'+(dimSeq++), // same scheme as 2D dim ids (Date.now + dimSeq tie-breaker)
1309
+ fmtLen:(mm)=>fmtFtIn(mm/304.8*12),
1293
1310
  };
1294
1311
  // Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
1295
1312
  // changes go through render()'s setSelection — only geometry mutations need a rebuild.
@@ -1316,7 +1333,11 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
1316
1333
  document.querySelectorAll('#m3dProj button').forEach(b=>b.onclick=()=>{window.Steel3DView.setProjection(b.dataset.proj);seg3dActive('#m3dProj','data-proj',b.dataset.proj);});
1317
1334
  document.querySelectorAll('#m3dMode button').forEach(b=>b.onclick=()=>{window.Steel3DView.setDisplayMode(b.dataset.mode);seg3dActive('#m3dMode','data-mode',b.dataset.mode);});
1318
1335
  document.getElementById('m3dFit').onclick=()=>window.Steel3DView.frameAll();
1319
- document.getElementById('m3dRef').onclick=()=>{const on=!window.Steel3DView.refLine();window.Steel3DView.setRefLine(on);document.getElementById('m3dRef').classList.toggle('on',on);};}
1336
+ document.getElementById('m3dRef').onclick=()=>{const on=!window.Steel3DView.refLine();window.Steel3DView.setRefLine(on);document.getElementById('m3dRef').classList.toggle('on',on);};
1337
+ const d3=window.Steel3DView;
1338
+ document.getElementById('m3dDim').onclick=()=>d3.toggleDimTool(); // toggleDimTool()'s reflectDimBar() owns the button .on class + axis/show visibility (single source, no duplicate DOM toggles)
1339
+ document.querySelectorAll('#m3dDimAxis button').forEach(b=>b.onclick=()=>{d3.setDimAxis(b.dataset.d3axis);seg3dActive('#m3dDimAxis','data-d3axis',b.dataset.d3axis);});
1340
+ document.getElementById('m3dDimShow').onclick=()=>{const on=!d3.dims3dVisible();d3.setDims3dVisible(on);document.getElementById('m3dDimShow').textContent=on?'Hide dims':'Show dims';};}
1320
1341
  function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
1321
1342
  view3d=on;
1322
1343
  const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
@@ -1332,6 +1353,8 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
1332
1353
  }
1333
1354
  async function setView(on){
1334
1355
  if(on){
1356
+ if(dimMode||dimChain||dimSplitMode){dimMode=dimChain=dimSplitMode=false;selDimIds.clear();setDimMode();document.body.classList.remove('dimsplit');} // entering 3D disarms the 2D dim/chain/split tools so their X/Y/F/S/Esc keys don't double-fire with the 3D tool
1357
+ if(selIds.size)sel3dDimId=null; // a member selected in 2D wins over a stale 3D-dim selection
1335
1358
  if(!window.Steel3DView){toast('3D view unavailable (renderer failed to load)');return;} // stay in 2D
1336
1359
  try{
1337
1360
  if(!view3dReady){window.Steel3DView.init(document.getElementById('stage3d'),view3dApi);view3dReady=true;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.43.0",
3
+ "version": "0.45.1",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {