@floless/app 0.31.4 → 0.32.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
CHANGED
|
@@ -52835,7 +52835,7 @@ function appVersion() {
|
|
|
52835
52835
|
return resolveVersion({
|
|
52836
52836
|
isSea: isSea2(),
|
|
52837
52837
|
sqVersionXml: readSqVersionXml(),
|
|
52838
|
-
define: true ? "0.
|
|
52838
|
+
define: true ? "0.32.0" : void 0,
|
|
52839
52839
|
pkgVersion: readPkgVersion()
|
|
52840
52840
|
});
|
|
52841
52841
|
}
|
|
@@ -52845,7 +52845,7 @@ function resolveChannel(s) {
|
|
|
52845
52845
|
return "dev";
|
|
52846
52846
|
}
|
|
52847
52847
|
function appChannel() {
|
|
52848
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
52848
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.32.0" : void 0 });
|
|
52849
52849
|
}
|
|
52850
52850
|
|
|
52851
52851
|
// oauth-presets.ts
|
|
@@ -53698,6 +53698,24 @@ function writeContract(appId, doc2) {
|
|
|
53698
53698
|
(0, import_node_fs16.writeFileSync)(p, JSON.stringify(doc2));
|
|
53699
53699
|
}
|
|
53700
53700
|
|
|
53701
|
+
// contract-resolve.ts
|
|
53702
|
+
function readContractForApp(appId, readAppFn = readApp) {
|
|
53703
|
+
const draft = readContract(appId);
|
|
53704
|
+
if (draft != null) return draft;
|
|
53705
|
+
let app;
|
|
53706
|
+
try {
|
|
53707
|
+
app = readAppFn(appId);
|
|
53708
|
+
} catch {
|
|
53709
|
+
return null;
|
|
53710
|
+
}
|
|
53711
|
+
const node = app.nodes.find((n) => n.config["contract"] === "steel.takeoff/v1");
|
|
53712
|
+
const t = node?.config["takeoff"];
|
|
53713
|
+
if (t == null || typeof t !== "object" || Array.isArray(t) || Object.keys(t).length === 0) {
|
|
53714
|
+
return null;
|
|
53715
|
+
}
|
|
53716
|
+
return t;
|
|
53717
|
+
}
|
|
53718
|
+
|
|
53701
53719
|
// contract-bake.ts
|
|
53702
53720
|
var import_node_fs17 = require("node:fs");
|
|
53703
53721
|
var import_yaml4 = __toESM(require_dist6(), 1);
|
|
@@ -55854,6 +55872,8 @@ var PRODUCT_SKILLS = [
|
|
|
55854
55872
|
// author event-driven ("on trigger") routines
|
|
55855
55873
|
"floless-app-steel-from-drawings",
|
|
55856
55874
|
// read a steel drawing into an interactive 3D model (bake the viewer-3d scene)
|
|
55875
|
+
"floless-app-steel-takeoff",
|
|
55876
|
+
// read a steel drawing into an editable steel.takeoff/v1 contract (the Steel Takeoff reader)
|
|
55857
55877
|
"floless-app-tweak-contract",
|
|
55858
55878
|
// handle a tweak-contract request from the steel-takeoff contract editor (Slice 2 AI round-trip)
|
|
55859
55879
|
"floless-app-ui",
|
|
@@ -58147,7 +58167,7 @@ async function startServer() {
|
|
|
58147
58167
|
return { ok: true, app: readApp(req.params.id) };
|
|
58148
58168
|
});
|
|
58149
58169
|
app.get("/api/contract/:appId", async (req, reply) => {
|
|
58150
|
-
const doc2 =
|
|
58170
|
+
const doc2 = readContractForApp(req.params.appId);
|
|
58151
58171
|
if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract for this app yet" });
|
|
58152
58172
|
return doc2;
|
|
58153
58173
|
});
|
|
@@ -58732,6 +58752,7 @@ async function startServer() {
|
|
|
58732
58752
|
{ bodyLimit: 25 * 1024 * 1024 },
|
|
58733
58753
|
async (req, reply) => {
|
|
58734
58754
|
const { appId, inputName, instruction, snapshots } = req.body ?? {};
|
|
58755
|
+
const sourceName = typeof req.body?.sourceName === "string" ? req.body.sourceName : void 0;
|
|
58735
58756
|
if (!appId) return reply.status(400).send({ ok: false, error: "appId required" });
|
|
58736
58757
|
let decoded;
|
|
58737
58758
|
try {
|
|
@@ -58740,7 +58761,7 @@ async function startServer() {
|
|
|
58740
58761
|
return reply.status(400).send({ ok: false, error: e instanceof Error ? e.message : "bad snapshot" });
|
|
58741
58762
|
}
|
|
58742
58763
|
const request = addRequest(
|
|
58743
|
-
{ type: "rebake", appId, ...inputName ? { inputName } : {}, instruction: instruction || REBAKE_INSTRUCTION },
|
|
58764
|
+
{ type: "rebake", appId, ...inputName ? { inputName } : {}, ...sourceName ? { sourceName } : {}, instruction: instruction || REBAKE_INSTRUCTION },
|
|
58744
58765
|
decoded
|
|
58745
58766
|
);
|
|
58746
58767
|
broadcast({ type: "request-added", request });
|
|
@@ -23,6 +23,18 @@
|
|
|
23
23
|
},
|
|
24
24
|
"description": "sheet -> { detail number -> normalized [fx,fy] bubble anchor }."
|
|
25
25
|
},
|
|
26
|
+
"source": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"description": "Provenance: where this takeoff was read from.",
|
|
29
|
+
"properties": {
|
|
30
|
+
"name": { "type": "string" },
|
|
31
|
+
"path": { "type": "string" },
|
|
32
|
+
"sheet": { "type": "string" },
|
|
33
|
+
"sha256": { "type": "string" },
|
|
34
|
+
"read_at": { "type": "string" }
|
|
35
|
+
},
|
|
36
|
+
"additionalProperties": false
|
|
37
|
+
},
|
|
26
38
|
"plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } }
|
|
27
39
|
},
|
|
28
40
|
"$defs": {
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: floless-app-steel-takeoff
|
|
3
|
+
description: Read a structural steel drawing into an editable steel.takeoff/v1 contract in floless.app — the "Steel Takeoff" reader. Triggers on a pending floless `rebake` request for the `steel-takeoff` app (queued from its "Re-read & re-bake ▸" button), or asks like "read this framing plan into a takeoff", "turn this structural drawing into a takeoff", "bake my drawing into steel-takeoff", "extract members from this PDF". Teaches the host AI to READ a steel drawing (PDF/image) with its own vision at COMPOSE time, build a steel.takeoff/v1 contract (members + profiles + elevations + weights from AISC lookup), write it to the contract store, and hand control to the editor — no model in the run path, no API key.
|
|
4
|
+
metadata:
|
|
5
|
+
version: 0.1.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Steel Takeoff — read a drawing into a steel.takeoff/v1 contract
|
|
9
|
+
|
|
10
|
+
## What this is
|
|
11
|
+
|
|
12
|
+
`steel-takeoff` is a FloLess app that turns a structural steel drawing into an editable
|
|
13
|
+
**takeoff contract** — members, profiles, AISC weights, T.O. elevations, connection detail refs
|
|
14
|
+
— rendered in the overlay editor, then baked at Approve into the app's `read` node.
|
|
15
|
+
|
|
16
|
+
The drawing is read **at compose time** with your own vision. There is **no model in the run
|
|
17
|
+
path and no API key** — the brain is you, in the terminal (the FloLess thin-UI contract). The
|
|
18
|
+
deterministic run only renders the approved result.
|
|
19
|
+
|
|
20
|
+
**Downstream outputs** (on the same canvas, triggered separately) are a 3D stick model
|
|
21
|
+
(`viewer-3d`, from the Approve bake), an IFC file, a Tekla bake, and a formatted BOM — but
|
|
22
|
+
this skill's job ends at producing and handing off the takeoff contract. Mention those as "next"
|
|
23
|
+
but do not attempt them here.
|
|
24
|
+
|
|
25
|
+
> **Pasted a request?** If the user pastes a message beginning with a `[floless-request
|
|
26
|
+
> type=rebake id=…]` marker, that's a request copied from the FloLess Dashboard — resolve it
|
|
27
|
+
> via `GET /api/requests` (match the id), don't run the pasted line verbatim, and `DELETE` it
|
|
28
|
+
> when done.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## The loop
|
|
33
|
+
|
|
34
|
+
### 1. Find the drawing
|
|
35
|
+
|
|
36
|
+
From a `rebake` request: `GET http://localhost:<port>/api/requests` → the entry with
|
|
37
|
+
`type:"rebake"` and `appId:"steel-takeoff"` carries `snapshots: ["<abs path>"]` (the drawing
|
|
38
|
+
file) and optionally `sourceName` (the original filename — use it for the `source.name`
|
|
39
|
+
provenance field). If the user just attaches or points at a drawing in prose, skip the lookup
|
|
40
|
+
and proceed from that path.
|
|
41
|
+
|
|
42
|
+
### 2. Read it with your own vision — the heavy pipeline
|
|
43
|
+
|
|
44
|
+
Follow the methodology condensed here (full detail + every gotcha in
|
|
45
|
+
`docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md`):
|
|
46
|
+
|
|
47
|
+
1. **Sheet index.** If it's a multi-sheet PDF, read the index sheet (e.g. `S-100`) once with
|
|
48
|
+
vision to build a `sheet → page` map. Focus the takeoff on one or a few framing plan sheets
|
|
49
|
+
(Foundation, Floor, Roof); say which.
|
|
50
|
+
2. **Tile the framing area** in display space (e.g. 4×3 grid, render scale ~2.0). Record each
|
|
51
|
+
tile's display origin and extent.
|
|
52
|
+
3. **Geometry (exact).** `page.get_drawings()` → keep member-scale line segments (~8–45 ft) in
|
|
53
|
+
display coords, tagged H/V/D. These are the candidate members and the snap targets.
|
|
54
|
+
4. **Labels (vision — parallel subagents).** Read section labels per tile; each subagent returns
|
|
55
|
+
**normalized fractions [0..1]** (not pixel coordinates — pixels from a subagent are
|
|
56
|
+
scale-ambiguous) plus orientation (`H`/`V`/`D`). Convert to display, dedup.
|
|
57
|
+
- **Skew bays get their OWN dedicated pass — they are the most-skipped members.** A skewed/angled
|
|
58
|
+
framing bay read together with the orthogonal grid loses its diagonal members (the orthogonal
|
|
59
|
+
pass "swallows" them, and free-hand label positions don't bind to the diagonal lines).
|
|
60
|
+
- **Reliable skew method = bind to the EXACT diagonal geometry, don't free-place.** This is the
|
|
61
|
+
technique that works (proven on S-202):
|
|
62
|
+
1. From step 3, take the `D` member-scale segments (the exact diagonal geometry) and their
|
|
63
|
+
bounding box — that's the skew bay.
|
|
64
|
+
2. Render that bbox at high DPI and **draw the `D` segments over it, each NUMBERED** at its
|
|
65
|
+
midpoint (red line + index 0..N).
|
|
66
|
+
3. One vision pass over that numbered image maps **each segment index → its label** (chords are
|
|
67
|
+
typically the short collinear sub-segments = `W16X26`; the long perpendicular rungs =
|
|
68
|
+
`W21X62`/`W12X19`; `MF` where it's a moment mark). Give the reader the per-segment length to
|
|
69
|
+
disambiguate chord-vs-rung.
|
|
70
|
+
4. Build one member per `D` segment using the segment's OWN endpoints as the work-points (so the
|
|
71
|
+
member lands exactly on the drawn line), profile from the map, AISC weight via lookup.
|
|
72
|
+
5. Replace any earlier free-placed diagonal members in the bay (members whose *real* orientation
|
|
73
|
+
is diagonal) with these exact ones. Cross-check: `D`-segment count ≈ diagonal members — a
|
|
74
|
+
pile of unbound `D` segments means skew is still missed.
|
|
75
|
+
5. **Bind** each label to the nearest matching segment (within tolerance, orientation penalty on
|
|
76
|
+
mismatch). **Bind `D` labels to `D` segments** (don't let a diagonal label grab a nearby
|
|
77
|
+
orthogonal grid line). Segments that remain unbound are the coverage queue (the human's markup
|
|
78
|
+
worklist) — a pile of unbound `D` segments means skew was missed; go back to step 4.
|
|
79
|
+
6. **Work-point extend.** Extend each bound segment's drawn-short ends to the centerline
|
|
80
|
+
intersection of connecting members (line–line intersection, capped reach). This closes joints
|
|
81
|
+
and is skew-proof.
|
|
82
|
+
7. **AISC lookup for weights.** Call the `steel-detailer-us` AWARE agent's `lookup` verb for
|
|
83
|
+
each distinct profile designation → `plf` (pounds per linear foot). Build `weights: {
|
|
84
|
+
profile → plf }`. Unresolved → `rfi: true`, weight omitted. Verify the lookup is live with a
|
|
85
|
+
known section (e.g. W16X26 → 26 lb/ft) before trusting a full run — `~/.aware` rules can
|
|
86
|
+
vanish on agent re-update (see gotcha §6 of the guidance spec).
|
|
87
|
+
8. **Dedupe coincident members.** Two labels binding the same segment (a W-size + its `MF` mark,
|
|
88
|
+
or overlapping tile reads) emit coincident members that double-count the BOM. Collapse members
|
|
89
|
+
with identical work-points (order-independent, ~3px tol), keeping the most-resolved one. (The
|
|
90
|
+
editor ALSO auto-dedupes coincident members on load as a safety net — but produce a clean
|
|
91
|
+
contract here; don't lean on it.)
|
|
92
|
+
9. **Cross-sheet sources.** Frame Elevation sheets → moment-frame schedule (resolve `MF` marks),
|
|
93
|
+
level datums (Ground / Second Floor / Roof elevations). Roof framing → per-zone T.O. STEEL
|
|
94
|
+
callouts. See guidance spec §5 for what each sheet gives you.
|
|
95
|
+
|
|
96
|
+
**Hard-won gotchas (from the guidance spec § 6 — do not skip these):**
|
|
97
|
+
- **Text is outlined, not real text.** `get_text()` returns almost nothing. Everything textual
|
|
98
|
+
needs vision.
|
|
99
|
+
- **Pages may be rotated (e.g. 270°).** Work entirely in display space via
|
|
100
|
+
`page.rotation_matrix`. Never mix native/display coords — this is the single biggest source of
|
|
101
|
+
misplaced members.
|
|
102
|
+
- **Subagent reads must return normalized fractions, one pass, no sub-cropping.** Pixel coords
|
|
103
|
+
from a subagent collapse after downscaling.
|
|
104
|
+
- **`MF` / `BF` are marks, not profiles.** No AISC section until resolved from Frame Elevations
|
|
105
|
+
by grid + level. Leave as `rfi` and `mf: true` until auto-resolved or the user picks.
|
|
106
|
+
- **AISC rules can vanish.** Verify `steel-detailer-us` with a known section before a full run
|
|
107
|
+
(`W16X26 → 26 lb/ft`); if every member comes back weightless, restore `rules/aisc-shapes-v15.json`
|
|
108
|
+
into the installed agent dir from the aware-aeco repo. Seen cause: the agent was renamed
|
|
109
|
+
`steel-detailer-aisc → steel-detailer-us`, so an old lookup binary points at a dir that no longer
|
|
110
|
+
exists — make sure you're calling the current `steel-detailer-us` and its rules dir is populated.
|
|
111
|
+
- **Half-grids have no plan coordinate.** Auto-resolve only confident cases; surface the rest in
|
|
112
|
+
the `rfi` panel.
|
|
113
|
+
- **Detail callout detection misreads numbers/sheets.** Verify at high zoom before trusting
|
|
114
|
+
(both the number and the sheet can be wrong).
|
|
115
|
+
- **`(N)` = new, `(E)` = existing (often out of scope).** Read the full set for context before
|
|
116
|
+
deciding.
|
|
117
|
+
|
|
118
|
+
### 3. Honor `target_confidence`
|
|
119
|
+
|
|
120
|
+
Read the app's `target_confidence` input (default 70). The confidence score is a **deterministic
|
|
121
|
+
function of observable evidence** — not model self-report. Full method in
|
|
122
|
+
`docs/superpowers/specs/2026-06-20-steel-takeoff-confidence-report.md`; the short summary:
|
|
123
|
+
|
|
124
|
+
- **Verified (100%)** — human-confirmed.
|
|
125
|
+
- **High (≥80%)** — fully resolved from drawing evidence, no contradiction.
|
|
126
|
+
- **Medium (50–79%)** — one indirection (mark→schedule) or assumed elevation.
|
|
127
|
+
- **Low (<50%)** — duplicate group, weak bind, half-grid, conflicting sources.
|
|
128
|
+
- **RFI (0%)** — no AISC size resolved.
|
|
129
|
+
|
|
130
|
+
Aggregate confidence = average of per-element scores over the detected set (members with
|
|
131
|
+
resolved profiles + elevations; RFI members drag it down). **Loop**: re-read low-confidence
|
|
132
|
+
regions (re-tile at higher zoom, re-run AISC lookup) until aggregate ≥ `target_confidence` OR
|
|
133
|
+
the returns are clearly diminishing. Be honest if you stop below target — it's a triage guide,
|
|
134
|
+
not a hard gate. The user decides whether to Approve.
|
|
135
|
+
|
|
136
|
+
### 4. Assemble the `steel.takeoff/v1` contract
|
|
137
|
+
|
|
138
|
+
Emit a JSON object matching the schema in `schemas/steel.takeoff.v1.schema.json`. Top-level
|
|
139
|
+
required fields: `type: "steel.takeoff/v1"` (discriminator), `plans` (array of plan objects).
|
|
140
|
+
Important optional fields:
|
|
141
|
+
|
|
142
|
+
```jsonc
|
|
143
|
+
{
|
|
144
|
+
"type": "steel.takeoff/v1",
|
|
145
|
+
"source": {
|
|
146
|
+
"name": "<sourceName or basename of the drawing path>",
|
|
147
|
+
"path": "<absolute path you read>",
|
|
148
|
+
"sheet": "<e.g. S-202>",
|
|
149
|
+
"sha256": "<sha256 of the file if available>",
|
|
150
|
+
"read_at": "<ISO 8601 timestamp>"
|
|
151
|
+
},
|
|
152
|
+
"weights": { "W16X26": 26, "HSS6X6X3/8": 27.48 }, // profile → plf; null for unresolved
|
|
153
|
+
"moment_frames": [ … ], // from Frame Elevation sheets
|
|
154
|
+
"detail_bubbles": { "S-504": { "5": [0.32, 0.71] } }, // sheet → { number → [fx,fy] }
|
|
155
|
+
"plans": [
|
|
156
|
+
{
|
|
157
|
+
"sheet": "S-202",
|
|
158
|
+
"title": "Second Floor Framing Plan",
|
|
159
|
+
"clip": [x0, y0, x1, y1], // display-space crop of framing area
|
|
160
|
+
"pt_per_ft": 12.3,
|
|
161
|
+
"default_tos": 198, // decimal inches (16'-6" = 198)
|
|
162
|
+
"raster_b64": "…", // CONFIDENTIAL — machine-local, never committed
|
|
163
|
+
"segments": [ { "id": "seg-1", "a": [x,y], "b": [x,y], "o": "H" } ],
|
|
164
|
+
"labels": [ { "text": "W16X26", "disp": [x,y] } ],
|
|
165
|
+
"details": [ { "text": "5-S504", "disp": [x,y] } ],
|
|
166
|
+
"tos_callouts": [ { "elev_in": 198, "type": "TOS", "disp": [x,y] } ],
|
|
167
|
+
"members": [
|
|
168
|
+
{
|
|
169
|
+
"id": "m1",
|
|
170
|
+
"profile": "W16X26",
|
|
171
|
+
"wp": [[x0,y0],[x1,y1]],
|
|
172
|
+
"angle": "H",
|
|
173
|
+
"role": "beam",
|
|
174
|
+
"rfi": false,
|
|
175
|
+
"mf": false,
|
|
176
|
+
"ends": [
|
|
177
|
+
{ "tos": 198, "note": "moment", "tosDef": false, "detail": "5-S504" },
|
|
178
|
+
{ "tos": 198, "note": "shear", "tosDef": true, "detail": "" }
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"id": "c1",
|
|
183
|
+
"profile": "HSS6X6X3/8",
|
|
184
|
+
"wp": [[x,y0],[x,y1]],
|
|
185
|
+
"angle": "V",
|
|
186
|
+
"role": "column",
|
|
187
|
+
"rfi": false,
|
|
188
|
+
"col": { "bos": 0, "tos": 198, "note": "", "detail": "" }
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Key field rules:
|
|
197
|
+
- All `wp` / `disp` / `a` / `b` coordinates are **display space** (not native PDF coords, not
|
|
198
|
+
millimetres).
|
|
199
|
+
- Elevations are **decimal inches** (canonical); the editor accepts/displays ft-in strings.
|
|
200
|
+
`tosDef: false` means the value came from a drawing callout; `true` means it follows the plan
|
|
201
|
+
default.
|
|
202
|
+
- `rfi: true` means no AISC size resolved — excluded from the BOM. `mf: true` marks a
|
|
203
|
+
moment-frame member (persistent; survives AISC resolution).
|
|
204
|
+
- `raster_b64` is a base64 JPEG of the framing area (title block excluded). It is
|
|
205
|
+
**machine-local** and **never committed**.
|
|
206
|
+
|
|
207
|
+
### 5. Write to the contract store
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
PUT http://localhost:<port>/api/contract/steel-takeoff
|
|
211
|
+
Content-Type: application/json
|
|
212
|
+
|
|
213
|
+
<contract JSON body>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The server schema-validates the body. If it responds with `400`, read the error, fix the
|
|
217
|
+
offending fields, and re-PUT. You can read back the current contract with
|
|
218
|
+
`GET /api/contract/steel-takeoff`.
|
|
219
|
+
|
|
220
|
+
### 6. Hand off to the user
|
|
221
|
+
|
|
222
|
+
Tell the user:
|
|
223
|
+
- What you read (e.g. "read 14 beams and 6 columns on sheets S-201/S-202; 3 MF members await
|
|
224
|
+
Frame-Elevation resolution; aggregate confidence 78% — above your 70% target").
|
|
225
|
+
- **Open the editor**: double-click the `read` node (or the takeoff node) in the FloLess
|
|
226
|
+
canvas. The editor renders the overlay, the member list, the RFI panel, and the confidence
|
|
227
|
+
report so they can verify and correct.
|
|
228
|
+
- **Approve & bake lock**: when satisfied, click Approve in the editor. Approve bakes the
|
|
229
|
+
contract into the `read` node's `config.takeoff` and recompiles; the Run gate disarms until
|
|
230
|
+
approved. After approval, the 3D view and BOM nodes render from the baked contract.
|
|
231
|
+
- **Freeze** the `read` node (right-click → Freeze, or `aware app freeze`) once it's approved
|
|
232
|
+
so a future re-read won't overwrite it until they explicitly unfreeze.
|
|
233
|
+
- What's next: the 3D view (`view` node), IFC export, Tekla bake, and formatted BOM are on the
|
|
234
|
+
same canvas and follow from Approve.
|
|
235
|
+
|
|
236
|
+
### 7. Clear the request
|
|
237
|
+
|
|
238
|
+
If the trigger came from a `rebake` request:
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
DELETE http://localhost:<port>/api/requests/<id>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 8. Confidentiality
|
|
245
|
+
|
|
246
|
+
Never write client, firm, or project identifiers into files, commits, or responses. The
|
|
247
|
+
contract carries the source path in `source.path` and an embedded raster in `raster_b64` — both
|
|
248
|
+
are machine-local (stored under `~/.floless/contracts/steel-takeoff.json` by the server and
|
|
249
|
+
never committed). Clip title blocks from any embedded preview.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Guardrails
|
|
254
|
+
|
|
255
|
+
- **Compose-time only.** The drawing is read HERE, by you, then PUT to the store. Never add a
|
|
256
|
+
runtime node that reads the drawing — `aware app validate` rejects it, and it breaks
|
|
257
|
+
determinism.
|
|
258
|
+
- **Contract, not scene.** This skill produces a `steel.takeoff/v1` contract. The 3D scene
|
|
259
|
+
derives from it at Approve (`contract-to-scene`); do not manually assemble the 3D scene here.
|
|
260
|
+
- **Thin-UI contract.** The browser recorded intent and the drawing path; the brain is you, in
|
|
261
|
+
the terminal. The UI never reads the drawing and never writes the contract directly.
|
|
262
|
+
- **Honest about gaps.** Unbound segments are the human's worklist, not an error. State
|
|
263
|
+
assumptions (plan-default elevations, assumed heights, unresolved MF marks). Never fabricate
|
|
264
|
+
sizes or grid positions the drawing does not show.
|
|
265
|
+
- **Resolve the port** from the running floless.app the same way other floless-app skills do
|
|
266
|
+
(check `~/.floless/port` or the active server port).
|
package/dist/web/aware.js
CHANGED
|
@@ -1073,12 +1073,13 @@
|
|
|
1073
1073
|
});
|
|
1074
1074
|
if (!res) return;
|
|
1075
1075
|
const dataUrl = res[name];
|
|
1076
|
+
const sourceName = res[name + '__filename'] || undefined;
|
|
1076
1077
|
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) { showToast('attach a drawing to re-bake', 'warn'); return; }
|
|
1077
1078
|
try {
|
|
1078
1079
|
const r = await fetch('/api/rebake', {
|
|
1079
1080
|
method: 'POST',
|
|
1080
1081
|
headers: { 'content-type': 'application/json' },
|
|
1081
|
-
body: JSON.stringify({ appId: app.id, inputName: name, snapshots: [{ dataUrl }] }),
|
|
1082
|
+
body: JSON.stringify({ appId: app.id, inputName: name, ...(sourceName ? { sourceName } : {}), snapshots: [{ dataUrl }] }),
|
|
1082
1083
|
});
|
|
1083
1084
|
const out = await r.json().catch(() => ({ ok: false, error: `re-bake failed (${r.status})` }));
|
|
1084
1085
|
if (!out || !out.ok) { showToast((out && out.error) || 'could not queue re-bake', 'err'); return; }
|
|
@@ -1417,6 +1418,9 @@
|
|
|
1417
1418
|
Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
|
|
1418
1419
|
Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
|
|
1419
1420
|
);
|
|
1421
|
+
// Focus the iframe once it loads so its keyboard shortcuts (Ctrl+D, etc.) work without
|
|
1422
|
+
// the user first having to click inside it.
|
|
1423
|
+
$contractEditorFrame.onload = () => { try { $contractEditorFrame.contentWindow.focus(); } catch (_) {} };
|
|
1420
1424
|
$contractEditorFrame.src = r.editorUrl(appId);
|
|
1421
1425
|
$contractEditor.hidden = false;
|
|
1422
1426
|
return true;
|
|
@@ -1956,7 +1960,11 @@
|
|
|
1956
1960
|
const out = {};
|
|
1957
1961
|
$body.querySelectorAll('[data-fm]').forEach((el) => { out[el.dataset.fm] = el.value; });
|
|
1958
1962
|
imageStates.forEach((entry, name) => { out[name] = entry.state; });
|
|
1959
|
-
fileStates.forEach((entry, name) => {
|
|
1963
|
+
fileStates.forEach((entry, name) => {
|
|
1964
|
+
out[name] = entry.st.mode === 'empty' ? '' : entry.st.value;
|
|
1965
|
+
// expose filename alongside so callers can capture provenance (e.g. rebake → sourceName)
|
|
1966
|
+
out[name + '__filename'] = entry.st.name || '';
|
|
1967
|
+
});
|
|
1960
1968
|
return out;
|
|
1961
1969
|
};
|
|
1962
1970
|
const done = (result) => {
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
<title>Steel takeoff — editor</title>
|
|
3
3
|
<style>
|
|
4
4
|
:root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6}
|
|
5
|
+
/* Theme EVERY scrollbar — no native white default may leak (applies to the canvas, panels, and every modal list). */
|
|
6
|
+
*{scrollbar-width:thin;scrollbar-color:#475569 transparent}
|
|
7
|
+
*::-webkit-scrollbar{width:10px;height:10px}
|
|
8
|
+
*::-webkit-scrollbar-track{background:transparent}
|
|
9
|
+
*::-webkit-scrollbar-thumb{background:#475569;border-radius:6px;border:2px solid transparent;background-clip:content-box}
|
|
10
|
+
*::-webkit-scrollbar-thumb:hover{background:#5b6b85;background-clip:content-box}
|
|
11
|
+
*::-webkit-scrollbar-corner{background:transparent}
|
|
5
12
|
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui;height:100vh;display:flex;flex-direction:column}
|
|
6
13
|
header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line)}
|
|
7
14
|
header b{font-size:14px} .stat{color:var(--mut)} .stat b{color:var(--text)}
|
|
@@ -134,7 +141,8 @@
|
|
|
134
141
|
<span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
|
|
135
142
|
<span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
|
|
136
143
|
<span class=stat id=confStat title="Confidence report — score each AI-read element by evidence" style="display:none">Confidence <b id=confPct>—</b></span>
|
|
137
|
-
<span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span
|
|
144
|
+
<span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span>
|
|
145
|
+
<span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
|
|
138
146
|
<button id=undoB title="Undo (Ctrl+Z)">↶</button>
|
|
139
147
|
<button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
|
|
140
148
|
<button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
|
|
@@ -207,6 +215,9 @@
|
|
|
207
215
|
<script>
|
|
208
216
|
const APP_ID = new URLSearchParams(location.search).get('app') || '';
|
|
209
217
|
let C, PAL, WT;
|
|
218
|
+
// Fallback palette (colorFor indexes this array by profile order) when a contract ships no `palette`
|
|
219
|
+
// — e.g. a contract shared/committed with raster_b64 + palette stripped for confidentiality.
|
|
220
|
+
const DEFAULT_PAL=['#60a5fa','#94a3b8','#f59e0b','#34d399','#f472b6','#a78bfa','#fb7185','#22d3ee','#facc15','#4ade80'];
|
|
210
221
|
let lastLocalPut = 0; // timestamp of our own PUT — used to suppress the SSE echo
|
|
211
222
|
function showEmpty(icon, headline, bodyText, appIdText) {
|
|
212
223
|
const wrap = document.createElement('div');
|
|
@@ -231,7 +242,7 @@ async function boot() {
|
|
|
231
242
|
return;
|
|
232
243
|
}
|
|
233
244
|
C = await res.json();
|
|
234
|
-
PAL = C.palette; WT = C.weights;
|
|
245
|
+
PAL = (Array.isArray(C.palette) && C.palette.length) ? C.palette : DEFAULT_PAL; WT = C.weights || {};
|
|
235
246
|
C.custom_details = C.custom_details || {};
|
|
236
247
|
C.profile_colors = C.profile_colors || {};
|
|
237
248
|
main();
|
|
@@ -421,6 +432,12 @@ function toSvg(e){const p=svg.createSVGPoint();p.x=e.clientX;p.y=e.clientY;retur
|
|
|
421
432
|
// --- zoom + pan ---
|
|
422
433
|
const ZMIN=0.1, ZMAX=4;
|
|
423
434
|
let zoom=1; const stage=document.getElementById('stage');
|
|
435
|
+
// The editor runs in an iframe; keyboard shortcuts (Ctrl+D etc.) only reach our handler when the
|
|
436
|
+
// iframe has focus. The SVG canvas isn't focusable, so clicking it wouldn't grab focus and a real
|
|
437
|
+
// Ctrl+D would hit the browser (bookmark) instead. Make the canvas focusable + grab focus on any
|
|
438
|
+
// pointerdown so shortcuts work after the user clicks into the drawing.
|
|
439
|
+
stage.tabIndex=-1; stage.style.outline='none';
|
|
440
|
+
stage.addEventListener('pointerdown',()=>{try{stage.focus({preventScroll:true});}catch(_){}}, true);
|
|
424
441
|
function applyZoom(z,ax,ay){z=Math.max(ZMIN,Math.min(ZMAX,z));
|
|
425
442
|
const pt=(ax!=null)?toSvg({clientX:ax,clientY:ay}):null;
|
|
426
443
|
zoom=z;svg.setAttribute('width',EXTX*zoom);svg.setAttribute('height',EXTY*zoom);
|
|
@@ -437,7 +454,7 @@ function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot
|
|
|
437
454
|
function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'<','>':'>','&':'&','"':'"'}[c]));}
|
|
438
455
|
function render(){
|
|
439
456
|
if(geoMode&&selIds.size!==1){geoMode=null;document.body.classList.remove('geo');} // geo edit needs exactly one member
|
|
440
|
-
let s
|
|
457
|
+
let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
|
|
441
458
|
for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
|
|
442
459
|
for(const m of P.members){const c=colorFor(m.profile);const on=selIds.has(m.id);const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
|
|
443
460
|
s+=`<line class="member${m.rfi?' rfi':''}${on?' sel':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
|
|
@@ -574,17 +591,28 @@ function rectHit(p0,p1,r){
|
|
|
574
591
|
for(let i=0;i<4;i++) if(segSeg(p0,p1,c[i],c[(i+1)%4])) return true;
|
|
575
592
|
return false;}
|
|
576
593
|
// --- snap to existing member/segment endpoints (members lie on grids → snaps to grid intersections); Alt = off ---
|
|
577
|
-
const SNAP_PX=9; let snapPts=[];
|
|
594
|
+
const SNAP_PX=9; let snapPts=[], snapSegs=[];
|
|
578
595
|
function buildSnap(except){const ex=except instanceof Set?except:(except!=null?new Set([except]):new Set()); // id or Set of ids to skip
|
|
579
|
-
snapPts=[];
|
|
596
|
+
snapPts=[];snapSegs=[];
|
|
597
|
+
for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);snapSegs.push([m.wp[0],m.wp[1]]);}
|
|
598
|
+
for(const s of P.segments){snapPts.push(s.a,s.b);snapSegs.push([s.a,s.b]);}}
|
|
599
|
+
// nearest point lying ON another member/segment LINE (perpendicular foot, clamped to the segment) within tol
|
|
600
|
+
function nearestOnLine(x,y,tol){let bp=null,bd=tol;
|
|
601
|
+
for(const sg of snapSegs){const pr=projPt([x,y],sg[0],sg[1]);const d=Math.hypot(pr.pt[0]-x,pr.pt[1]-y);if(d<bd){bd=d;bp=pr.pt;}}
|
|
602
|
+
return bp?{x:bp[0],y:bp[1],d:bd}:null;}
|
|
580
603
|
function snap(x,y){const tol=SNAP_PX/zoom;let bp=null,bd=tol;
|
|
581
604
|
for(const q of snapPts){const d=Math.hypot(q[0]-x,q[1]-y);if(d<bd){bd=d;bp=q;}}
|
|
582
|
-
if(bp)return {x:bp[0],y:bp[1],hit:true};
|
|
583
|
-
|
|
605
|
+
if(bp)return {x:bp[0],y:bp[1],hit:true}; // 1) snap to an endpoint/grid intersection
|
|
606
|
+
const ln=nearestOnLine(x,y,tol);if(ln)return {x:ln.x,y:ln.y,hit:true}; // 2) else snap ONTO a line (not only its endpoints)
|
|
607
|
+
let sx=x,sy=y,bx=tol,by=tol; // 3) else axis-align to a nearby point
|
|
584
608
|
for(const q of snapPts){const dx=Math.abs(q[0]-x);if(dx<bx){bx=dx;sx=q[0];}const dy=Math.abs(q[1]-y);if(dy<by){by=dy;sy=q[1];}}
|
|
585
609
|
return {x:sx,y:sy,hit:false};}
|
|
586
610
|
function snapMark(x,y){let c=document.getElementById('snapMark');if(!c){c=document.createElementNS('http://www.w3.org/2000/svg','circle');c.id='snapMark';c.setAttribute('class','snapmk');svg.appendChild(c);}c.setAttribute('cx',x);c.setAttribute('cy',y);c.setAttribute('r',6/zoom);}
|
|
587
611
|
function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
|
|
612
|
+
// themed transient toast (baseline tokens — never a native alert)
|
|
613
|
+
function toast(msg){let t=document.getElementById('toast');
|
|
614
|
+
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);}
|
|
615
|
+
t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
|
|
588
616
|
// --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
|
|
589
617
|
svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
590
618
|
if(geoMode&&selIds.size===1){const id=[...selIds][0],m=byId(id);
|
|
@@ -631,9 +659,10 @@ svg.addEventListener('pointermove',e=>{
|
|
|
631
659
|
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;}
|
|
632
660
|
if(drag.type==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
|
|
633
661
|
if(e.shiftKey){if(Math.abs(dx)>=Math.abs(dy))dy=0;else dx=0;snapClear();}
|
|
634
|
-
else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so
|
|
662
|
+
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)
|
|
635
663
|
for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
|
|
636
|
-
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};}}
|
|
664
|
+
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};}}
|
|
665
|
+
const ln=nearestOnLine(nx,ny,bd);if(ln){bd=ln.d;best={q:[ln.x,ln.y],cx:ln.x-nx,cy:ln.y-ny};}} // also snap onto other lines
|
|
637
666
|
if(best){dx+=best.cx;dy+=best.cy;snapMark(best.q[0],best.q[1]);}else snapClear();}
|
|
638
667
|
else snapClear();
|
|
639
668
|
for(const it of drag.items){const m=byId(it.id);if(!m)continue;
|
|
@@ -956,9 +985,27 @@ document.addEventListener('keydown',e=>{if(!comboInput||comboPop.style.display==
|
|
|
956
985
|
e.preventDefault();e.stopPropagation();opts.forEach((o,i)=>o.classList.toggle('active',i===comboIdx));if(comboIdx>=0)opts[comboIdx].scrollIntoView({block:'nearest'});},true);
|
|
957
986
|
// --- multi-plan: switch between all steel plan views ---
|
|
958
987
|
function setPlan(i){C.active=i;P=C.plans[i];
|
|
959
|
-
|
|
988
|
+
if(!Array.isArray(P.members))P.members=[];
|
|
989
|
+
if(!Array.isArray(P.segments))P.segments=[];
|
|
990
|
+
// clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
|
|
991
|
+
// committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
|
|
992
|
+
// box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
|
|
993
|
+
let clip=P.clip;
|
|
994
|
+
if(!Array.isArray(clip)||clip.length<4){
|
|
995
|
+
const xs=[],ys=[];for(const m of P.members)for(const pt of (m.wp||[])){xs.push(pt[0]);ys.push(pt[1]);}
|
|
996
|
+
if(xs.length){const pad=Math.max(200,(Math.max(...xs)-Math.min(...xs))*0.1);
|
|
997
|
+
clip=[Math.min(...xs)-pad,Math.min(...ys)-pad,Math.max(...xs)+pad,Math.max(...ys)+pad];}
|
|
998
|
+
else clip=[0,0,1000,1000];}
|
|
999
|
+
X0=clip[0];Y0=clip[1];X1=clip[2];Y1=clip[3];FT=P.pt_per_ft||12;RB64=P.raster_b64||'';EXTX=X1-X0;EXTY=Y1-Y0;
|
|
960
1000
|
svg.setAttribute('viewBox',`${X0} ${Y0} ${X1-X0} ${Y1-Y0}`);
|
|
961
1001
|
P.members.forEach(ensureMeta);
|
|
1002
|
+
// Auto-dedupe coincident members on first load of each plan — the read can bind one segment to
|
|
1003
|
+
// two labels (a W-size + its MF mark) or overlap tile reads, producing stacked duplicates that
|
|
1004
|
+
// double-count the BOM. Collapse them automatically (keep the most-resolved copy), undoable, once
|
|
1005
|
+
// per plan. The "Duplicates" button stays for any made while editing.
|
|
1006
|
+
if(!P.deduped){P.deduped=true;const red=new Set(redundantDups());
|
|
1007
|
+
if(red.size){(P.undo=P.undo||[]).push(JSON.stringify(P.members));P.members=P.members.filter(m=>!red.has(m.id));
|
|
1008
|
+
scheduleSave();setTimeout(()=>toast('Auto-removed '+red.size+' duplicate member'+(red.size>1?'s':'')),60);}}
|
|
962
1009
|
profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
|
|
963
1010
|
undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
|
|
964
1011
|
selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
|
|
@@ -974,6 +1021,18 @@ document.getElementById('revertB').onclick=()=>{if(confirm('Discard ALL saved ed
|
|
|
974
1021
|
const _restored=restoreSaved(); // bring back this browser's saved edits before first paint
|
|
975
1022
|
setPlan(C.active==null?0:C.active);
|
|
976
1023
|
setSaved(_restored?'ok':'idle', _restored?'Restored ✓':'Auto-save on');
|
|
1024
|
+
try{stage.focus({preventScroll:true});}catch(_){} // grab keyboard focus on open so shortcuts work before the first click
|
|
1025
|
+
// --- provenance caption: show contract.source when present (read from <name> · <sheet> · <read_at>) ---
|
|
1026
|
+
(function(){
|
|
1027
|
+
const src=C.source;if(!src||typeof src!=='object')return;
|
|
1028
|
+
const parts=[];
|
|
1029
|
+
if(src.name)parts.push('read from '+src.name);
|
|
1030
|
+
if(src.sheet)parts.push(src.sheet);
|
|
1031
|
+
if(src.read_at){try{const d=new Date(src.read_at);if(!isNaN(d.getTime()))parts.push(d.toLocaleDateString());}catch{/* skip unparseable date */}}
|
|
1032
|
+
if(!parts.length)return;
|
|
1033
|
+
const el=document.getElementById('srcStat');if(!el)return;
|
|
1034
|
+
el.textContent=parts.join(' · ');el.style.display='';
|
|
1035
|
+
})();
|
|
977
1036
|
// --- Ask AI: send instruction + optional screenshots → POST /api/contract-request ---
|
|
978
1037
|
// The server records a tweak-contract request; the terminal AI picks it up async and PUTs
|
|
979
1038
|
// back a revised contract. This is NOT a chat — the status message says so honestly.
|