@floless/app 0.38.0 → 0.40.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.
@@ -52856,7 +52856,7 @@ function appVersion() {
52856
52856
  return resolveVersion({
52857
52857
  isSea: isSea2(),
52858
52858
  sqVersionXml: readSqVersionXml(),
52859
- define: true ? "0.38.0" : void 0,
52859
+ define: true ? "0.40.0" : 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.38.0" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.40.0" : void 0 });
52870
52870
  }
52871
52871
 
52872
52872
  // oauth-presets.ts
@@ -68,7 +68,8 @@
68
68
  "details": { "type": "array", "items": { "$ref": "#/$defs/detail" } },
69
69
  "tos_callouts": { "type": "array", "items": { "$ref": "#/$defs/tos_callout" } },
70
70
  "members": { "type": "array", "items": { "$ref": "#/$defs/member" } },
71
- "dims": { "type": "array", "items": { "$ref": "#/$defs/dim" }, "description": "Draft-only on-drawing dimensions (editor annotations). NOT baked into the lock / 3D / IFC / BOM." }
71
+ "dims": { "type": "array", "items": { "$ref": "#/$defs/dim" }, "description": "Draft-only on-drawing dimensions (editor annotations). NOT baked into the lock / 3D / IFC / BOM." },
72
+ "frame": { "$ref": "#/$defs/frame" }
72
73
  }
73
74
  },
74
75
  "segment": {
@@ -90,8 +91,19 @@
90
91
  "id": { "type": "string" },
91
92
  "a": { "$ref": "#/$defs/point2", "description": "First snapped anchor, display space." },
92
93
  "b": { "$ref": "#/$defs/point2", "description": "Second snapped anchor, display space." },
93
- "axis": { "enum": ["free", "x", "y"], "description": "free = aligned distance hypot(b-a); x = |b.x-a.x|; y = |b.y-a.y|." },
94
- "off": { "type": "number", "description": "Dimension-line offset: for axis x the dim-line Y; for axis y the dim-line X; for free the signed perpendicular distance from the a-b baseline." }
94
+ "axis": { "enum": ["free", "x", "y"], "description": "free = aligned distance hypot(b-a); x = along local-X; y = along local-Y. With no frame (rot 0) x/y are screen-horizontal/vertical." },
95
+ "off": { "type": "number", "description": "Dimension-line offset: for axis x the dim-line Y; for axis y the dim-line X; for free the signed perpendicular distance from the a-b baseline. Stored in the dim's own (rot-rotated) frame." },
96
+ "rot": { "type": "number", "description": "Local-frame bearing (radians) baked at creation; 0 = global axes. x/y measure along this rotated frame so resetting the plan frame never moves placed dims." }
97
+ }
98
+ },
99
+ "frame": {
100
+ "type": ["object", "null"],
101
+ "description": "Optional per-plan local coordinate frame for skewed framing. null/absent = global axes. Affects only editor ortho-snapping + X/Y dimensions; NOT baked into the lock / 3D / IFC / BOM.",
102
+ "required": ["o", "u"],
103
+ "additionalProperties": true,
104
+ "properties": {
105
+ "o": { "$ref": "#/$defs/point2", "description": "Frame origin, display space (glyph anchor)." },
106
+ "u": { "$ref": "#/$defs/point2", "description": "Unit local-X direction [ux,uy], display space. Local-Y = [uy,-ux]." }
95
107
  }
96
108
  },
97
109
  "label": {
@@ -73,6 +73,7 @@
73
73
  .aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
74
74
  .aithumb button{position:absolute;top:-5px;right:-5px;width:16px;height:16px;padding:0;border-radius:8px;font-size:10px;line-height:16px;text-align:center;background:#7f1d1d;border-color:#991b1b;color:#fecaca}
75
75
  #saveStat.dirty{color:#fbbf24} #saveStat.ok{color:#86efac} #saveStat.err{color:#fca5a5}
76
+ #csStat.local{border:1px solid #22d3ee;background:rgba(34,211,238,.10);border-radius:4px;padding:1px 6px} /* lift the chip out of the muted row when a local frame is active (reuses the dim/accent cyan — no new colour) */
76
77
  #rfiStat{cursor:pointer;text-decoration:underline dotted} #rfiStat:hover b{color:#fff}
77
78
  #confStat{cursor:pointer;text-decoration:underline dotted} #confStat:hover b{filter:brightness(1.25)}
78
79
  .conf-cats{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:14px;border-bottom:1px solid var(--line)}
@@ -123,6 +124,8 @@
123
124
  #moreMenu button:hover{background:#334155}
124
125
  #moreMenu button.mdanger{color:#fca5a5}
125
126
  #moreMenu button.mdanger:hover{background:#7f1d1d;color:#fecaca}
127
+ #moreMenu button:disabled{opacity:.4;cursor:default;background:transparent}
128
+ #moreMenu button.on{color:var(--brand)}
126
129
  button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}
127
130
  #srcStat{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
128
131
  #detailNewForm input#dnName{margin:2px 0 10px}
@@ -146,9 +149,12 @@
146
149
  circle.dimend{fill:#22d3ee;pointer-events:none}
147
150
  rect.dimchip{fill:#111827;stroke:#22d3ee;stroke-width:1;vector-effect:non-scaling-stroke;cursor:pointer}
148
151
  text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
149
- line.dim.dimsel{stroke:#f59e0b} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
152
+ line.dim.dimsel{stroke:#f59e0b} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
153
+ circle.dimhandle{fill:#22d3ee;stroke:#0b1220;stroke-width:2;cursor:grab}
150
154
  .dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
151
155
  body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
156
+ body.csaxison #svg,body.csaxison line.member,body.csaxison line.seg,body.csaxison image{cursor:crosshair}
157
+ g.csglyph text{user-select:none}
152
158
  body.geo line.seg{opacity:.45;cursor:crosshair} body.geo line.member{cursor:crosshair}
153
159
  body.geo line.seg:hover{stroke:#22d3ee;opacity:1;stroke-width:4} body.geo line.member:hover{stroke-width:8}
154
160
  circle.numbg{fill:var(--brand);stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:none}
@@ -190,6 +196,7 @@
190
196
  <span class=stat>Members <b id=mc>0</b></span><span class=stat>Weight <b id=wt>0</b> tons · <b id=wtlb>0</b> lb</span>
191
197
  <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
192
198
  <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
199
+ <span class=stat id=csStat title="Coordinate system — Global by default. Set a local frame from the ⋯ menu for skewed framing.">Axes <b>Global</b></span>
193
200
  <span class=stat id=confStat title="Confidence report — AI-read score vs. target. Click to review element evidence." style="display:none">Confidence <b id=confPct>—</b><span id=confTgt></span></span>
194
201
  <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span>
195
202
  <span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
@@ -204,6 +211,10 @@
204
211
  <div class=mlabel>Dimensions</div>
205
212
  <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
206
213
  <hr>
214
+ <div class=mlabel>Coordinate system</div>
215
+ <button id=csSetB title="Define a local X axis by clicking two points (origin, then X-direction). Y follows at 90°. Orthogonal drawing and X/Y dimensions then snap to this frame — for skewed framing.">↺ Set local axes…</button>
216
+ <button id=csResetB title="Return to the global X/Y axes (default)" disabled>Reset to global</button>
217
+ <hr>
207
218
  <div class=mlabel>Review</div>
208
219
  <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
209
220
  <button id=revB title="Select beams drawn the wrong way — a mostly-horizontal beam should run left→right, a steep/skew beam bottom→up. Review, then press P to swap their direction.">Reversed beams</button>
@@ -411,6 +422,8 @@ function main() {
411
422
  let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
412
423
  let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
413
424
  let dimMode=false, dimDraft=null, dimAxis='free', selDimIds=new Set(), dimsVisible=true; // Dimension tool: armed flag, in-progress {a,b,axis}, sticky axis, selected dim ids (multi-select), show/hide
425
+ 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).
426
+ 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
414
427
  let selIds=new Set();
415
428
  let undo=[], redo=[];
416
429
  const byId=id=>P.members.find(m=>m.id===id);
@@ -462,13 +475,15 @@ function syncDefaults(){for(const m of P.members){ensureMeta(m);
462
475
  else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
463
476
  const autofillTOS=syncDefaults;
464
477
  // profs is per-plan — computed in setPlan()
465
- function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[]});}
478
+ function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null});}
466
479
  function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
467
480
  function apply(json){const d=JSON.parse(json);
468
481
  if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
469
- else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;}
482
+ 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
483
+ updCS();
470
484
  selIds=new Set([...selIds].filter(id=>byId(id)));
471
485
  selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
486
+ 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)
472
487
  scheduleSave();render();sync3D();}
473
488
  function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
474
489
  function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
@@ -484,7 +499,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
484
499
  else el.textContent=msg||'Auto-save on';}
