@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.
|
|
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
|
}
|