@floless/app 0.62.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +3 -3
- package/dist/schemas/steel.takeoff.v1.schema.json +30 -5
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +29 -1
- package/dist/skills/floless-app-vectorize/SKILL.md +57 -4
- package/dist/web/aware.js +20 -0
- package/dist/web/grid-core.js +441 -0
- package/dist/web/renderers.js +3 -0
- package/dist/web/steel-3d-core.js +49 -3
- package/dist/web/steel-3d-view.js +417 -8
- package/dist/web/steel-editor.html +414 -32
- package/dist/web/vector-3d.html +242 -0
- package/package.json +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Vectorize — 3D view</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Locked baseline: shadcn dark slate-blue (same tokens + scrollbar theming as the sibling editors). */
|
|
9
|
+
:root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6}
|
|
10
|
+
*{scrollbar-width:thin;scrollbar-color:#475569 transparent}
|
|
11
|
+
*::-webkit-scrollbar{width:10px;height:10px}
|
|
12
|
+
*::-webkit-scrollbar-track{background:transparent}
|
|
13
|
+
*::-webkit-scrollbar-thumb{background:#475569;border-radius:6px;border:2px solid transparent;background-clip:content-box}
|
|
14
|
+
*::-webkit-scrollbar-thumb:hover{background:#5b6b85;background-clip:content-box}
|
|
15
|
+
*::-webkit-scrollbar-corner{background:transparent}
|
|
16
|
+
*{box-sizing:border-box}
|
|
17
|
+
body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui;height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
|
18
|
+
header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line);flex:none}
|
|
19
|
+
header b{font-size:14px}
|
|
20
|
+
header .spacer{flex:1}
|
|
21
|
+
.stat{color:var(--mut)} .stat b{color:var(--text);font-variant-numeric:tabular-nums}
|
|
22
|
+
/* The unplaced-entities chip — amber "needs review" language (never red: honesty, not error). */
|
|
23
|
+
#skipChip{display:inline-flex;align-items:center;gap:6px;background:transparent;border:1px solid var(--line);color:#f59e0b;border-radius:6px;padding:4px 10px;font:inherit;cursor:pointer}
|
|
24
|
+
#skipChip:hover{border-color:#f59e0b}
|
|
25
|
+
#skipChip[hidden]{display:none} /* the id's display beats the [hidden] UA rule — restore it */
|
|
26
|
+
/* Full-bleed canvas. Wrapper bg matches the Three.js clear color EXACTLY (#020817) so the brief
|
|
27
|
+
WebGL-init frame never flashes a seam against the app --bg. */
|
|
28
|
+
#wrap{flex:1;position:relative;min-height:0;background:#020817}
|
|
29
|
+
#stage3d{position:absolute;inset:0;width:100%;height:100%;outline:none}
|
|
30
|
+
#state{position:absolute;inset:0;display:none;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:var(--bg);color:var(--mut);text-align:center;padding:24px;z-index:4}
|
|
31
|
+
#state.show{display:flex}
|
|
32
|
+
#state .spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--brand);border-radius:50%;animation:spin .8s linear infinite}
|
|
33
|
+
#state .msg{max-width:520px;line-height:1.55}
|
|
34
|
+
#state .msg b{color:var(--text)}
|
|
35
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
36
|
+
/* Floating toolbar pill, top-left (the established 3D slot). */
|
|
37
|
+
#bar{position:absolute;top:12px;left:12px;display:flex;gap:6px;align-items:center;z-index:5}
|
|
38
|
+
#bar .seg-group{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:var(--panel);box-shadow:0 4px 14px rgba(0,0,0,.45)}
|
|
39
|
+
#bar .seg-group button{padding:5px 11px;margin:0;border:0;border-radius:0;background:transparent;color:var(--mut);font:600 12px system-ui;cursor:pointer}
|
|
40
|
+
#bar .seg-group button+button{border-left:1px solid var(--line)}
|
|
41
|
+
#bar .seg-group button:hover{color:var(--text)}
|
|
42
|
+
#bar .seg-group button.on{background:#0f172a;color:var(--text);box-shadow:inset 0 -2px 0 var(--brand)}
|
|
43
|
+
#bar>button{background:var(--panel);border:1px solid var(--line);border-radius:6px;color:var(--text);font:600 12px system-ui;padding:5px 11px;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.45)}
|
|
44
|
+
#bar>button:hover{border-color:var(--brand)}
|
|
45
|
+
#bar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto}
|
|
46
|
+
/* Group visibility toggles folded INLINE into the pill (leaves bottom-left to the skip panel). */
|
|
47
|
+
#bar .gck{display:inline-flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:4px 9px;box-shadow:0 4px 14px rgba(0,0,0,.45);cursor:pointer;color:var(--text);font-size:12px}
|
|
48
|
+
#bar .gck input{accent-color:var(--brand);cursor:pointer;margin:0}
|
|
49
|
+
#bar .gck .ct{color:var(--mut);font-variant-numeric:tabular-nums;font-size:11px}
|
|
50
|
+
#bar .gck .sw{width:9px;height:9px;border-radius:2px;flex:none}
|
|
51
|
+
/* The unplaced-entities panel, docked bottom-left. Glanceable (never a modal — it coexists with
|
|
52
|
+
the scene it annotates). Inner list scrolls with the themed scrollbar. */
|
|
53
|
+
#skipPanel{position:absolute;left:12px;bottom:12px;width:380px;max-width:calc(100% - 24px);background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:5;display:none}
|
|
54
|
+
#skipPanel.open{display:block}
|
|
55
|
+
#skipPanel h3{margin:0;padding:9px 12px 4px;font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
|
|
56
|
+
#skipPanel .hint{padding:0 12px 7px;color:var(--mut);font-size:12px;line-height:1.45}
|
|
57
|
+
#skipList{max-height:200px;overflow:auto;padding:0 6px 8px;display:flex;flex-direction:column;gap:2px}
|
|
58
|
+
.srow{display:flex;align-items:baseline;gap:6px;padding:4px 6px;border-radius:6px;font-size:12px}
|
|
59
|
+
.srow:hover{background:var(--line)}
|
|
60
|
+
.srow .sid{color:#f59e0b;flex:none;font-variant-numeric:tabular-nums}
|
|
61
|
+
.srow .rsn{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
62
|
+
</style>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
<header>
|
|
66
|
+
<b>Vectorize — 3D</b>
|
|
67
|
+
<span class="stat" id="title">—</span>
|
|
68
|
+
<span class="spacer"></span>
|
|
69
|
+
<span class="stat"><b id="nBoxes">0</b> members · <b id="nExtr">0</b> bodies</span>
|
|
70
|
+
<button id="skipChip" hidden>⚠ <b id="nSkipped">0</b> unplaced</button>
|
|
71
|
+
</header>
|
|
72
|
+
<div id="wrap">
|
|
73
|
+
<canvas id="stage3d"></canvas>
|
|
74
|
+
<div id="bar" hidden>
|
|
75
|
+
<div class="seg-group" id="projSeg">
|
|
76
|
+
<button data-proj="persp" class="on" title="Perspective view — natural depth">Persp</button>
|
|
77
|
+
<button data-proj="ortho" title="Orthographic — true scale, no perspective">Ortho</button>
|
|
78
|
+
</div>
|
|
79
|
+
<button id="fitBtn" title="Fit all to view">Fit</button>
|
|
80
|
+
<span class="tb-sep"></span>
|
|
81
|
+
<div class="seg-group" id="modeSeg">
|
|
82
|
+
<button data-mode="solid" class="on" title="Solid shaded model">Solid</button>
|
|
83
|
+
<button data-mode="wire" title="Wireframe — edges only">Wire</button>
|
|
84
|
+
<button data-mode="xray" title="See-through — reveal hidden parts">X-ray</button>
|
|
85
|
+
</div>
|
|
86
|
+
<span class="tb-sep"></span>
|
|
87
|
+
<span id="groupToggles"></span>
|
|
88
|
+
</div>
|
|
89
|
+
<div id="skipPanel">
|
|
90
|
+
<h3 id="skipTitle">Not shown in 3D</h3>
|
|
91
|
+
<div class="hint">These couldn't be matched to a 3D position. Fix the reason below, then reopen this view.</div>
|
|
92
|
+
<div id="skipList"></div>
|
|
93
|
+
</div>
|
|
94
|
+
<div id="state" class="show" role="status" aria-live="polite"><div class="spin"></div><div class="msg" id="stateMsg">Reconstructing…</div></div>
|
|
95
|
+
</div>
|
|
96
|
+
<script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/","three-bvh-csg":"./vendor/three-bvh-csg.module.js"}}</script>
|
|
97
|
+
<script type="module" src="./steel-3d-view.js"></script>
|
|
98
|
+
<script type="module">
|
|
99
|
+
(function poll(waited) { // steel-3d-view registers window.Steel3DView on module load — wait for it
|
|
100
|
+
if (window.Steel3DView) { boot(window.Steel3DView); return; }
|
|
101
|
+
if (waited > 8000) { // the module never loaded (bad vendor path / script error) — don't spin forever
|
|
102
|
+
document.getElementById('stateMsg').textContent = 'Could not load the 3D viewer. Reload the page, or check the console.';
|
|
103
|
+
document.getElementById('state').querySelector('.spin').style.display = 'none';
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
setTimeout(() => poll((waited || 0) + 20), 20);
|
|
107
|
+
})(0);
|
|
108
|
+
|
|
109
|
+
function boot(v3d) {
|
|
110
|
+
const $ = (id) => document.getElementById(id);
|
|
111
|
+
const params = new URLSearchParams(location.search);
|
|
112
|
+
const app = params.get('app');
|
|
113
|
+
let scene = null;
|
|
114
|
+
let skipped = [];
|
|
115
|
+
|
|
116
|
+
function showState(msgNode, spinning) {
|
|
117
|
+
$('stateMsg').replaceChildren(msgNode);
|
|
118
|
+
$('state').classList.add('show');
|
|
119
|
+
$('state').querySelector('.spin').style.display = spinning ? '' : 'none';
|
|
120
|
+
}
|
|
121
|
+
function hideState() { $('state').classList.remove('show'); }
|
|
122
|
+
|
|
123
|
+
// The read-only host api: the module's editing/dim/snap hooks are inert here — a viewer never
|
|
124
|
+
// mutates the contract. fetchScene serves the one scene this page already loaded.
|
|
125
|
+
const api = {
|
|
126
|
+
fetchScene: async () => scene,
|
|
127
|
+
getMembers: () => [],
|
|
128
|
+
ptPerFt: () => 1,
|
|
129
|
+
defaultTosMm: () => 0,
|
|
130
|
+
geoMode: () => null,
|
|
131
|
+
fmtLen: (mm) => `${Math.round(mm)} mm`,
|
|
132
|
+
getDimOverlays: () => [],
|
|
133
|
+
getDims3d: () => [],
|
|
134
|
+
selDim3d: () => [],
|
|
135
|
+
newDim3dId: () => 'd0',
|
|
136
|
+
onSelect: () => {}, onSelectMany: () => {}, onSelectDim3d: () => {}, onAddDim3d: () => {},
|
|
137
|
+
onMoveMember: () => {}, onMoveEndpoint: () => {}, onElevateMember: () => {}, onSplit: () => {},
|
|
138
|
+
onTrimExtend: () => {}, onIsolateChange: () => {}, onClipModeChange: () => {}, onClipsChange: () => {},
|
|
139
|
+
onWorkAreaChange: () => {}, beginClipEdit: () => {},
|
|
140
|
+
statusHint: () => 'Right-drag orbit · middle-drag pan · wheel zoom · dbl-click to zoom in',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
function emptyStateNode() {
|
|
144
|
+
// Plain AEC language — a cold empty state must not lean on schema vocabulary (per design consult).
|
|
145
|
+
// Two distinct zero-element cases: NOTHING was declared as a 3D entity (cold set), vs entities
|
|
146
|
+
// were read but NONE could be placed (all skipped) — the honest message differs.
|
|
147
|
+
const d = document.createElement('div');
|
|
148
|
+
const b = document.createElement('b');
|
|
149
|
+
const p = document.createElement('div');
|
|
150
|
+
p.style.marginTop = '8px';
|
|
151
|
+
if (skipped.length > 0) {
|
|
152
|
+
b.textContent = skipped.length === 1
|
|
153
|
+
? 'Nothing placed in 3D yet — the one entity read couldn’t be placed.'
|
|
154
|
+
: `Nothing placed in 3D yet — all ${skipped.length} entities are unplaced.`;
|
|
155
|
+
p.textContent = 'Each one is listed with the reason in the "Not shown in 3D" panel — usually a missing height (a level datum, an extrude value, or a matching elevation). Fix the reasons and reopen this view.';
|
|
156
|
+
} else {
|
|
157
|
+
b.textContent = 'No 3D yet — this drawing set doesn’t have matched plan and elevation sheets.';
|
|
158
|
+
p.textContent = '3D reconstruction needs at least one plan sheet and one elevation (or section) sheet that share entities, so heights can be worked out from the elevation. Classify your sheets as plan/elevation and make sure floor levels or extrude heights are set, then reopen this view — or ask your terminal AI to do it.';
|
|
159
|
+
}
|
|
160
|
+
d.append(b, p);
|
|
161
|
+
return d;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderSkipPanel() {
|
|
165
|
+
const chip = $('skipChip');
|
|
166
|
+
chip.hidden = skipped.length === 0;
|
|
167
|
+
$('nSkipped').textContent = skipped.length;
|
|
168
|
+
$('skipTitle').textContent = `Not shown in 3D (${skipped.length})`;
|
|
169
|
+
const list = $('skipList');
|
|
170
|
+
while (list.firstChild) list.removeChild(list.firstChild);
|
|
171
|
+
for (const s of skipped) {
|
|
172
|
+
const row = document.createElement('div'); row.className = 'srow';
|
|
173
|
+
const sid = document.createElement('span'); sid.className = 'sid'; sid.textContent = s.id;
|
|
174
|
+
const rsn = document.createElement('span'); rsn.className = 'rsn'; rsn.textContent = s.reason; rsn.title = s.reason;
|
|
175
|
+
row.append(sid, rsn); list.appendChild(row);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function renderGroupToggles() {
|
|
180
|
+
const box = $('groupToggles');
|
|
181
|
+
while (box.firstChild) box.removeChild(box.firstChild);
|
|
182
|
+
const counts = new Map();
|
|
183
|
+
for (const el of scene.elements) counts.set(el.group, (counts.get(el.group) || 0) + 1);
|
|
184
|
+
for (const g of v3d.getGroups()) {
|
|
185
|
+
const lab = document.createElement('label'); lab.className = 'gck';
|
|
186
|
+
const inp = document.createElement('input'); inp.type = 'checkbox'; inp.checked = true;
|
|
187
|
+
inp.addEventListener('change', () => v3d.toggleGroup(g.key));
|
|
188
|
+
const sw = document.createElement('span'); sw.className = 'sw'; sw.style.background = g.color;
|
|
189
|
+
const nm = document.createElement('span'); nm.textContent = g.label;
|
|
190
|
+
const ct = document.createElement('span'); ct.className = 'ct'; ct.textContent = String(counts.get(g.key) || 0);
|
|
191
|
+
lab.append(inp, sw, nm, ct); box.appendChild(lab);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
$('fitBtn').addEventListener('click', () => v3d.frameAll());
|
|
196
|
+
$('projSeg').addEventListener('click', (e) => {
|
|
197
|
+
const b = e.target.closest('button'); if (!b) return;
|
|
198
|
+
v3d.setProjection(b.dataset.proj);
|
|
199
|
+
for (const x of $('projSeg').children) x.classList.toggle('on', x === b);
|
|
200
|
+
});
|
|
201
|
+
$('modeSeg').addEventListener('click', (e) => {
|
|
202
|
+
const b = e.target.closest('button'); if (!b) return;
|
|
203
|
+
v3d.setDisplayMode(b.dataset.mode);
|
|
204
|
+
for (const x of $('modeSeg').children) x.classList.toggle('on', x === b);
|
|
205
|
+
});
|
|
206
|
+
$('skipChip').addEventListener('click', () => $('skipPanel').classList.toggle('open'));
|
|
207
|
+
|
|
208
|
+
(async () => {
|
|
209
|
+
if (!app) { showState(document.createTextNode('No app given — open this view from a workflow node.'), false); return; }
|
|
210
|
+
let out;
|
|
211
|
+
try {
|
|
212
|
+
const r = await fetch('/api/contract/' + encodeURIComponent(app) + '/scene', {
|
|
213
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
|
|
214
|
+
});
|
|
215
|
+
out = await r.json();
|
|
216
|
+
if (!r.ok || !out.ok) throw new Error(out.error || ('HTTP ' + r.status));
|
|
217
|
+
} catch (e) {
|
|
218
|
+
showState(document.createTextNode('Could not reconstruct the drawing (' + e.message + ').'), false);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
scene = out.scene;
|
|
222
|
+
skipped = Array.isArray(out.skipped) ? out.skipped : [];
|
|
223
|
+
const name = (scene.meta && scene.meta.name) || app;
|
|
224
|
+
$('title').textContent = name;
|
|
225
|
+
document.title = 'Vectorize — 3D · ' + name;
|
|
226
|
+
const boxes = scene.elements.filter((e) => e.kind === 'box').length;
|
|
227
|
+
const extr = scene.elements.filter((e) => e.kind === 'extrusion').length;
|
|
228
|
+
$('nBoxes').textContent = boxes; $('nExtr').textContent = extr;
|
|
229
|
+
renderSkipPanel();
|
|
230
|
+
if (skipped.length) $('skipPanel').classList.add('open'); // partial/failed reconstruction: lead with honesty
|
|
231
|
+
if (scene.elements.length === 0) { showState(emptyStateNode(), false); return; } // (chip + panel still show above #state)
|
|
232
|
+
v3d.init($('stage3d'), api);
|
|
233
|
+
v3d.show();
|
|
234
|
+
await v3d.rebuild(true); // fit the camera on first build
|
|
235
|
+
renderGroupToggles();
|
|
236
|
+
$('bar').hidden = false;
|
|
237
|
+
hideState();
|
|
238
|
+
})();
|
|
239
|
+
}
|
|
240
|
+
</script>
|
|
241
|
+
</body>
|
|
242
|
+
</html>
|