485
500
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
486
501
  custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence,
487
- plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details,dims:p.dims}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
502
+ 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);}}
488
503
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
489
504
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
490
505
  // fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
@@ -513,7 +528,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
513
528
  if(d.custom_details)C.custom_details=d.custom_details;
514
529
  if(d.profile_colors)C.profile_colors=d.profile_colors;
515
530
  if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
516
- 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;p.autofilled=true;}});
531
+ 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;}});
517
532
  if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
518
533
  function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
519
534
  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];}
@@ -524,36 +539,74 @@ function lineX(p1,p2,p3,p4){const x1=p1[0],y1=p1[1],x2=p2[0],y2=p2[1],x3=p3[0],y
524
539
  const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/den;return [x1+t*(x2-x1),y1+t*(y2-y1)];}
525
540
  function projPt(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy||1;
526
541
  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};}
542
+ // --- local coordinate frame: P.frame={o:[ox,oy],u:[ux,uy]} (unit local-X); local-Y v=[uy,-ux] (right-hand,
543
+ // plan convention X-right→Y-up in y-down screen). null = global. The math is direction-only (origin o is
544
+ // just the glyph anchor). frameRot() = the frame's bearing baked into each dim's `rot`. ---
545
+ function frameRot(){return P.frame?Math.atan2(P.frame.u[1],P.frame.u[0]):0;}
546
+ // Lock free point (x,y) to the dominant axis through anchor (ax,ay). No frame → screen H/V (today,
547
+ // bit-identical). Frame → nearer of local-X / local-Y. Returns the locked [x,y].
548
+ function orthoLock(ax,ay,x,y){
549
+ if(!P.frame)return Math.abs(x-ax)>=Math.abs(y-ay)?[x,ay]:[ax,y];
550
+ const ux=P.frame.u[0],uy=P.frame.u[1],vx=uy,vy=-ux,dx=x-ax,dy=y-ay; // v = local-Y
551
+ const du=dx*ux+dy*uy,dv=dx*vx+dy*vy; // components along local axes
552
+ return Math.abs(du)>=Math.abs(dv)?[ax+du*ux,ay+du*uy]:[ax+dv*vx,ay+dv*vy];}
553
+ // rotate p about the world origin by angle r (rad). rotPt(p,-rot) maps display→a frame whose local-X is
554
+ // horizontal; rotPt(p,+rot) maps back. Used to run the existing screen-aligned dim math inside a rotated frame.
555
+ function rotPt(p,r){const c=Math.cos(r),s=Math.sin(r);return [p[0]*c-p[1]*s,p[0]*s+p[1]*c];}
556
+ // Which axis ('x'|'y') the span a→p is closer to — local components when a frame is set, else screen dx/dy.
557
+ // Drives the Dimension tool's Shift / momentary axis lock so it agrees with the active frame.
558
+ function dominantAxis(a,p){const dx=p[0]-a[0],dy=p[1]-a[1];
559
+ if(!P.frame)return Math.abs(dx)>=Math.abs(dy)?'x':'y';
560
+ const ux=P.frame.u[0],uy=P.frame.u[1],du=dx*ux+dy*uy,dv=dx*uy-dy*ux; // along local-X (u) vs local-Y (v=[uy,-ux])
561
+ return Math.abs(du)>=Math.abs(dv)?'x':'y';}
562
+ // Guard a frame that arrived from outside the UI (hand-edited contract / AI writeback): the math assumes a
563
+ // finite, non-zero, unit u. Drop a malformed frame to global; normalize a non-unit direction. (The schema's
564
+ // minimal validator can't express unit-length, so we sanitize at the load boundary — UI-set frames are clean.)
565
+ function sanitizeFrame(p){const f=p&&p.frame;if(!f)return;
566
+ const okPt=a=>Array.isArray(a)&&a.length>=2&&isFinite(a[0])&&isFinite(a[1]);
567
+ if(!okPt(f.u)||!okPt(f.o)){p.frame=null;return;}
568
+ const L=Math.hypot(f.u[0],f.u[1]);
569
+ if(!(L>1e-6)){p.frame=null;return;} // zero-length → no usable axis → global
570
+ if(Math.abs(L-1)>1e-6)f.u=[f.u[0]/L,f.u[1]/L];} // normalize a non-unit direction
527
571
  // --- dimension geometry (display space, y-down). `off` meaning by axis: x -> the dim-line Y;
528
572
  // y -> the dim-line X; free -> signed perpendicular distance from the a->b baseline. ---
