@floless/app 0.49.0 → 0.51.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.
@@ -212,6 +212,7 @@
212
212
  "segments": { "type": "array", "items": { "$ref": "#/$defs/segment" } },
213
213
  "labels": { "type": "array", "items": { "$ref": "#/$defs/label" } },
214
214
  "details": { "type": "array", "items": { "$ref": "#/$defs/detail" } },
215
+ "callouts": { "type": "array", "items": { "$ref": "#/$defs/callout" }, "description": "Drawing cross-references drawn ON this plan that point to another sheet (section cuts, elevation marks, detail refs). The editor renders a clickable bubble at each `disp` that jumps to the referenced sheet." },
215
216
  "tos_callouts": { "type": "array", "items": { "$ref": "#/$defs/tos_callout" } },
216
217
  "members": { "type": "array", "items": { "$ref": "#/$defs/member" } },
217
218
  "dims": { "type": "array", "items": { "$ref": "#/$defs/dim" }, "description": "Draft-only on-drawing dimensions (editor annotations). NOT baked into the lock / 3D / IFC / BOM." },
@@ -281,6 +282,26 @@
281
282
  "disp": { "$ref": "#/$defs/point2" }
282
283
  }
283
284
  },
285
+ "callout": {
286
+ "type": "object",
287
+ "required": ["kind", "target"],
288
+ "additionalProperties": true,
289
+ "description": "A drawing cross-reference drawn ON this plan that points to another sheet — a section cut, an elevation mark, or a detail reference. Clicking its bubble in the editor jumps to the referenced sheet (a loaded plan → switch to it & frame it; otherwise the detail lightbox).",
290
+ "properties": {
291
+ "kind": { "enum": ["section", "elevation", "detail-ref"], "description": "section = a section cut symbol; elevation = an elevation / interior-elevation mark; detail-ref = a detail callout pointing to another sheet." },
292
+ "text": { "type": "string", "description": "The mark as drawn, e.g. \"A\", \"A-A\", \"3/S-501\"." },
293
+ "disp": { "$ref": "#/$defs/point2", "description": "Where the mark is drawn on THIS plan (display space) — the clickable hotspot anchor." },
294
+ "target": {
295
+ "type": "object",
296
+ "additionalProperties": true,
297
+ "description": "Where the mark points.",
298
+ "properties": {
299
+ "sheet": { "type": "string", "description": "Referenced sheet, e.g. S-301." },
300
+ "number": { "type": "string", "description": "Detail / section number on that sheet, e.g. \"A\" or \"3\"." }
301
+ }
302
+ }
303
+ }
304
+ },
284
305
  "tos_callout": {
285
306
  "type": "object",
286
307
  "required": ["elev_in"],
@@ -237,6 +237,7 @@ Important optional fields:
237
237
  "segments": [ { "id": "seg-1", "a": [x,y], "b": [x,y], "o": "H" } ],
238
238
  "labels": [ { "text": "W16X26", "disp": [x,y] } ],
239
239
  "details": [ { "text": "5-S504", "disp": [x,y] } ],
240
+ "callouts": [ { "kind": "section", "text": "A", "disp": [x,y], "target": { "sheet": "S-301", "number": "A" } } ],
240
241
  "tos_callouts": [ { "elev_in": 198, "type": "TOS", "disp": [x,y] } ],
241
242
  "members": [
242
243
  {
@@ -296,6 +297,14 @@ Key field rules:
296
297
  `company-rules.md`: "moment → 50S504 → Tekla 146"), read it and emit those rows in `connections[]`
297
298
  with `source: "company-rules"`, then PUT. The user reviews/edits them in the editor's
298
299
  **Connections** library panel and assigns a row per member end. No new agent verb — it's contract data.
300
+ - **Clickable callouts (`callouts[]`).** Harvest the cross-reference marks drawn ON the plan that
301
+ point to ANOTHER sheet — **section cuts** (e.g. a circle with `A` over `A` and a sheet ref),
302
+ **elevation/interior-elevation marks**, and **detail references** — as per-plan
303
+ `callouts: [{ kind:"section"|"elevation"|"detail-ref", text:"A", disp:[x,y], target:{ sheet:"S-301", number:"A" } }]`.
304
+ `disp` is where the mark sits on THIS plan (display space — the clickable hotspot); `target` is where
305
+ it points. The editor draws a clickable bubble at each `disp`: clicking jumps to the referenced sheet
306
+ if it's loaded as a plan, else opens the detail lightbox. (Keep `details[]` as-is — those are the
307
+ detail callouts that already preview; `callouts[]` adds the section/elevation marks and off-sheet refs.)
299
308
 
300
309
  ### 5. Write to the contract store
301
310
 
package/dist/web/aware.js CHANGED
@@ -1072,6 +1072,8 @@
1072
1072
  // Export/write group — visually separated from the look-and-read actions above.
1073
1073
  addNodeActionDivider(card);
1074
1074
  addNodeAction(card, 'Export IFC ▸', () => exportContractIfc(currentId));
1075
+ addNodeAction(card, 'Export BOM (CSV) ▸', () => exportContractBom(currentId, 'csv'));
1076
+ addNodeAction(card, 'Export BOM (Excel) ▸', () => exportContractBom(currentId, 'xlsx'));
1075
1077
  const teklaBtn = addNodeAction(card, 'Send to Tekla ▸', () => exportContractTekla(currentId));
1076
1078
  teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
1077
1079
  card.dataset.tip = 'Double-click to open the contract editor';
@@ -1544,6 +1546,40 @@
1544
1546
  }
