@floless/app 0.59.1 → 0.61.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 +145 -29
- package/dist/schemas/drawing.vector.v1.schema.json +135 -0
- package/dist/skills/floless-app-vectorize/SKILL.md +175 -0
- package/dist/skills/floless-app-vectorize/references/vision-inputs.template.json +40 -0
- package/dist/skills/floless-app-vectorize/scripts/extract_pdf.py +240 -0
- package/dist/skills/floless-app-vectorize/scripts/vision_to_contract.py +151 -0
- package/dist/templates/vectorize.flo +69 -0
- package/dist/web/aware.js +15 -11
- package/dist/web/renderers.js +7 -0
- package/dist/web/vector-editor.html +466 -0
- package/dist/web/vector-example.json +107 -0
- package/package.json +1 -1
|
@@ -0,0 +1,466 @@
|
|
|
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 — 2D editor</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Locked baseline: shadcn dark slate-blue (identical tokens + scrollbar theming as steel-editor.html). */
|
|
9
|
+
:root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6;--paper:#f8fafc}
|
|
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
|
+
button{height:28px;padding:0 12px;background:var(--line);color:var(--text);border:1px solid var(--line);border-radius:6px;cursor:pointer;font:inherit}
|
|
23
|
+
button:hover{background:#475569}
|
|
24
|
+
button.primary{background:var(--brand);border-color:var(--brand);color:#fff}
|
|
25
|
+
button.primary:hover{filter:brightness(1.08)}
|
|
26
|
+
#wrap{flex:1;display:flex;min-height:0}
|
|
27
|
+
#stage{flex:1;position:relative;background:var(--paper);overflow:hidden;min-width:0}
|
|
28
|
+
#svg{position:absolute;inset:0;width:100%;height:100%;touch-action:none;cursor:grab}
|
|
29
|
+
#svg.panning{cursor:grabbing}
|
|
30
|
+
#svg path{stroke-linecap:round;stroke-linejoin:round}
|
|
31
|
+
#svg .weak{stroke:#f59e0b!important} #svg text.weak{fill:#f59e0b!important}
|
|
32
|
+
#svg .hidden{display:none}
|
|
33
|
+
#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}
|
|
34
|
+
#state.show{display:flex}
|
|
35
|
+
#state .spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--brand);border-radius:50%;animation:spin .8s linear infinite}
|
|
36
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
37
|
+
aside{width:244px;flex:none;background:var(--panel);border-left:1px solid var(--line);padding:12px;overflow:auto;display:flex;flex-direction:column;gap:14px}
|
|
38
|
+
aside h3{margin:0;font-size:10px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
|
|
39
|
+
.ck{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:6px;cursor:pointer;color:var(--text)}
|
|
40
|
+
.ck:hover{background:var(--line)}
|
|
41
|
+
.ck input{accent-color:var(--brand);cursor:pointer;margin:0}
|
|
42
|
+
.ck .ct{margin-left:auto;color:var(--mut);font-variant-numeric:tabular-nums;font-size:11px}
|
|
43
|
+
.muted{color:var(--mut);font-size:12px;line-height:1.5}
|
|
44
|
+
hr{border:0;border-top:1px solid var(--line);margin:0}
|
|
45
|
+
#zoombar{position:absolute;left:12px;bottom:12px;display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 10px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:5}
|
|
46
|
+
#zoombar input[type=range]{width:120px;padding:0;accent-color:var(--brand)}
|
|
47
|
+
#zoombar #zpct{min-width:42px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
|
|
48
|
+
#zoombar .ghost{height:24px;padding:0 8px;background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:5px;font-size:11px}
|
|
49
|
+
#zoombar .ghost:hover{color:var(--text);border-color:var(--brand)}
|
|
50
|
+
/* Auto-save chip — same states/colors as steel-editor's #saveStat. */
|
|
51
|
+
#saveStat{color:var(--mut);font-size:12px}
|
|
52
|
+
#saveStat.dirty{color:#fbbf24} #saveStat.err{color:#f87171}
|
|
53
|
+
/* Weak-trace review list (inner scroll box — inherits the themed scrollbar rules above). */
|
|
54
|
+
#weakList{max-height:280px;overflow:auto;display:flex;flex-direction:column;gap:2px;margin-top:4px}
|
|
55
|
+
.wrow{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:6px;font-size:12px}
|
|
56
|
+
.wrow:hover{background:var(--line)}
|
|
57
|
+
.wrow .lbl{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
|
|
58
|
+
.wrow .pct{color:#f59e0b;font-variant-numeric:tabular-nums;font-size:11px}
|
|
59
|
+
.wrow button{height:22px;padding:0 8px;font-size:11px;border-radius:5px}
|
|
60
|
+
.wrow .ghost{background:transparent;border:1px solid var(--line);color:var(--mut)}
|
|
61
|
+
.wrow .ghost:hover{color:var(--text);border-color:var(--brand);background:transparent}
|
|
62
|
+
.wrow .danger{background:#7f1d1d;border-color:#991b1b;color:#fecaca}
|
|
63
|
+
.wrow .danger:hover{background:#991b1b}
|
|
64
|
+
.wrow.flash{background:var(--line)}
|
|
65
|
+
#svg .locfl{stroke:var(--brand)!important} #svg text.locfl{fill:var(--brand)!important}
|
|
66
|
+
/* Single-level Undo toast (styled modal pattern — never a native dialog). */
|
|
67
|
+
#toast{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);display:none;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 12px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:6;color:var(--text)}
|
|
68
|
+
#toast.show{display:flex}
|
|
69
|
+
#toast .ghost{height:24px;padding:0 8px;background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:5px;font-size:11px}
|
|
70
|
+
#toast .ghost:hover{color:var(--text);border-color:var(--brand)}
|
|
71
|
+
</style>
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<header>
|
|
75
|
+
<b>Vectorize</b>
|
|
76
|
+
<span class="stat" id="title">—</span>
|
|
77
|
+
<span class="spacer"></span>
|
|
78
|
+
<span class="stat"><b id="nEl">0</b> elements · <b id="nTx">0</b> text</span>
|
|
79
|
+
<span id="saveStat" title="Edits save to the app's contract automatically" hidden>Auto-save on</span>
|
|
80
|
+
<button class="primary" id="exportBtn" disabled>Export SVG</button>
|
|
81
|
+
</header>
|
|
82
|
+
<div id="wrap">
|
|
83
|
+
<div id="stage">
|
|
84
|
+
<svg id="svg" xmlns="http://www.w3.org/2000/svg"></svg>
|
|
85
|
+
<div id="state" class="show"><div class="spin"></div><div id="stateMsg">Loading drawing…</div></div>
|
|
86
|
+
<div id="zoombar">
|
|
87
|
+
<button class="ghost" id="fitBtn" title="Fit to view">Fit</button>
|
|
88
|
+
<input type="range" id="zoom" min="10" max="800" value="100" aria-label="Zoom">
|
|
89
|
+
<span id="zpct">100%</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div id="toast" role="status"><span id="toastMsg"></span><button class="ghost" id="undoBtn">Undo</button></div>
|
|
92
|
+
</div>
|
|
93
|
+
<aside>
|
|
94
|
+
<div>
|
|
95
|
+
<h3>Show</h3>
|
|
96
|
+
<label class="ck"><input type="checkbox" id="kLines" checked><span>Lines</span><span class="ct" id="cLines">0</span></label>
|
|
97
|
+
<label class="ck"><input type="checkbox" id="kCurves" checked><span>Curves</span><span class="ct" id="cCurves">0</span></label>
|
|
98
|
+
<label class="ck"><input type="checkbox" id="kText" checked><span>Text</span><span class="ct" id="cText">0</span></label>
|
|
99
|
+
</div>
|
|
100
|
+
<hr>
|
|
101
|
+
<div>
|
|
102
|
+
<h3>Layers</h3>
|
|
103
|
+
<div id="layers"><div class="muted">No layers in this drawing.</div></div>
|
|
104
|
+
</div>
|
|
105
|
+
<hr>
|
|
106
|
+
<div>
|
|
107
|
+
<h3>Confidence</h3>
|
|
108
|
+
<label class="ck" id="weakRow" hidden><input type="checkbox" id="weakOnly"><span>Isolate weak traces</span><span class="ct" id="cWeak">0</span></label>
|
|
109
|
+
<div class="muted" id="revLine" hidden>Reviewed 0/0</div>
|
|
110
|
+
<div id="weakList" hidden></div>
|
|
111
|
+
<div class="muted" id="weakNote">No weak traces — traced exactly from the vector PDF (no AI involved).</div>
|
|
112
|
+
</div>
|
|
113
|
+
</aside>
|
|
114
|
+
</div>
|
|
115
|
+
<script>
|
|
116
|
+
(function () {
|
|
117
|
+
'use strict';
|
|
118
|
+
const $ = (id) => document.getElementById(id);
|
|
119
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
120
|
+
const svg = $('svg');
|
|
121
|
+
const params = new URLSearchParams(location.search);
|
|
122
|
+
const app = params.get('app');
|
|
123
|
+
const src = params.get('src') || (app ? ('/api/contract/' + encodeURIComponent(app)) : '/vector-example.json');
|
|
124
|
+
const WEAK = 0.5;
|
|
125
|
+
|
|
126
|
+
let sheet = null;
|
|
127
|
+
let view = { x: 0, y: 0, w: 100, h: 100 };
|
|
128
|
+
let C = null; // the full contract (PUT body) — `sheet` is a reference into it
|
|
129
|
+
// Edits are only possible against the app's live contract store; a static ?src= view stays read-only.
|
|
130
|
+
const editable = !!app && src.indexOf('/api/contract/') === 0;
|
|
131
|
+
let initialWeak = 0; // M in "Reviewed N/M" — the weak count when the drawing loaded
|
|
132
|
+
let lastDeleted = null; // single-level undo: { el, i } of the last deleted trace
|
|
133
|
+
let toastT = 0;
|
|
134
|
+
|
|
135
|
+
function showState(msg) { $('stateMsg').textContent = msg; $('state').classList.add('show'); $('state').querySelector('.spin').style.display = msg ? 'none' : ''; }
|
|
136
|
+
function hideState() { $('state').classList.remove('show'); }
|
|
137
|
+
|
|
138
|
+
// Build an SVG polyline path from explicit points (a line/polyline carrying pts but no d).
|
|
139
|
+
function ptsPath(pts) {
|
|
140
|
+
if (!pts || pts.length < 2) return '';
|
|
141
|
+
return pts.map((p, i) => (i === 0 ? 'M' : 'L') + (+p[0] || 0) + ' ' + (+p[1] || 0)).join(' ');
|
|
142
|
+
}
|
|
143
|
+
// Build one element as a real SVG DOM node (safe — setAttribute / textContent, no innerHTML).
|
|
144
|
+
function makeNode(el) {
|
|
145
|
+
const weak = (el.confidence != null && el.confidence < WEAK);
|
|
146
|
+
if (el.kind === 'text') {
|
|
147
|
+
if (!el.text) return null;
|
|
148
|
+
const ox = (el.origin && el.origin[0] != null) ? el.origin[0] : (el.bbox ? el.bbox[0] : 0);
|
|
149
|
+
const oy = (el.origin && el.origin[1] != null) ? el.origin[1] : (el.bbox ? (el.bbox[3] != null ? el.bbox[3] : el.bbox[1]) : 0);
|
|
150
|
+
const t = document.createElementNS(SVGNS, 'text');
|
|
151
|
+
t.setAttribute('x', ox); t.setAttribute('y', oy);
|
|
152
|
+
t.setAttribute('font-size', el.size != null ? el.size : 4);
|
|
153
|
+
t.setAttribute('fill', el.color || '#334155');
|
|
154
|
+
if (el.angle) t.setAttribute('transform', 'rotate(' + el.angle + ' ' + ox + ' ' + oy + ')');
|
|
155
|
+
t.textContent = el.text;
|
|
156
|
+
tag(t, el, 'text', weak);
|
|
157
|
+
return t;
|
|
158
|
+
}
|
|
159
|
+
const d = el.d || ptsPath(el.pts);
|
|
160
|
+
if (!d) return null;
|
|
161
|
+
const p = document.createElementNS(SVGNS, 'path');
|
|
162
|
+
p.setAttribute('d', d);
|
|
163
|
+
p.setAttribute('fill', 'none');
|
|
164
|
+
p.setAttribute('stroke', el.color || '#334155');
|
|
165
|
+
p.setAttribute('stroke-width', el.w || 1);
|
|
166
|
+
p.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
167
|
+
if (el.dashed) p.setAttribute('stroke-dasharray', '3 2');
|
|
168
|
+
tag(p, el, (el.kind === 'line' || el.kind === 'polyline') ? 'lines' : 'curves', weak);
|
|
169
|
+
return p;
|
|
170
|
+
}
|
|
171
|
+
function tag(node, el, group, weak) {
|
|
172
|
+
node.setAttribute('class', 'el' + (weak ? ' weak' : ''));
|
|
173
|
+
node.dataset.kind = el.kind;
|
|
174
|
+
node.dataset.group = group;
|
|
175
|
+
node.dataset.layer = el.layer || '';
|
|
176
|
+
node.dataset.id = el.id || '';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function render() {
|
|
180
|
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
181
|
+
const frag = document.createDocumentFragment();
|
|
182
|
+
for (const el of sheet.elements) { const n = makeNode(el); if (n) frag.appendChild(n); }
|
|
183
|
+
svg.appendChild(frag);
|
|
184
|
+
applyVisibility();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function counts() {
|
|
188
|
+
let lines = 0, curves = 0, text = 0, weak = 0;
|
|
189
|
+
for (const el of sheet.elements) {
|
|
190
|
+
if (el.kind === 'text') text++;
|
|
191
|
+
else if (el.kind === 'line' || el.kind === 'polyline') lines++;
|
|
192
|
+
else curves++;
|
|
193
|
+
if (el.confidence != null && el.confidence < WEAK) weak++;
|
|
194
|
+
}
|
|
195
|
+
return { lines, curves, text, weak };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function applyVisibility() {
|
|
199
|
+
const showLines = $('kLines').checked, showCurves = $('kCurves').checked, showText = $('kText').checked;
|
|
200
|
+
const off = new Set([...document.querySelectorAll('#layers input:not(:checked)')].map((i) => i.dataset.layer));
|
|
201
|
+
const weakOnly = $('weakOnly').checked;
|
|
202
|
+
for (const node of svg.children) {
|
|
203
|
+
const g = node.dataset.group;
|
|
204
|
+
let vis = g === 'text' ? showText : g === 'lines' ? showLines : showCurves;
|
|
205
|
+
if (vis && node.dataset.layer && off.has(node.dataset.layer)) vis = false;
|
|
206
|
+
if (vis && weakOnly) vis = node.classList.contains('weak');
|
|
207
|
+
node.classList.toggle('hidden', !vis);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pageW() { return (sheet.page && sheet.page.w) || 100; }
|
|
212
|
+
function pageH() { return (sheet.page && sheet.page.h) || 100; }
|
|
213
|
+
function setViewBox() { svg.setAttribute('viewBox', view.x + ' ' + view.y + ' ' + view.w + ' ' + view.h); }
|
|
214
|
+
function baseScale() { const r = svg.getBoundingClientRect(); return Math.min(r.width / (pageW() * 1.08), r.height / (pageH() * 1.08)) || 1; }
|
|
215
|
+
function curScale() { const r = svg.getBoundingClientRect(); return (r.width / view.w) || 1; }
|
|
216
|
+
function syncZoom() { const pct = Math.round((curScale() / baseScale()) * 100); $('zoom').value = Math.max(10, Math.min(800, pct)); $('zpct').textContent = pct + '%'; }
|
|
217
|
+
function fit() { const m = Math.max(pageW(), pageH()) * 0.04; view = { x: -m, y: -m, w: pageW() + 2 * m, h: pageH() + 2 * m }; setViewBox(); syncZoom(); }
|
|
218
|
+
|
|
219
|
+
svg.addEventListener('wheel', (e) => {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
const r = svg.getBoundingClientRect();
|
|
222
|
+
const px = view.x + ((e.clientX - r.left) / r.width) * view.w;
|
|
223
|
+
const py = view.y + ((e.clientY - r.top) / r.height) * view.h;
|
|
224
|
+
const f = e.deltaY < 0 ? 0.9 : 1.1;
|
|
225
|
+
view.w *= f; view.h *= f;
|
|
226
|
+
view.x = px - ((e.clientX - r.left) / r.width) * view.w;
|
|
227
|
+
view.y = py - ((e.clientY - r.top) / r.height) * view.h;
|
|
228
|
+
setViewBox(); syncZoom();
|
|
229
|
+
}, { passive: false });
|
|
230
|
+
|
|
231
|
+
let pan = null;
|
|
232
|
+
svg.addEventListener('pointerdown', (e) => { pan = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y }; svg.setPointerCapture(e.pointerId); svg.classList.add('panning'); });
|
|
233
|
+
svg.addEventListener('pointermove', (e) => {
|
|
234
|
+
if (!pan) return;
|
|
235
|
+
const r = svg.getBoundingClientRect();
|
|
236
|
+
view.x = pan.vx - (e.clientX - pan.x) * (view.w / r.width);
|
|
237
|
+
view.y = pan.vy - (e.clientY - pan.y) * (view.h / r.height);
|
|
238
|
+
setViewBox();
|
|
239
|
+
});
|
|
240
|
+
const endPan = () => { pan = null; svg.classList.remove('panning'); };
|
|
241
|
+
svg.addEventListener('pointerup', endPan); svg.addEventListener('pointercancel', endPan);
|
|
242
|
+
|
|
243
|
+
$('zoom').addEventListener('input', (e) => {
|
|
244
|
+
const pct = +e.target.value, target = baseScale() * (pct / 100), r = svg.getBoundingClientRect();
|
|
245
|
+
const cx = view.x + view.w / 2, cy = view.y + view.h / 2;
|
|
246
|
+
view.w = r.width / target; view.h = r.height / target; view.x = cx - view.w / 2; view.y = cy - view.h / 2;
|
|
247
|
+
setViewBox(); $('zpct').textContent = pct + '%';
|
|
248
|
+
});
|
|
249
|
+
$('fitBtn').addEventListener('click', fit);
|
|
250
|
+
['kLines', 'kCurves', 'kText', 'weakOnly'].forEach((id) => $(id).addEventListener('change', applyVisibility));
|
|
251
|
+
|
|
252
|
+
// Export the visible drawing as a standalone SVG — clone the live nodes and serialize (no string HTML).
|
|
253
|
+
$('exportBtn').addEventListener('click', () => {
|
|
254
|
+
if (!sheet) return; // nothing loaded yet — guard against a null-sheet deref
|
|
255
|
+
const out = document.createElementNS(SVGNS, 'svg');
|
|
256
|
+
out.setAttribute('xmlns', SVGNS);
|
|
257
|
+
out.setAttribute('viewBox', '0 0 ' + pageW() + ' ' + pageH());
|
|
258
|
+
out.setAttribute('width', pageW()); out.setAttribute('height', pageH());
|
|
259
|
+
for (const node of svg.children) {
|
|
260
|
+
if (node.classList.contains('hidden')) continue;
|
|
261
|
+
const c = node.cloneNode(true);
|
|
262
|
+
c.removeAttribute('class');
|
|
263
|
+
out.appendChild(c);
|
|
264
|
+
}
|
|
265
|
+
const doc = new XMLSerializer().serializeToString(out);
|
|
266
|
+
const blob = new Blob([doc], { type: 'image/svg+xml' });
|
|
267
|
+
const a = document.createElement('a');
|
|
268
|
+
a.href = URL.createObjectURL(blob);
|
|
269
|
+
a.download = String((sheet.label || 'drawing')).replace(/[^a-z0-9._-]+/gi, '-') + '.svg';
|
|
270
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
271
|
+
URL.revokeObjectURL(a.href);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
function buildLayers() {
|
|
275
|
+
const box = $('layers');
|
|
276
|
+
while (box.firstChild) box.removeChild(box.firstChild);
|
|
277
|
+
const layers = (sheet.layers || []).filter((l) => l && l.name);
|
|
278
|
+
if (!layers.length) {
|
|
279
|
+
const d = document.createElement('div'); d.className = 'muted'; d.textContent = 'No layers in this drawing.'; box.appendChild(d); return;
|
|
280
|
+
}
|
|
281
|
+
for (const l of layers) {
|
|
282
|
+
const lab = document.createElement('label'); lab.className = 'ck';
|
|
283
|
+
const inp = document.createElement('input'); inp.type = 'checkbox'; inp.checked = true; inp.dataset.layer = l.name;
|
|
284
|
+
inp.addEventListener('change', applyVisibility);
|
|
285
|
+
const nm = document.createElement('span'); nm.textContent = l.name;
|
|
286
|
+
const ct = document.createElement('span'); ct.className = 'ct'; ct.textContent = String(l.count || 0);
|
|
287
|
+
lab.append(inp, nm, ct); box.appendChild(lab);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── auto-save (mirrors steel-editor: debounce-chip + flushContract for the shell's Approve) ──
|
|
292
|
+
let saveT = 0;
|
|
293
|
+
function setSaved(state) {
|
|
294
|
+
if (!editable) return;
|
|
295
|
+
const el = $('saveStat'); el.hidden = false; el.classList.remove('dirty', 'err');
|
|
296
|
+
if (state === 'dirty') { el.classList.add('dirty'); el.textContent = 'Saving…'; }
|
|
297
|
+
else if (state === 'err') { el.classList.add('err'); el.textContent = 'Save failed'; }
|
|
298
|
+
else el.textContent = 'Saved';
|
|
299
|
+
}
|
|
300
|
+
// PUT the full contract as the server-side draft — the copy Approve bakes. Saves are
|
|
301
|
+
// SERIALIZED through a generation-checked queue: overlapping PUTs could land out of order
|
|
302
|
+
// (last-write-wins on the server), letting an older in-flight snapshot overwrite a newer one
|
|
303
|
+
// right before Approve. Each queued write stringifies C at send time, and a write that has
|
|
304
|
+
// been superseded by a newer request skips itself — so the queue's tail always leaves the
|
|
305
|
+
// LATEST state on the server. Returns the queue tail (ok boolean).
|
|
306
|
+
let saveGen = 0;
|
|
307
|
+
let saveQueue = Promise.resolve(true);
|
|
308
|
+
async function doPut() {
|
|
309
|
+
try {
|
|
310
|
+
const res = await fetch('/api/contract/' + encodeURIComponent(app), {
|
|
311
|
+
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C),
|
|
312
|
+
});
|
|
313
|
+
setSaved(res.ok ? 'ok' : 'err');
|
|
314
|
+
if (!res.ok) console.error('server save rejected (' + res.status + ')', await res.text().catch(() => ''));
|
|
315
|
+
return res.ok;
|
|
316
|
+
} catch (e) { setSaved('err'); console.error('server save failed', e); return false; }
|
|
317
|
+
}
|
|
318
|
+
function persistServer() {
|
|
319
|
+
if (!editable || !C) return Promise.resolve(true);
|
|
320
|
+
const gen = ++saveGen;
|
|
321
|
+
saveQueue = saveQueue.then(() => (gen === saveGen ? doPut() : true)); // superseded → the newer write carries C
|
|
322
|
+
return saveQueue;
|
|
323
|
+
}
|
|
324
|
+
function scheduleSave() { if (!editable) return; setSaved('dirty'); clearTimeout(saveT); saveT = setTimeout(persistServer, 500); }
|
|
325
|
+
// The shell's Approve button awaits this before baking — throw so a rejected save aborts the bake.
|
|
326
|
+
window.flushContract = async function flushContract() {
|
|
327
|
+
if (!editable) return;
|
|
328
|
+
clearTimeout(saveT);
|
|
329
|
+
const ok = await persistServer(); // queued behind any in-flight write → latest state, in order
|
|
330
|
+
if (!ok) throw new Error('Save failed — the edited contract was rejected; fix it before Approve');
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// ── weak-trace review: Accept clears the flag (absent = trusted), Delete removes with 1-level Undo ──
|
|
334
|
+
function isWeak(el) { return el.confidence != null && el.confidence < WEAK; }
|
|
335
|
+
function elLabel(el) {
|
|
336
|
+
if (el.kind === 'text') return '“' + (el.text || '') + '”';
|
|
337
|
+
return (el.kind || 'trace') + ' · ' + (el.id || '?');
|
|
338
|
+
}
|
|
339
|
+
function acceptEl(id) {
|
|
340
|
+
const el = sheet.elements.find((e) => e.id === id);
|
|
341
|
+
if (!el) return;
|
|
342
|
+
delete el.confidence; // schema: absent = trusted — cleaner than a magic 1.0
|
|
343
|
+
afterEdit();
|
|
344
|
+
}
|
|
345
|
+
function deleteEl(id) {
|
|
346
|
+
const i = sheet.elements.findIndex((e) => e.id === id);
|
|
347
|
+
if (i < 0) return;
|
|
348
|
+
// Prune the id from any group memberships too — a dangling groups[].elementIds reference
|
|
349
|
+
// would survive PUT/Approve (the schema only checks it's a string array). Remember each
|
|
350
|
+
// pruned spot so Undo restores memberships along with the element.
|
|
351
|
+
const memberships = [];
|
|
352
|
+
for (const g of sheet.groups || []) {
|
|
353
|
+
const gi = (g.elementIds || []).indexOf(id);
|
|
354
|
+
if (gi >= 0) { memberships.push({ g, gi }); g.elementIds.splice(gi, 1); }
|
|
355
|
+
}
|
|
356
|
+
lastDeleted = { el: sheet.elements[i], i, memberships };
|
|
357
|
+
sheet.elements.splice(i, 1);
|
|
358
|
+
showToast('Trace deleted');
|
|
359
|
+
afterEdit();
|
|
360
|
+
}
|
|
361
|
+
function undoDelete() {
|
|
362
|
+
if (!lastDeleted) return;
|
|
363
|
+
sheet.elements.splice(Math.min(lastDeleted.i, sheet.elements.length), 0, lastDeleted.el);
|
|
364
|
+
for (const m of lastDeleted.memberships) m.g.elementIds.splice(Math.min(m.gi, m.g.elementIds.length), 0, lastDeleted.el.id);
|
|
365
|
+
lastDeleted = null;
|
|
366
|
+
hideToast();
|
|
367
|
+
afterEdit();
|
|
368
|
+
}
|
|
369
|
+
function showToast(msg) {
|
|
370
|
+
$('toastMsg').textContent = msg;
|
|
371
|
+
$('toast').classList.add('show');
|
|
372
|
+
clearTimeout(toastT); toastT = setTimeout(hideToast, 6000);
|
|
373
|
+
}
|
|
374
|
+
function hideToast() { $('toast').classList.remove('show'); clearTimeout(toastT); }
|
|
375
|
+
$('undoBtn').addEventListener('click', undoDelete);
|
|
376
|
+
|
|
377
|
+
function buildWeakList() {
|
|
378
|
+
const box = $('weakList');
|
|
379
|
+
while (box.firstChild) box.removeChild(box.firstChild);
|
|
380
|
+
const weak = editable ? sheet.elements.filter(isWeak) : [];
|
|
381
|
+
box.hidden = weak.length === 0;
|
|
382
|
+
for (const el of weak) {
|
|
383
|
+
const row = document.createElement('div'); row.className = 'wrow'; row.dataset.id = el.id || '';
|
|
384
|
+
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = elLabel(el);
|
|
385
|
+
lbl.title = 'Show this trace on the drawing';
|
|
386
|
+
lbl.addEventListener('click', () => locateOnCanvas(el.id));
|
|
387
|
+
const pct = document.createElement('span'); pct.className = 'pct'; pct.textContent = Math.round((el.confidence || 0) * 100) + '%';
|
|
388
|
+
const ok = document.createElement('button'); ok.className = 'ghost'; ok.textContent = 'Accept';
|
|
389
|
+
ok.title = 'Mark this trace as correct — clears its low-confidence flag.';
|
|
390
|
+
ok.addEventListener('click', () => acceptEl(el.id));
|
|
391
|
+
const del = document.createElement('button'); del.className = 'danger'; del.textContent = 'Delete';
|
|
392
|
+
del.title = 'Remove this trace from the drawing (Undo available).';
|
|
393
|
+
del.addEventListener('click', () => deleteEl(el.id));
|
|
394
|
+
row.append(lbl, pct, ok, del); box.appendChild(row);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Locate: list row → flash the trace on the canvas; canvas click on a weak trace → flash its row.
|
|
398
|
+
function locateOnCanvas(id) {
|
|
399
|
+
const node = [...svg.children].find((n) => n.dataset.id === id);
|
|
400
|
+
if (!node) return;
|
|
401
|
+
node.classList.add('locfl');
|
|
402
|
+
setTimeout(() => node.classList.remove('locfl'), 1200);
|
|
403
|
+
}
|
|
404
|
+
svg.addEventListener('click', (e) => {
|
|
405
|
+
const id = e.target && e.target.dataset && e.target.dataset.id;
|
|
406
|
+
if (!id || !e.target.classList.contains('weak')) return;
|
|
407
|
+
const row = [...document.querySelectorAll('#weakList .wrow')].find((r) => r.dataset.id === id);
|
|
408
|
+
if (!row) return;
|
|
409
|
+
row.scrollIntoView({ block: 'nearest' });
|
|
410
|
+
row.classList.add('flash');
|
|
411
|
+
setTimeout(() => row.classList.remove('flash'), 1200);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// Header stats + Confidence section (counts, note wording, Reviewed N/M) — rerun after every edit.
|
|
415
|
+
function refreshStats() {
|
|
416
|
+
const c = counts();
|
|
417
|
+
$('nEl').textContent = sheet.elements.length; $('nTx').textContent = c.text;
|
|
418
|
+
$('cLines').textContent = c.lines; $('cCurves').textContent = c.curves; $('cText').textContent = c.text;
|
|
419
|
+
$('cWeak').textContent = c.weak;
|
|
420
|
+
// The zero-weak wording must match HOW the drawing was read: after a vision read the honest
|
|
421
|
+
// message is "scored above the bar, still spot-check", never the exact-extraction claim
|
|
422
|
+
// (the extractor field is the provenance; absent/non-vision = the no-AI deterministic path).
|
|
423
|
+
const visionRead = /^vision/.test((C && C.source && C.source.extractor) || '');
|
|
424
|
+
$('weakNote').textContent = c.weak
|
|
425
|
+
? (c.weak + ' trace' + (c.weak === 1 ? '' : 's') + ' scored below ' + WEAK + ' confidence.')
|
|
426
|
+
: (visionRead ? 'No weak traces — every element scored above ' + WEAK + '. Spot-check against the original.' : 'No weak traces — traced exactly from the vector PDF (no AI involved).');
|
|
427
|
+
$('weakRow').hidden = c.weak === 0; // hide the filter when there's nothing to filter (not disabled)
|
|
428
|
+
if (c.weak === 0) $('weakOnly').checked = false; // nothing left to isolate — release the filter
|
|
429
|
+
// Reviewed N/M — M is the weak count at load; Accept and Delete both count as reviewed.
|
|
430
|
+
const reviewed = Math.max(0, initialWeak - c.weak);
|
|
431
|
+
$('revLine').hidden = !editable || initialWeak === 0;
|
|
432
|
+
$('revLine').textContent = c.weak === 0 && initialWeak > 0
|
|
433
|
+
? 'All ' + initialWeak + ' reviewed'
|
|
434
|
+
: 'Reviewed ' + reviewed + '/' + initialWeak;
|
|
435
|
+
}
|
|
436
|
+
function afterEdit() { refreshStats(); render(); buildWeakList(); scheduleSave(); }
|
|
437
|
+
|
|
438
|
+
function load(contract) {
|
|
439
|
+
if (!contract || !Array.isArray(contract.sheets) || !contract.sheets.length) { showState('This app has no vectorized drawing yet. Drop a PDF and re-bake to read one.'); return; }
|
|
440
|
+
C = contract;
|
|
441
|
+
sheet = contract.sheets[contract.active || 0] || contract.sheets[0];
|
|
442
|
+
if (!sheet || !Array.isArray(sheet.elements)) { showState('The drawing has no elements.'); return; }
|
|
443
|
+
initialWeak = sheet.elements.filter(isWeak).length;
|
|
444
|
+
const name = (contract.source && contract.source.name) ? contract.source.name : (sheet.label || 'drawing');
|
|
445
|
+
$('title').textContent = name;
|
|
446
|
+
document.title = 'Vectorize — ' + name;
|
|
447
|
+
if (editable) setSaved('ok');
|
|
448
|
+
refreshStats();
|
|
449
|
+
buildLayers();
|
|
450
|
+
buildWeakList();
|
|
451
|
+
render(); fit(); $('exportBtn').disabled = false; hideState();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const ctrl = new AbortController();
|
|
455
|
+
const timer = setTimeout(() => ctrl.abort(), 15000);
|
|
456
|
+
fetch(src, { headers: { accept: 'application/json' }, signal: ctrl.signal })
|
|
457
|
+
.then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
458
|
+
.then(load)
|
|
459
|
+
.catch((e) => showState(e.name === 'AbortError' ? 'Loading timed out — is the server reachable?' : ('Could not load the drawing (' + e.message + ').')))
|
|
460
|
+
.finally(() => clearTimeout(timer));
|
|
461
|
+
|
|
462
|
+
window.addEventListener('resize', () => { if (sheet) syncZoom(); });
|
|
463
|
+
})();
|
|
464
|
+
</script>
|
|
465
|
+
</body>
|
|
466
|
+
</html>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "drawing.vector/v1",
|
|
3
|
+
"units": "mm",
|
|
4
|
+
"source": {
|
|
5
|
+
"name": "vectorize-detail.pdf",
|
|
6
|
+
"kind": "pdf",
|
|
7
|
+
"extractor": "pymupdf@compose-time"
|
|
8
|
+
},
|
|
9
|
+
"sheets": [
|
|
10
|
+
{
|
|
11
|
+
"id": "s1",
|
|
12
|
+
"label": "page 1",
|
|
13
|
+
"page": {
|
|
14
|
+
"w": 300,
|
|
15
|
+
"h": 220
|
|
16
|
+
},
|
|
17
|
+
"elements": [
|
|
18
|
+
{
|
|
19
|
+
"kind": "polyline",
|
|
20
|
+
"d": "M90.0 70.0 H210.0 V170.0 H90.0 Z",
|
|
21
|
+
"color": "#000000",
|
|
22
|
+
"w": 1.4,
|
|
23
|
+
"id": "e1"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"kind": "polyline",
|
|
27
|
+
"d": "M120.0 100.0 H180.0 V140.0 H120.0 Z",
|
|
28
|
+
"color": "#667084",
|
|
29
|
+
"w": 1,
|
|
30
|
+
"id": "e2"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"kind": "spline",
|
|
34
|
+
"d": "M100.0 85.0 C100.0 87.8 102.2 90.0 105.0 90.0 M105.0 90.0 C107.8 90.0 110.0 87.8 110.0 85.0 M110.0 85.0 C110.0 82.2 107.8 80.0 105.0 80.0 M105.0 80.0 C102.2 80.0 100.0 82.2 100.0 85.0",
|
|
35
|
+
"color": "#db2626",
|
|
36
|
+
"w": 1,
|
|
37
|
+
"id": "e3"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"kind": "spline",
|
|
41
|
+
"d": "M190.0 85.0 C190.0 87.8 192.2 90.0 195.0 90.0 M195.0 90.0 C197.8 90.0 200.0 87.8 200.0 85.0 M200.0 85.0 C200.0 82.2 197.8 80.0 195.0 80.0 M195.0 80.0 C192.2 80.0 190.0 82.2 190.0 85.0",
|
|
42
|
+
"color": "#db2626",
|
|
43
|
+
"w": 1,
|
|
44
|
+
"id": "e4"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"kind": "spline",
|
|
48
|
+
"d": "M100.0 155.0 C100.0 157.8 102.2 160.0 105.0 160.0 M105.0 160.0 C107.8 160.0 110.0 157.8 110.0 155.0 M110.0 155.0 C110.0 152.2 107.8 150.0 105.0 150.0 M105.0 150.0 C102.2 150.0 100.0 152.2 100.0 155.0",
|
|
49
|
+
"color": "#db2626",
|
|
50
|
+
"w": 1,
|
|
51
|
+
"id": "e5"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"kind": "spline",
|
|
55
|
+
"d": "M190.0 155.0 C190.0 157.8 192.2 160.0 195.0 160.0 M195.0 160.0 C197.8 160.0 200.0 157.8 200.0 155.0 M200.0 155.0 C200.0 152.2 197.8 150.0 195.0 150.0 M195.0 150.0 C192.2 150.0 190.0 152.2 190.0 155.0",
|
|
56
|
+
"color": "#db2626",
|
|
57
|
+
"w": 1,
|
|
58
|
+
"id": "e6"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"kind": "line",
|
|
62
|
+
"d": "M210.0 70.0 L250.0 55.0",
|
|
63
|
+
"color": "#667084",
|
|
64
|
+
"w": 0.8,
|
|
65
|
+
"id": "e7"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"kind": "text",
|
|
69
|
+
"text": "BASE PLATE PL1/2",
|
|
70
|
+
"bbox": [
|
|
71
|
+
96,
|
|
72
|
+
50.3,
|
|
73
|
+
177,
|
|
74
|
+
62.7
|
|
75
|
+
],
|
|
76
|
+
"origin": [
|
|
77
|
+
96,
|
|
78
|
+
60
|
|
79
|
+
],
|
|
80
|
+
"size": 9,
|
|
81
|
+
"color": "#000000",
|
|
82
|
+
"font": "Helvetica",
|
|
83
|
+
"id": "e8"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"kind": "text",
|
|
87
|
+
"text": "4x Ø22",
|
|
88
|
+
"bbox": [
|
|
89
|
+
214,
|
|
90
|
+
43.4,
|
|
91
|
+
239.8,
|
|
92
|
+
54.4
|
|
93
|
+
],
|
|
94
|
+
"origin": [
|
|
95
|
+
214,
|
|
96
|
+
52
|
|
97
|
+
],
|
|
98
|
+
"size": 8,
|
|
99
|
+
"color": "#db2626",
|
|
100
|
+
"font": "Helvetica",
|
|
101
|
+
"id": "e9"
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
"layers": []
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
}
|