529
- function dimGeo(a,b,axis,off){
530
- if(axis==='x'){const y=off;return {p1:[a[0],y],p2:[b[0],y],w:[[a,[a[0],y]],[b,[b[0],y]]],mid:[(a[0]+b[0])/2,y],px:Math.abs(b[0]-a[0])};}
531
- if(axis==='y'){const x=off;return {p1:[x,a[1]],p2:[x,b[1]],w:[[a,[x,a[1]]],[b,[x,b[1]]]],mid:[x,(a[1]+b[1])/2],px:Math.abs(b[1]-a[1])};}
532
- const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy),D=L||1,nx=-dy/D,ny=dx/D; // free: parallel-offset along the normal (D guards a zero-length divide; L stays the true measured length so a 0-length dim reads 0'-0", not 1px)
533
- const a2=[a[0]+nx*off,a[1]+ny*off],b2=[b[0]+nx*off,b[1]+ny*off];
534
- return {p1:a2,p2:b2,w:[[a,a2],[b,b2]],mid:[(a2[0]+b2[0])/2,(a2[1]+b2[1])/2],px:L};}
573
+ // `rot` (rad) = the dim's baked local frame; 0 = global (screen-aligned x/y, today's behavior). For x/y
574
+ // we rotate the anchors into the frame (local-X horizontal), run the original screen-aligned logic, then
575
+ // rotate the produced geometry back — so x measures along local-X, y along local-Y. `free` is frame-independent.
576
+ function dimGeo(a,b,axis,off,rot){rot=rot||0;
577
+ if(axis==='free'){const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy),D=L||1,nx=-dy/D,ny=dx/D; // free: parallel-offset along the normal (D guards a zero-length divide; L stays the true measured length so a 0-length dim reads 0'-0", not 1px)
578
+ const a2=[a[0]+nx*off,a[1]+ny*off],b2=[b[0]+nx*off,b[1]+ny*off];
579
+ return {p1:a2,p2:b2,w:[[a,a2],[b,b2]],mid:[(a2[0]+b2[0])/2,(a2[1]+b2[1])/2],px:L};}
580
+ const A=rot?rotPt(a,-rot):a,B=rot?rotPt(b,-rot):b; // into the local frame (no-op when rot 0)
581
+ let g;
582
+ if(axis==='x'){const y=off;g={p1:[A[0],y],p2:[B[0],y],w:[[A,[A[0],y]],[B,[B[0],y]]],mid:[(A[0]+B[0])/2,y],px:Math.abs(B[0]-A[0])};}
583
+ else{const x=off;g={p1:[x,A[1]],p2:[x,B[1]],w:[[A,[x,A[1]]],[B,[x,B[1]]]],mid:[x,(A[1]+B[1])/2],px:Math.abs(B[1]-A[1])};} // axis y
584
+ if(rot){const f=p=>rotPt(p,rot);g.p1=f(g.p1);g.p2=f(g.p2);g.mid=f(g.mid);g.w=g.w.map(s=>[f(s[0]),f(s[1])]);} // back to display space (witness `from` maps back to the original anchor)
585
+ return g;}
535
586
  // measured pixels -> real length text, identical to every other length in the editor (ft-in, 16ths).
536
587
  function dimValueText(px){return fmtFtIn(px/FT*12);}
537
- // the offset a click at pointer p implies, given the two anchors + axis.
538
- function dimOffFromPointer(a,b,axis,p){
539
- if(axis==='x')return p[1];
540
- if(axis==='y')return p[0];
541
- const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy)||1,nx=-dy/L,ny=dx/L;
542
- return (p[0]-a[0])*nx+(p[1]-a[1])*ny;}
588
+ // the offset a click at pointer p implies, given the two anchors + axis (+ baked frame rot). For x/y the
589
+ // pointer is read in the local frame so the stored off is in that frame (consistent with dimGeo).
590
+ function dimOffFromPointer(a,b,axis,p,rot){rot=rot||0;
591
+ if(axis==='free'){const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy)||1,nx=-dy/L,ny=dx/L;
592
+ return (p[0]-a[0])*nx+(p[1]-a[1])*ny;}
593
+ const P_=rot?rotPt(p,-rot):p;
594
+ return axis==='x'?P_[1]:P_[0];}
543
595
  // a sensible offset for the stage-1 preview (before the 3rd click picks one): clear of the anchors by a constant screen distance.
544
- function dimDefaultOff(a,b,axis){
545
- if(axis==='x')return Math.min(a[1],b[1])-28/zoom; // X dim line sits above the higher anchor
546
- if(axis==='y')return Math.min(a[0],b[0])-28/zoom; // Y dim line sits left of the leftmost anchor
547
- return -28/zoom;} // free: a constant perpendicular offset
596
+ function dimDefaultOff(a,b,axis,rot){rot=rot||0;
597
+ if(axis==='free')return -28/zoom; // free: a constant perpendicular offset
598
+ const A=rot?rotPt(a,-rot):a,B=rot?rotPt(b,-rot):b; // x/y: clear of the anchors in the LOCAL frame (off is local)
599
+ if(axis==='x')return Math.min(A[1],B[1])-28/zoom; // X dim line sits "above" the anchors (local-Y)
600
+ return Math.min(A[0],B[0])-28/zoom;} // Y dim line sits "left" of the anchors (local-X)
548
601
  // one dimension -> SVG string. opts: {id, sel, preview}. Lines use non-scaling-stroke (constant on
549
602
  // screen); the chip rect/text + end dots are sized in 1/zoom and re-synced by updateBadges on zoom.
550
- function dimSvg(d,o){o=o||{};const g=dimGeo(d.a,d.b,d.axis,d.off);
603
+ function dimSvg(d,o){o=o||{};const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);
551
604
  const c=(o.preview?'dimprev ':'')+(o.sel?'dimsel ':'');const txt=esc(dimValueText(g.px));
552
605
  const cw=txt.length*7+16,ch=18,dd=o.id?` data-dim="${esc(o.id)}"`:'';
553
606
  // a free (aligned) dim rotates its chip+text to the line, like a drafting dimension; x/y keep it
554
607
  // horizontal (more readable). Clamp to the −90..90 arc so the text never reads upside-down; within
555
608
  // 8° of horizontal it stays flat.
556
- let ang=0;if(d.axis==='free'){ang=Math.atan2(g.p2[1]-g.p1[1],g.p2[0]-g.p1[0])*180/Math.PI;if(ang>90)ang-=180;else if(ang<-90)ang+=180;if(Math.abs(ang)<8)ang=0;}
609
+ let ang=0;if(d.axis==='free'||d.rot){ang=Math.atan2(g.p2[1]-g.p1[1],g.p2[0]-g.p1[0])*180/Math.PI;if(ang>90)ang-=180;else if(ang<-90)ang+=180;if(Math.abs(ang)<8)ang=0;} // free + framed x/y rotate the chip to the (slanted) dim line; screen-aligned x/y (rot 0) stay flat
557
610
  let s='';for(const w of g.w)s+=`<line class="dimwit ${c}" x1="${w[0][0]}" y1="${w[0][1]}" x2="${w[1][0]}" y2="${w[1][1]}"/>`;
558
611
  s+=`<line class="dim ${c}"${dd} x1="${g.p1[0]}" y1="${g.p1[1]}" x2="${g.p2[0]}" y2="${g.p2[1]}"/>`;
559
612
  s+=`<circle class="dimend ${c}" cx="${g.p1[0]}" cy="${g.p1[1]}" r="${3/zoom}"/><circle class="dimend ${c}" cx="${g.p2[0]}" cy="${g.p2[1]}" r="${3/zoom}"/>`;
@@ -562,7 +615,10 @@ function dimSvg(d,o){o=o||{};const g=dimGeo(d.a,d.b,d.axis,d.off);
562
615
  s+=ang?`<g transform="rotate(${ang.toFixed(2)} ${g.mid[0]} ${g.mid[1]})">${chip}</g>`:chip; // rotate the chip group around the dim-line midpoint
563
616
  return s;}
564
617
  function renderDims(){if(!dimsVisible||!Array.isArray(P.dims))return '';
565
- return P.dims.map(d=>dimSvg(d,{id:d.id,sel:selDimIds.has(d.id)})).join('');}
618
+ let s=P.dims.map(d=>dimSvg(d,{id:d.id,sel:selDimIds.has(d.id)})).join('');
619
+ const HR=epR();for(const d of P.dims)if(selDimIds.has(d.id)) // each selected dim gets draggable anchor handles — drag an end to re-measure (snaps like drawing; Alt off)
620
+ s+=`<circle class=dimhandle data-dim="${esc(d.id)}" data-dimend="0" cx="${d.a[0]}" cy="${d.a[1]}" r="${HR}"/><circle class=dimhandle data-dim="${esc(d.id)}" data-dimend="1" cx="${d.b[0]}" cy="${d.b[1]}" r="${HR}"/>`;
621
+ return s;}
566
622
  function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