1545
1547
  }
1546
1548
 
1549
+ // Export BOM ▸ — write the approved takeoff's Bill of Materials OUT to a CSV or a real .xlsx and
1550
+ // download it. Generated host-side (no model): the server reuses the same BOM aggregation the
1551
+ // canvas BOM node shows, saves the file under ~/.floless/exports/<appId>/, and returns the bytes
1552
+ // (csv text, or xlsx base64) which the browser turns into a download. api() throws on any failure
1553
+ // (no contract, all-RFI 422, write error).
1554
+ async function exportContractBom(appId, format) {
1555
+ const label = format === 'xlsx' ? 'Excel' : 'CSV';
1556
+ showToast(`Generating BOM ${label}…`, 'info');
1557
+ try {
1558
+ const res = await api('/api/contract/' + encodeURIComponent(appId) + '/export-bom/' + format, { method: 'POST' });
1559
+ const body = res.encoding === 'base64' ? b64ToBytes(res.content || '') : (res.content || '');
1560
+ const mime = format === 'xlsx'
1561
+ ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
1562
+ : 'text/csv';
1563
+ const blob = new Blob([body], { type: mime });
1564
+ const url = URL.createObjectURL(blob);
1565
+ const a = document.createElement('a');
1566
+ a.href = url; a.download = res.filename || (appId + '-bom.' + format);
1567
+ document.body.appendChild(a); a.click(); a.remove();
1568
+ setTimeout(() => URL.revokeObjectURL(url), 4000);
1569
+ showToast(`BOM ${label} saved & downloaded — ${res.filename || appId + '-bom.' + format}`, 'ok');
1570
+ } catch (e) {
1571
+ showToast(`BOM ${label} export failed: ` + (e && e.message ? e.message : String(e)), 'warn');
1572
+ }
1573
+ }
1574
+
1575
+ // Decode a base64 string into a Uint8Array for a binary download (the .xlsx bytes).
1576
+ function b64ToBytes(b64) {
1577
+ const bin = atob(b64);
1578
+ const out = new Uint8Array(bin.length);
1579
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
1580
+ return out;
1581
+ }
1582
+
1547
1583
  // Send to Tekla ▸ — create native parts in the OPEN Tekla model from the approved takeoff. Needs
1548
1584
  // Tekla running with a model; when it's not, the server returns a clear needsTekla state (api()
1549
1585
  // throws with err.body.needsTekla) — surfaced as an informational nudge, not a hard error.