567
623
  // 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.
568
624
  // Multi: trim/extend the nearest end of EVERY given member to the (tA,tB) target line; members
@@ -681,7 +737,8 @@ function render(){
681
737
  for(const k in grp){const a=grp[k],n=a.length;a.forEach((it,j)=>{const x=it.c[0]+(j-(n-1)/2)*R*2,y=it.c[1];const d=`data-bx="${it.c[0]}" data-fi="${j}" data-gn="${n}"`;
682
738
  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>`;});}}
683
739
  s+=renderDims();
684
- 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();
740
+ 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)
741
+ 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();
685
742
  if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
686
743
  }
687
744
  function updDup(){const n=redundantDups().length;
@@ -693,11 +750,12 @@ function updDup(){const n=redundantDups().length;
693
750
  function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
694
751
  svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
695
752
  svg.querySelectorAll('text.numtx').forEach(t=>{t.setAttribute('x',(+t.dataset.bx)+ox(t));t.style.fontSize=F+'px';});
696
- svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',epR()));
753
+ svg.querySelectorAll('circle.handle,circle.dimhandle').forEach(h=>h.setAttribute('r',epR()));
697
754
  const dz=13/zoom;svg.querySelectorAll('text.dimtx').forEach(t=>t.style.fontSize=dz+'px');
698
755
  svg.querySelectorAll('circle.dimend').forEach(c=>c.setAttribute('r',3/zoom));
699
756
  svg.querySelectorAll('rect.dimchip').forEach(r=>{const w=(+r.dataset.w)/zoom,h=(+r.dataset.h)/zoom;
700
- r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.cx)-w/2);r.setAttribute('y',(+r.dataset.cy)-h/2);});}
757
+ r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.cx)-w/2);r.setAttribute('y',(+r.dataset.cy)-h/2);});
758
+ const cg=svg.querySelector('g.csglyph');if(cg&&P.frame)cg.outerHTML=axisGlyphSvg(P.frame.o,P.frame.u,false);} // glyph is sized in 1/zoom → regenerate on zoom (like the dim chips)
701
759
  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]);});}
702
760
  function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
703
761
  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]);}}
@@ -712,14 +770,20 @@ const VARIES=Symbol('varies');
712
770
  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;}
713
771
  function panel(){
714
772
  const p=document.getElementById('panel');
773
+ if(csaxisMode){p.innerHTML=`<span class=badge>Set local axes</span>
774
+ <div class=hint id=csHint style="margin-top:8px">${csDraft?'Click the <b>second</b> point — the local-X direction. Y follows at 90°.':'Click the <b>origin</b> point to start.'}</div>
775
+ <div class=hint style="margin-top:8px">Two clicks define the frame; snaps to member ends &amp; grid (<b>Alt</b> off). <b>Esc</b> cancels.</div>`;return;}
715
776
  if(dimMode){p.innerHTML=`<span class=badge>Dimension</span>
716
777
  <div class=hint id=dimHint style="margin-top:8px">${esc(dimHintText())}</div>
717
778
  <div class=sect style="margin-top:12px">Direction</div>
718
779
  <div class=seg2><button id=dimAxFree class="${dimAxis==='free'?'on':''}">Free</button><button id=dimAxX class="${dimAxis==='x'?'on':''}">X</button><button id=dimAxY class="${dimAxis==='y'?'on':''}">Y</button></div>
780
+ <div class=sect style="margin-top:12px">Mode</div>
781
+ <div class=seg2><button id=dimChainB class="${dimChain?'on':''}" title="Chain (C) — keep adding dimensions from the last point; Esc/Enter ends the chain">⛓ Chain</button></div>
719
782
  <div class=hint style="margin-top:8px">Snaps to grid intersections, member ends, and onto member lines — same as drawing. <b>Alt</b> turns snap off. <b>Esc</b> cancels.</div>`;
720
783
  document.getElementById('dimAxFree').onclick=()=>{dimSetAxis('free');dimRefreshPrev();panel();};
721
784
  document.getElementById('dimAxX').onclick=()=>{dimSetAxis('x');dimRefreshPrev();panel();};
722
785
  document.getElementById('dimAxY').onclick=()=>{dimSetAxis('y');dimRefreshPrev();panel();};
786
+ document.getElementById('dimChainB').onclick=()=>toggleDimChain();
723
787
  return;}
724
788
  if(mode==='add'){
725
789
  p.innerHTML=`<span class=badge>Add member</span>
@@ -909,49 +973,114 @@ function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove()
909
973
  // svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
910
974
  const SVGNS='http://www.w3.org/2000/svg';
911
975
  function setDimMode(){document.body.classList.toggle('dimon',dimMode);document.getElementById('dimB').classList.toggle('on',dimMode);
912
- if(dimMode){if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();dimsVisible=true;updDimToggle();} // arming clears conflicting modes + ensures dims are visible
913
- dimDraft=null;dimPrevClear();}
976
+ if(dimMode){if(mode==='add'){mode='sel';setMode();}if(csaxisMode){csaxisMode=false;setCsMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();dimsVisible=true;updDimToggle();} // arming clears conflicting modes (add/set-axes) + ensures dims are visible — covers the D-key path too, not just the button
977
+ dimDraft=null;dimChainPrev=null;dimPrevClear();}
914
978
  function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
915
979
  if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;return {x,y,raw:[q.x,q.y],hit:sn.hit};}
916
980
  return {x,y,raw:[q.x,q.y],hit:false};}
917
981
  function dimClick(e){const s=dimSnapAt(e);
982
+ if(dimChainPrev){const a=dimChainPrev.point,b=[s.x,s.y],axis=dimChainPrev.axis,off=dimChainPrev.off,rot=dimChainPrev.rot||0; // CHAIN: each click adds the next dim from the previous point, on the same axis + offset line + baked frame
983
+ if(dimGeo(a,b,axis,off,rot).px<1){dimPrev(e);return;} // ignore a zero / sub-pixel click (measured length in the frame) — don't commit a degenerate dim
984
+ edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off,rot});});
985
+ dimChainPrev={point:b,axis,off,rot};dimPrev(e);return;} // keep going from b; Esc/Enter ends the chain
918
986
  if(!dimDraft){dimDraft={a:[s.x,s.y],b:null,axis:dimAxis};dimPrev(e);return;} // click 1 → first anchor
919
987
  if(!dimDraft.b){dimDraft.b=[s.x,s.y];dimPrev(e);return;} // click 2 → second anchor (value locks)
920
988
  const axis=dimDraft._ax||dimDraft.axis; // commit the axis the preview last showed: _ax is cached every move (incl. the click-2 preview), so a Shift press/release between move and click can't make the commit disagree with what was on screen
921
- const off=dimOffFromPointer(dimDraft.a,dimDraft.b,axis,s.raw); // click 3 offset/side from raw pointer
989
+ const rot=axis==='free'?0:frameRot(); // bake the active frame so a later Reset/redefine never moves this dim (free is frame-independent)
990
+ const off=dimOffFromPointer(dimDraft.a,dimDraft.b,axis,s.raw,rot); // click 3 → offset/side from raw pointer
922
991
  const a=dimDraft.a,b=dimDraft.b;
923
- edit(()=>{P.dims.push({id:'d'+Date.now(),a,b,axis,off});}); // undoable; render() clears the preview
992
+ edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off,rot});}); // undoable; render() clears the preview
993
+ if(dimChain)dimChainPrev={point:b,axis,off,rot}; // chain mode → continue from b along this same offset line + baked frame
924
994
  dimDraft=null;}
925
995
  function dimPrevClear(){const g=document.getElementById('dimPrevG');if(g)g.remove();snapClear();}
926
- function dimPrev(e){if(!dimMode||!dimDraft){dimPrevClear();return;}
996
+ function dimPrev(e){if(!dimMode){dimPrevClear();return;}
997
+ {const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();} // keep the panel hint live (chain / stage state)
998
+ if(dimChainPrev){const s=dimSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear(); // chain: preview the next segment from the last point at the shared axis + offset
999
+ let g=document.getElementById('dimPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='dimPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
1000
+ g.innerHTML=dimSvg({a:dimChainPrev.point,b:[s.x,s.y],axis:dimChainPrev.axis,off:dimChainPrev.off,rot:dimChainPrev.rot||0},{preview:true});return;}
1001
+ if(!dimDraft){const s=dimSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear();const og=document.getElementById('dimPrevG');if(og)og.remove();return;} // before click 1: show the snap marker for the prospective FIRST anchor (no dim drawn yet)
927
1002
  let g=document.getElementById('dimPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='dimPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
928
1003
  if(!dimDraft.b){const s=dimSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear(); // stage 1: live CONSTRAINED preview from a → the tentative 2nd point
929
1004
  const b=[s.x,s.y];dimLastPtr=b;
930
- const ax=e.shiftKey?(Math.abs(b[0]-dimDraft.a[0])>=Math.abs(b[1]-dimDraft.a[1])?'x':'y'):dimDraft.axis; // X/Y/F + Shift force already apply while picking the 2nd point (value shown live)
931
- g.innerHTML=dimSvg({a:dimDraft.a,b,axis:ax,off:dimDefaultOff(dimDraft.a,b,ax)},{preview:true});return;}
1005
+ const ax=e.shiftKey?dominantAxis(dimDraft.a,b):dimDraft.axis; // X/Y/F + Shift force already apply while picking the 2nd point (value shown live); Shift dominant-axis follows the local frame
1006
+ const rot=ax==='free'?0:frameRot();
1007
+ g.innerHTML=dimSvg({a:dimDraft.a,b,axis:ax,off:dimDefaultOff(dimDraft.a,b,ax,rot),rot},{preview:true});return;}
932
1008
  const q=toSvg(e);dimLastPtr=[q.x,q.y];snapClear();
933
- const ax=e.shiftKey?(Math.abs(dimDraft.b[0]-dimDraft.a[0])>=Math.abs(dimDraft.b[1]-dimDraft.a[1])?'x':'y'):dimDraft.axis; // Shift = momentary dominant-axis lock
934
- const off=dimOffFromPointer(dimDraft.a,dimDraft.b,ax,[q.x,q.y]); // stage 2: full dim rides the pointer
935
- g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:ax,off},{preview:true});dimDraft._ax=ax;}
1009
+ const ax=e.shiftKey?dominantAxis(dimDraft.a,dimDraft.b):dimDraft.axis; // Shift = momentary dominant-axis lock (local-frame aware)
1010
+ const rot=ax==='free'?0:frameRot();
1011
+ const off=dimOffFromPointer(dimDraft.a,dimDraft.b,ax,[q.x,q.y],rot); // stage 2: full dim rides the pointer
1012
+ g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:ax,off,rot},{preview:true});dimDraft._ax=ax;}
936
1013
  function dimSetAxis(ax){dimAxis=ax;if(dimDraft)dimDraft.axis=ax;
937
1014
  const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();
938
1015
  for(const [id,a] of [['dimAxFree','free'],['dimAxX','x'],['dimAxY','y']]){const b=document.getElementById(id);if(b)b.classList.toggle('on',dimAxis===a);}} // keep the Free/X/Y control in sync whether the axis changed by button OR keyboard (x/y/f)