@@ -68,6 +68,15 @@
68
68
  rect.dethot{cursor:pointer;fill:rgba(168,85,247,.16);stroke:#a855f7;stroke-width:1;stroke-dasharray:3 3;vector-effect:non-scaling-stroke}
69
69
  rect.dethot:hover{fill:rgba(168,85,247,.34)}
70
70
  text.dettx{fill:#ddd6fe;font:bold 11px system-ui;text-anchor:middle;pointer-events:none}
71
+ /* clickable callout bubbles (Workstream B): section/elevation/detail-ref + plain details — navigable cross-references drawn on the plan in select mode. Existing palette only: purple #a855f7 (details), cyan #22d3ee (sections/elevations); solid vs dashed separates the kinds. */
72
+ g.cohot{cursor:pointer}
73
+ circle.cobub{stroke-width:1.5;vector-effect:non-scaling-stroke;opacity:.55}
74
+ circle.cobub.k-detail,circle.cobub.k-detailref{stroke:#a855f7;color:#a855f7;fill:rgba(168,85,247,.12)}
75
+ circle.cobub.k-section,circle.cobub.k-elev{stroke:#22d3ee;color:#22d3ee;fill:rgba(34,211,238,.10)}
76
+ circle.cobub.k-detailref,circle.cobub.k-elev{stroke-dasharray:3 3}
77
+ g.cohot:hover circle.cobub{opacity:1;stroke-width:2;filter:drop-shadow(0 0 4px currentColor)}
78
+ text.cotx{fill:#e2e8f0;font:bold 11px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none;opacity:.6}
79
+ g.cohot:hover text.cotx{opacity:1}
71
80
  #detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal,#connModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
72
81
  #askAiDrop:hover{border-color:var(--brand);color:var(--text)} #askAiDrop.has{border-style:solid;border-color:var(--brand)}
73
82
  .aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
@@ -215,6 +224,7 @@
215
224
  <div id=moreMenu role=menu>
216
225
  <div class=mlabel>Dimensions</div>
217
226
  <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
227
+ <button id=calloutToggleB title="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
218
228
  <hr>
219
229
  <div class=mlabel>Coordinate system</div>
220
230
  <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>
@@ -438,6 +448,7 @@ function main() {
438
448
  let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
439
449
  let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
440
450
  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
451
+ let calloutsVisible=true; // Workstream B: show/hide the clickable callout bubbles (details + section/elevation/detail-ref) on the plan in select mode
441
452
  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).
442
453
  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
443
454
  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
@@ -750,6 +761,22 @@ function render(){
750
761
  s+=`<rect class=lblhot data-prof="${esc(lb.text)}" x="${lb.disp[0]-w/2}" y="${lb.disp[1]-10}" width="${w}" height="20" rx="3"><title>${esc(lb.text)}</title></rect>`;}
751
762
  if(picking&&pickKind==='detail'&&P.details) for(const d of P.details){const w=Math.max(34,d.text.length*8);
752
763
  s+=`<rect class=dethot data-det="${esc(d.text)}" x="${d.disp[0]-w/2}" y="${d.disp[1]-9}" width="${w}" height="18" rx="3"><title>${esc(d.text)}</title></rect><text class=dettx x="${d.disp[0]}" y="${d.disp[1]+4}">${esc(d.text)}</text>`;}
764
+ // Workstream B: clickable callout bubbles in normal select mode — details[] + per-plan callouts[].
765
+ // data-co-sheet drives the smart jump (loaded plan → switch; else data-co-prev → detail lightbox).
766
+ // hidden while a sub-tool is armed (dim-split / geo extend-trim-split) so the bubbles never distract
767
+ // or shadow a tool click — those handlers run earlier in pointerdown, but don't draw navigables mid-tool.
768
+ if(calloutsVisible&&mode==='sel'&&!picking&&!dimMode&&!csaxisMode&&!dimSplitMode&&!geoMode){
769
+ const sheetLoaded=sh=>!!sh&&C.plans.some(p=>p.sheet&&p.sheet.toUpperCase()===String(sh).toUpperCase());
770
+ // honest destination hint: loaded → no suffix (jumps); a resolvable preview → "(lightbox)"; else "(not loaded)".
771
+ const hint=(sh,prev)=>sheetLoaded(sh)?'':(prev&&previewFor(prev)?' (lightbox)':' (not loaded)');
772
+ const cob=(kc,mark,disp,sheet,prev,tip)=>`<g class=cohot data-co-sheet="${esc(sheet||'')}" data-co-prev="${esc(prev||'')}"><title>${esc(tip)}</title><circle class="cobub k-${kc}" cx="${disp[0]}" cy="${disp[1]}" r="11"/><text class=cotx x="${disp[0]}" y="${disp[1]}">${esc(String(mark==null?'':mark).slice(0,3))}</text></g>`;
773
+ for(const d of (P.details||[])){if(!Array.isArray(d.disp))continue;const sh=sheetOf(d.text),nu=numOf(d.text);
774
+ s+=cob('detail',nu||d.text,d.disp,sh,d.text,'Detail '+(nu||d.text||'')+(sh?' → '+sh:'')+hint(sh,d.text));}
775
+ for(const co of (P.callouts||[])){if(!Array.isArray(co.disp))continue;const sh=co.target&&co.target.sheet,nu=co.target&&co.target.number;
776
+ const kc=co.kind==='section'?'section':co.kind==='elevation'?'elev':'detailref';const kl=co.kind==='section'?'Section':co.kind==='elevation'?'Elevation':'Detail ref';
777
+ const prev=(nu&&sh)?(nu+'-'+sh):(co.text||'');
778
+ s+=cob(kc,co.text||nu,co.disp,sh,prev,kl+' '+(co.text||nu||'')+(sh?' → '+sh:'')+hint(sh,prev));}
779
+ }
753
780
  // numbered badges on the selected members (1..N in selection order; coincident ones fan out so duplicates are countable)
754
781
  // sized in 1/zoom units → constant on screen at any zoom (updateBadges keeps them in sync while zooming)
755
782
  const selM=selArr();
@@ -1177,6 +1204,12 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1177
1204
  return;}
1178
1205
  if(t.classList.contains('dethot')){const det=t.dataset.det;
1179
1206
  if(picking&&pickKind==='detail'&&pickEnd){const o=pickEnd;picking=false;pickEnd=null;edit(()=>{o.detail=det;});}return;}
1207
+ {const cg=t.closest('g.cohot');if(cg){const sheet=cg.dataset.coSheet,prev=cg.dataset.coPrev; // Workstream B smart jump
1208
+ const idx=sheet?C.plans.findIndex(p=>p.sheet&&p.sheet.toUpperCase()===sheet.toUpperCase()):-1;
1209
+ if(idx>=0){if(idx!==C.active)setPlan(idx);else fitToWindow();} // referenced sheet is loaded → switch to it (or re-frame if already here)
1210
+ else if(prev&&previewFor(prev))openPreview(prev); // not loaded but we have the detail image → lightbox
1211
+ else toast(sheet?('Sheet '+sheet+' isn’t loaded — open it to jump there'):'No target sheet for this callout');
1212
+ return;}}
1180
1213
  if(t.classList.contains('seg')&&mode==='add'){addFromSeg(t.dataset.seg);return;}
1181
1214
  if(t.tagName==='image'&&mode==='add'){buildSnap(null);let q=toSvg(e),x0=q.x,y0=q.y;if(!e.altKey){const sn=snap(x0,y0);x0=sn.x;y0=sn.y;}const ln=document.createElementNS('http://www.w3.org/2000/svg','line');ln.setAttribute('class','draw');ln.setAttribute('x1',x0);ln.setAttribute('y1',y0);ln.setAttribute('x2',x0);ln.setAttribute('y2',y0);svg.appendChild(ln);drag={type:'draw',x0,y0,line:ln};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
1182
1215
  if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id;
@@ -1239,6 +1272,8 @@ document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode(
1239
1272
  document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
1240
1273
  function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
1241
1274
  document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
1275
+ function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b)b.textContent=calloutsVisible?'Hide callouts':'Show callouts';}
1276
+ document.getElementById('calloutToggleB').onclick=()=>{calloutsVisible=!calloutsVisible;updCalloutToggle();render();};
1242
1277
  document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
1243
1278
  if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
1244
1279
  setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.49.0",
3
+ "version": "0.51.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": {
@@ -39,6 +39,7 @@
39
39
  "rcedit": "^5.0.2",
40
40
  "tsx": "^4.19.0",
41
41
  "typescript": "^5.6.0",
42
+ "write-excel-file": "^4.1.1",
42
43
  "yaml": "^2.9.0"
43
44
  }
44
45
  }