939
- function dimHintText(){const a={free:'Free (aligned)',x:'X (horizontal)',y:'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
940
- return 'Dimension — '+a+'. Click 1: start · Click 2: end · Click 3: place. Shift=lock axis · X/Y/F force · Alt=no snap · Esc=cancel.';}
1016
+ function dimHintText(){if(dimChainPrev)return 'Chaining — click the next point to add another dimension from the last one. Enter or Esc ends the chain.';
1017
+ const loc=!!(P&&P.frame);
1018
+ const a={free:'Free (aligned)',x:loc?'X (local)':'X (horizontal)',y:loc?'Y (local)':'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
1019
+ return 'Dimension — '+a+(dimChain?' · Chain ON':'')+(loc?' · local axes':'')+'. Click 1: start · Click 2: end · Click 3: place. Shift=lock axis · X/Y/F force · Alt=no snap · Esc=cancel.';}
1020
+ function toggleDimChain(){dimChain=!dimChain;if(!dimChain){dimChainPrev=null;dimPrevClear();}render();} // Chain (C): toggle continuous dimensioning; turning it off ends any running chain (render() repaints the panel)
941
1021
  let dimLastPtr=null; // last canvas pointer during placement, for axis-key live refresh
942
1022
  function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
943
1023
  const g=document.getElementById('dimPrevG');if(!g)return;
1024
+ const rot=dimDraft.axis==='free'?0:frameRot();
944
1025
  if(!dimDraft.b){const b=dimLastPtr; // stage 1: redraw the forming dim with the just-changed axis (key/button), no mouse move needed
945
- g.innerHTML=dimSvg({a:dimDraft.a,b,axis:dimDraft.axis,off:dimDefaultOff(dimDraft.a,b,dimDraft.axis)},{preview:true});return;}
946
- const off=dimOffFromPointer(dimDraft.a,dimDraft.b,dimDraft.axis,dimLastPtr);dimDraft._ax=dimDraft.axis; // stage 2: keep the offset the pointer last set; sync _ax so the 3rd-click commit uses the axis now on screen
947
- g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:dimDraft.axis,off},{preview:true});}
1026
+ g.innerHTML=dimSvg({a:dimDraft.a,b,axis:dimDraft.axis,off:dimDefaultOff(dimDraft.a,b,dimDraft.axis,rot),rot},{preview:true});return;}
1027
+ const off=dimOffFromPointer(dimDraft.a,dimDraft.b,dimDraft.axis,dimLastPtr,rot);dimDraft._ax=dimDraft.axis; // stage 2: keep the offset the pointer last set; sync _ax so the 3rd-click commit uses the axis now on screen
1028
+ g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:dimDraft.axis,off,rot},{preview:true});}
1029
+ // --- Local coordinate system "set axes" tool: two snapped clicks (origin, then X-direction) define
1030
+ // P.frame={o,u}. Shares buildSnap/snap/snapMark; preview lives in its own <g> (render() rebuilds wholesale). ---
1031
+ function setCsMode(){document.body.classList.toggle('csaxison',csaxisMode);
1032
+ const b=document.getElementById('csSetB');if(b)b.classList.toggle('on',csaxisMode);
1033
+ if(csaxisMode){if(mode==='add'){mode='sel';setMode();}if(dimMode){dimMode=false;setDimMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();} // arming disarms conflicting tools + clears selection
1034
+ csDraft=null;csPrevClear();}
1035
+ function csSnapAt(e){const q=toSvg(e);if(e.altKey)return [q.x,q.y];buildSnap(null);const sn=snap(q.x,q.y);return [sn.x,sn.y];}
1036
+ function csClick(e){const p=csSnapAt(e);
1037
+ if(!csDraft){csDraft=p;csPrev(e);panel();return;} // click 1 → origin (refresh the panel hint to "click the 2nd point")
1038
+ const dx=p[0]-csDraft[0],dy=p[1]-csDraft[1],L=Math.hypot(dx,dy); // click 2 → X-direction
1039
+ if(L<1e-6){toast('Pick two distinct points for the X axis');return;} // degenerate → stay armed
1040
+ const o=csDraft.slice(),u=[dx/L,dy/L];
1041
+ csaxisMode=false;setCsMode();
1042
+ edit(()=>{P.frame={o,u};}); // undoable + persisted; render() draws the glyph
1043
+ toast('Local axes set ('+Math.round(Math.atan2(u[1],u[0])*180/Math.PI)+'°) — ortho & X/Y dims now follow them');}
1044
+ function csPrevClear(){const g=document.getElementById('csPrevG');if(g)g.remove();snapClear();}
1045
+ function csPrev(e){if(!csaxisMode){csPrevClear();return;}
1046
+ const p=csSnapAt(e);(!e.altKey)?snapMark(p[0],p[1]):snapClear();
1047
+ let g=document.getElementById('csPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='csPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
1048
+ g.innerHTML=csDraft?axisGlyphSvg(csDraft,[p[0]-csDraft[0],p[1]-csDraft[1]],true):'';} // before click 1 → just the snap marker; after → rubber-band axes toward the pointer
1049
+ function resetFrame(){if(!P.frame)return;edit(()=>{P.frame=null;});toast('Axes reset to global');}
1050
+ // One coordinate-axes glyph → SVG string. `dir` = any vector along local-X (normalized here). Screen-constant
1051
+ // size (÷zoom), re-synced on zoom by updateBadges. Tekla axis colours: X = red, Y = green, origin = Z blue
1052
+ // (local-Y = [uy,-ux], plan X-right→Y-up; in plan view Z points out of screen, so it's the origin dot).
1053
+ function axisGlyphSvg(o,dir,preview){
1054
+ const L=Math.hypot(dir[0],dir[1]);let ux=1,uy=0;if(L>1e-6){ux=dir[0]/L;uy=dir[1]/L;}
1055
+ const vx=uy,vy=-ux,len=46/zoom,hd=9/zoom,lab=15/zoom,sw=2.5/zoom,halo=sw+3/zoom,ox=o[0],oy=o[1];
1056
+ const xt=[ox+ux*len,oy+uy*len],yt=[ox+vx*len,oy+vy*len];
1057
+ const lx=[xt[0]+ux*lab,xt[1]+uy*lab],ly=[yt[0]+vx*lab,yt[1]+vy*lab]; // label anchors just past each tip
1058
+ const barbs=(tip,ax,ay)=>{const px=-ay,py=ax; // arrowhead: tip + two barbs back along (ax,ay)
1059
+ return `${tip[0]},${tip[1]} ${tip[0]-ax*hd+px*hd*0.5},${tip[1]-ay*hd+py*hd*0.5} ${tip[0]-ax*hd-px*hd*0.5},${tip[1]-ay*hd-py*hd*0.5}`;};
1060
+ // dark halo under each arm so the X/Y axis stays readable where a same-colour member runs through it
1061
+ const arm=(tip,col)=>`<line x1="${ox}" y1="${oy}" x2="${tip[0]}" y2="${tip[1]}" stroke="#0b1220" stroke-width="${halo}" stroke-linecap="round"/>`
1062
+ +`<line x1="${ox}" y1="${oy}" x2="${tip[0]}" y2="${tip[1]}" stroke="${col}" stroke-width="${sw}"/>`;
1063
+ const r=7/zoom; // label backing chip (dark, axis-coloured edge — like the dim chip), keeps the letter off the drawing
1064
+ const tag=(c,col,txt)=>`<rect x="${c[0]-r}" y="${c[1]-r}" width="${2*r}" height="${2*r}" rx="${2/zoom}" fill="#111827" stroke="${col}" stroke-width="${1/zoom}"/>`
1065
+ +`<text x="${c[0]}" y="${c[1]}" fill="${col}" font-weight="700" style="font-size:${lab}px" text-anchor="middle" dominant-baseline="central">${txt}</text>`;
1066
+ return `<g class="csglyph"${preview?' opacity="0.85"':''} pointer-events="none">`
1067
+ +arm(xt,'#ef4444')+`<polygon points="${barbs(xt,ux,uy)}" fill="#ef4444"/>`
1068
+ +arm(yt,'#22c55e')+`<polygon points="${barbs(yt,vx,vy)}" fill="#22c55e"/>`
1069
+ +`<circle cx="${ox}" cy="${oy}" r="${4/zoom}" fill="#3b82f6" stroke="#e2e8f0" stroke-width="${1.5/zoom}"/>`
1070
+ +tag(lx,'#ef4444','X')+tag(ly,'#22c55e','Y')+`</g>`;}
1071
+ // header chip + the More-menu "Reset to global" enabled state — reflect the current frame.
1072
+ function updCS(){const s=document.getElementById('csStat');
1073
+ if(s){const f=P&&P.frame;s.innerHTML='Axes <b>'+(f?(Math.round(Math.atan2(f.u[1],f.u[0])*180/Math.PI)+'° local'):'Global')+'</b>';s.classList.toggle('local',!!f);s.title=f?'Local coordinate frame active — ortho draw & X/Y dimensions follow it. Reset via the ⋯ menu.':'Global axes (default). Set a local frame from the ⋯ menu for skewed framing.';}
1074
+ const r=document.getElementById('csResetB');if(r)r.disabled=!(P&&P.frame);}
948
1075
  // themed transient toast (baseline tokens — never a native alert)
949
1076
  function toast(msg){let t=document.getElementById('toast');
950
1077
  if(!t){t=document.createElement('div');t.id='toast';t.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:8px 14px;box-shadow:0 6px 20px rgba(0,0,0,.5);z-index:60;font:13px system-ui;opacity:0;transition:opacity .2s;pointer-events:none';document.body.appendChild(t);}
951
1078
  t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
952
1079
  // --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
953
1080
  svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1081
+ if(csaxisMode){csClick(e);e.preventDefault();return;} // set-local-axes armed → clicks define origin then X-direction
954
1082
  if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
1083
+ if(t.dataset.dimend!=null&&mode==='sel'){const id=t.dataset.dim,end=+t.dataset.dimend;buildSnap(null);drag={type:'dimend',id,end,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // drag a selected dim's anchor handle → re-measure (snaps)
955
1084
  if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
956
1085
  if(e.ctrlKey||e.metaKey){selDimIds.has(did)?selDimIds.delete(did):selDimIds.add(did);render();return;} // Ctrl+click toggles this dim within the dim selection
957
1086
  selDimIds=new Set([did]);const d=P.dims.find(x=>x.id===did); // plain click → exactly this dim (collapses any multi-selection, so Delete removes only it)
@@ -990,20 +1119,22 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
990
1119
  const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
991
1120
  drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
992
1121
  svg.addEventListener('pointermove',e=>{
1122
+ if(csaxisMode){csPrev(e);return;}
993
1123
  if(dimMode){dimPrev(e);return;}
994
1124
  if(geoMode==='split'&&selIds.size===1&&!drag){const m=byId([...selIds][0]);if(m){const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
995
1125
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
996
1126
  if(d<16/zoom&&raw.t>0.03&&raw.t<0.97){let cut=raw.pt;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;}}snapMark(cut[0],cut[1]);}else snapClear();}return;}
997
1127
  if(!drag)return;const p=toSvg(e);
998
- if(drag.type==='dim'){const d=P.dims.find(x=>x.id===drag.id);if(d){d.off=dimOffFromPointer(d.a,d.b,d.axis,[p.x,p.y]);render();}return;}
1128
+ if(drag.type==='dimend'){const d=P.dims.find(x=>x.id===drag.id);if(d){let x=p.x,y=p.y;if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();if(drag.end===0)d.a=[x,y];else d.b=[x,y];render();}return;} // dragging a dim anchor → snap + re-measure live
1129
+ if(drag.type==='dim'){const d=P.dims.find(x=>x.id===drag.id);if(d){d.off=dimOffFromPointer(d.a,d.b,d.axis,[p.x,p.y],d.rot);render();}return;}
999
1130
  if(drag.type==='draw'){let x=p.x,y=p.y;
1000
- if(e.shiftKey){if(Math.abs(x-drag.x0)>=Math.abs(y-drag.y0))y=drag.y0;else x=drag.x0;snapClear();}
1131
+ if(e.shiftKey){[x,y]=orthoLock(drag.x0,drag.y0,x,y);snapClear();} // ortho draw — local frame when set, else screen H/V
1001
1132
  else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
1002
1133
  drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
1003
1134
  if(drag.type==='marquee'){const x=Math.min(drag.x0,p.x),y=Math.min(drag.y0,p.y),w=Math.abs(p.x-drag.x0),h=Math.abs(p.y-drag.y0);
1004
1135
  drag.rect.setAttribute('x',x);drag.rect.setAttribute('y',y);drag.rect.setAttribute('width',w);drag.rect.setAttribute('height',h);drag.cur=[x,y,x+w,y+h];return;}
1005
1136
  if(drag.type==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
1006
- if(e.shiftKey){if(Math.abs(dx)>=Math.abs(dy))dy=0;else dx=0;snapClear();}
1137
+ if(e.shiftKey){[dx,dy]=orthoLock(0,0,dx,dy);snapClear();} // ortho move — lock the delta to the local frame (or screen H/V)
1007
1138
  else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint OR line)
1008
1139
  for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
1009
1140
  for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny};}}
@@ -1015,7 +1146,7 @@ svg.addEventListener('pointermove',e=>{
1015
1146
  if(selIds.size<=2)for(const m of selArr())updateHandles(m);return;}
1016
1147
  // endpoint
1017
1148
  const m=byId(drag.id);if(!m)return;let x=p.x,y=p.y;
1018
- if(e.shiftKey){if(Math.abs(x-drag.anchor[0])>=Math.abs(y-drag.anchor[1]))y=drag.anchor[1];else x=drag.anchor[0];snapClear();}
1149
+ if(e.shiftKey){[x,y]=orthoLock(drag.anchor[0],drag.anchor[1],x,y);snapClear();} // ortho endpoint — local frame when set, else screen H/V
1019
1150
  else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
1020
1151
  m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);});
1021
1152
  svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
@@ -1026,12 +1157,14 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
1026
1157
  drag=null;render();return;}
1027
1158
  if(drag.type==='marquee'){if(!drag.add){selIds.clear();selDimIds.clear();}
1028
1159
  const r=drag.cur;if(r&&(r[2]-r[0]>2||r[3]-r[1]>2)){for(const m of P.members)if(rectHit(m.wp[0],m.wp[1],r))selIds.add(m.id);
1029
- if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off);if(rectHit(g.p1,g.p2,r)||rectHit(g.w[0][0],g.w[0][1],r)||rectHit(g.w[1][0],g.w[1][1],r))selDimIds.add(d.id);}} // area-select grabs dims by their VISIBLE geometry — the dim line + its two witness lines (not the invisible anchor span)
1160
+ if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);if(rectHit(g.p1,g.p2,r)||rectHit(g.w[0][0],g.w[0][1],r)||rectHit(g.w[1][0],g.w[1][1],r))selDimIds.add(d.id);}} // area-select grabs dims by their VISIBLE geometry — the dim line + its two witness lines (not the invisible anchor span)
1030
1161
  drag.rect.remove();drag=null;render();return;}
1031
1162
  if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
1032
1163
  function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
1033
- document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();}; // entering add/sel disarms the Dimension tool (else dimMode keeps eating canvas clicks) and drops any dim selection
1034
- document.getElementById('dimB').onclick=()=>{dimMode=!dimMode;setDimMode();render();};
1164
+ document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();}; // entering add/sel disarms the Dimension + set-axes tools (else they keep eating canvas clicks) and drops any dim selection
1165
+ document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();};
1166
+ document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
1167
+ document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
1035
1168
  function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
1036
1169
  document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
1037
1170
  document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
@@ -1060,14 +1193,17 @@ addEventListener('keydown',e=>{
1060
1193
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
1061
1194
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
1062
1195
  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)
1063
- if(e.key==='Escape'&&dimMode){if(dimDraft){dimDraft=null;dimPrevClear();}else{dimMode=false;setDimMode();}render();return;} // 1st Esc drops the in-progress dim, 2nd exits the tool
1196
+ if(e.key==='Escape'&&csaxisMode){if(csDraft){csDraft=null;csPrevClear();}else{csaxisMode=false;setCsMode();}render();return;} // 1st Esc drops the in-progress origin, 2nd exits set-axes
1197
+ if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
1198
+ 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
1064
1199
  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;}
1065
1200
  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)
1066
1201
  if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
1067
1202
  if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
1068
1203
  if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'){e.preventDefault();dimMode=!dimMode;setDimMode();render();return;} // D — toggle the Dimension tool
1069
1204
  if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
1070
- if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}}
1205
+ if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
1206
+ if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
1071
1207
  if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
1072
1208
  if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
1073
1209
  if(kk==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
@@ -1466,6 +1602,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
1466
1602
  if(!Array.isArray(P.members))P.members=[];
1467
1603
  if(!Array.isArray(P.segments))P.segments=[];
1468
1604
  if(!Array.isArray(P.dims))P.dims=[];
1605
+ sanitizeFrame(P); // tolerate a hand-edited/AI-written frame: normalize or drop to global
1469
1606
  // clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
1470
1607
  // committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
1471
1608
  // box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
@@ -1488,7 +1625,8 @@ function setPlan(i){C.active=i;P=C.plans[i];
1488
1625
  profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
1489
1626
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
1490
1627
  selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
1491
- dimMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan; setDimMode syncs the button/body.dimon classes + clears any draft/preview (dimsVisible persists across plans)
1628
+ dimMode=false;dimChain=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
1629
+ csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
1492
1630
  defaultTOS=(P.default_tos!=null?P.default_tos:198);
1493
1631
  P.default_tos=defaultTOS; // make the default explicit so the 3D scene + bake use the SAME TOS the editor/dots do (contractToScene falls back to 0, not 198 — keeping them in sync stops the end dots floating off the steel)
1494
1632
  if(!P.autofilled){autofillTOS();P.autofilled=true;}
@@ -1503,6 +1641,37 @@ const _restored=restoreSaved(); // bring back this brows
1503
1641
  setPlan(C.active==null?0:C.active);
1504
1642
  setSaved(_restored?'ok':'idle', _restored?'Restored ✓':'Auto-save on');
1505
1643
  try{stage.focus({preventScroll:true});}catch(_){} // grab keyboard focus on open so shortcuts work before the first click
1644
+ // --- ?selftest=1 — cheap regression guard for the only non-trivial new math (local frame). Logs PASS/FAIL
1645
+ // to the console and a toast; never runs in normal use. (ponytail: one runnable check for the frame logic.)
1646
+ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
1647
+ const fails=[];const ok=(c,m)=>{if(!c)fails.push(m);};
1648
+ const near=(a,b,t=1e-6)=>Math.abs(a-b)<=t;const pnear=(p,q,t=1e-6)=>near(p[0],q[0],t)&&near(p[1],q[1],t);
1649
+ const sf=P.frame;
1650
+ // 1) orthoLock — global (no frame): snaps to screen H/V
1651
+ P.frame=null;
1652
+ ok(pnear(orthoLock(0,0,3,1),[3,0]),'orthoLock global x-dominant');
1653
+ ok(pnear(orthoLock(0,0,1,3),[0,3]),'orthoLock global y-dominant');
1654
+ // 2) orthoLock — frame with local-X pointing down u=(0,1), local-Y v=(1,0)
1655
+ P.frame={o:[0,0],u:[0,1]};
1656
+ ok(pnear(orthoLock(0,0,3,1),[3,0]),'orthoLock framed locks to local-Y');
1657
+ ok(pnear(orthoLock(0,0,1,3),[0,3]),'orthoLock framed locks to local-X');
1658
+ ok(dominantAxis([0,0],[1,3])==='x'&&dominantAxis([0,0],[3,1])==='y','dominantAxis framed');
1659
+ // 3) dim rot — an x dim under a 90° frame measures along local-X (here 0), y measures 10
1660
+ P.frame=null;
1661
+ ok(near(dimGeo([0,0],[10,0],'x',0,0).px,10),'dimGeo x rot0');
1662
+ ok(near(dimGeo([0,0],[10,0],'x',0,Math.PI/2).px,0),'dimGeo x rot90 → along local-X');
1663
+ ok(near(dimGeo([0,0],[10,0],'y',0,Math.PI/2).px,10),'dimGeo y rot90 → along local-Y');
1664
+ ok(near(dimGeo([0,0],[3,4],'free',0,Math.PI/2).px,5),'dimGeo free rot-independent');
1665
+ // 4) witness "from" maps back to the anchor after the rotate-out
1666
+ ok((()=>{const g=dimGeo([2,3],[8,3],'x',5,0.5);return pnear(g.w[0][0],[2,3],1e-9);})(),'dim witness anchor round-trip');
1667
+ // 5) sanitizeFrame — normalize a non-unit u, drop zero-length / malformed (external/AI contract safety)
1668
+ {const tp={frame:{o:[0,0],u:[3,0]}};sanitizeFrame(tp);ok(tp.frame&&near(Math.hypot(tp.frame.u[0],tp.frame.u[1]),1),'sanitizeFrame normalizes non-unit u');}
1669
+ {const tz={frame:{o:[0,0],u:[0,0]}};sanitizeFrame(tz);ok(tz.frame===null,'sanitizeFrame drops zero-length u');}
1670
+ {const tb={frame:{o:[0,0],u:[1]}};sanitizeFrame(tb);ok(tb.frame===null,'sanitizeFrame drops malformed u');}
1671
+ P.frame=sf; // restore
1672
+ const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (local-frame math)';
1673
+ console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
1674
+ })();}
1506
1675
  // --- provenance caption: show contract.source when present (read from <name> · <sheet> · <read_at>) ---
1507
1676
  (function(){
1508
1677
  const src=C.source;if(!src||typeof src!=='object')return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
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": {