@floless/app 0.61.0 → 0.63.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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.61.0" : void 0,
53025
+ define: true ? "0.63.0" : void 0,
53026
53026
  pkgVersion: readPkgVersion()
53027
53027
  });
53028
53028
  }
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
53032
53032
  return "dev";
53033
53033
  }
53034
53034
  function appChannel() {
53035
- return resolveChannel({ isSea: isSea2(), define: true ? "0.61.0" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.63.0" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -54017,7 +54017,219 @@ function postProcess(contract, opts = {}) {
54017
54017
  const groups = sheet.groups ? assignIds(sheet.groups, "g") : void 0;
54018
54018
  return { ...sheet, id, elements, layers, ...groups ? { groups } : {} };
54019
54019
  });
54020
- return { ...contract, type: "drawing.vector/v1", sheets };
54020
+ const datums = contract.datums ? assignIds(contract.datums, "dt") : void 0;
54021
+ const links = contract.links ? assignIds(contract.links, "lk") : void 0;
54022
+ return {
54023
+ ...contract,
54024
+ type: "drawing.vector/v1",
54025
+ sheets,
54026
+ ...datums ? { datums } : {},
54027
+ ...links ? { links } : {}
54028
+ };
54029
+ }
54030
+
54031
+ // views-to-scene.ts
54032
+ function mapper(sheet) {
54033
+ const t = sheet.transform;
54034
+ if (!t || typeof t.scale !== "number" || !(t.scale > 0)) return null;
54035
+ const s = t.scale;
54036
+ const [ox, oy] = t.origin ?? [0, 0];
54037
+ const rot = (t.rotation ?? 0) * Math.PI / 180;
54038
+ const cos = Math.cos(rot);
54039
+ const sin = Math.sin(rot);
54040
+ return (p) => {
54041
+ const dx = p[0] - ox;
54042
+ const dy = p[1] - oy;
54043
+ const rx = dx * cos - dy * sin;
54044
+ const ry = dx * sin + dy * cos;
54045
+ return [rx * s + 0, -ry * s + 0];
54046
+ };
54047
+ }
54048
+ function uprightHorizontal(sheet) {
54049
+ const dir = sheet.view?.dir ?? "+y";
54050
+ if (dir === "+y") return { axis: "x", sign: 1 };
54051
+ if (dir === "-y") return { axis: "x", sign: -1 };
54052
+ if (dir === "+x") return { axis: "y", sign: -1 };
54053
+ if (dir === "-x") return { axis: "y", sign: 1 };
54054
+ return null;
54055
+ }
54056
+ function elevationZExtent(sheet, g) {
54057
+ const map = mapper(sheet);
54058
+ if (!map) return null;
54059
+ const pts = collectDisplayPoints(g, sheet);
54060
+ if (pts.length === 0) return null;
54061
+ let lo = Infinity;
54062
+ let hi = -Infinity;
54063
+ for (const p of pts) {
54064
+ const z = map(p)[1];
54065
+ if (z < lo) lo = z;
54066
+ if (z > hi) hi = z;
54067
+ }
54068
+ return lo <= hi ? [lo, hi] : null;
54069
+ }
54070
+ function worldInterval(sheet, g, component, sign = 1) {
54071
+ const map = mapper(sheet);
54072
+ if (!map) return null;
54073
+ const pts = collectDisplayPoints(g, sheet);
54074
+ if (pts.length === 0) return null;
54075
+ let lo = Infinity;
54076
+ let hi = -Infinity;
54077
+ for (const p of pts) {
54078
+ const v = map(p)[component] * sign;
54079
+ if (v < lo) lo = v;
54080
+ if (v > hi) hi = v;
54081
+ }
54082
+ return lo <= hi ? [lo, hi] : null;
54083
+ }
54084
+ function collectDisplayPoints(g, sheet) {
54085
+ if (g.footprint && g.footprint.length) return g.footprint;
54086
+ if (g.axis && g.axis.length === 2) return g.axis;
54087
+ const ids = new Set(g.elementIds ?? []);
54088
+ const pts = [];
54089
+ for (const el of sheet.elements ?? []) {
54090
+ if (!el.id || !ids.has(el.id)) continue;
54091
+ if (el.pts) pts.push(...el.pts);
54092
+ else if (el.bbox && el.bbox.length === 4) {
54093
+ pts.push([el.bbox[0], el.bbox[1]], [el.bbox[2], el.bbox[3]]);
54094
+ }
54095
+ }
54096
+ return pts;
54097
+ }
54098
+ function resolveDatum(v, datums, axis) {
54099
+ if (typeof v === "number") return v;
54100
+ if (typeof v === "string") {
54101
+ const d = datums.find((x) => x.id === v);
54102
+ return d && (d.axis ?? "z") === axis ? d.value : null;
54103
+ }
54104
+ return null;
54105
+ }
54106
+ var PALETTE = ["#3b82f6", "#f59e0b", "#10b981", "#8b5cf6", "#ef4444", "#14b8a6", "#eab308", "#64748b"];
54107
+ function overlapRatio(a, b) {
54108
+ const lo = Math.max(a[0], b[0]);
54109
+ const hi = Math.min(a[1], b[1]);
54110
+ if (hi <= lo) return 0;
54111
+ const shorter = Math.max(1e-9, Math.min(a[1] - a[0], b[1] - b[0]));
54112
+ return (hi - lo) / shorter;
54113
+ }
54114
+ function viewsToScene(contractInput) {
54115
+ const contract = contractInput ?? {};
54116
+ const sheets = contract.sheets ?? [];
54117
+ const datums = contract.datums ?? [];
54118
+ const links = contract.links ?? [];
54119
+ const elements = [];
54120
+ const skipped = [];
54121
+ const groupKeys = [];
54122
+ const seenKey = /* @__PURE__ */ new Set();
54123
+ const plans = sheets.filter((s) => s.view?.kind === "plan");
54124
+ const uprights = sheets.filter((s) => s.view?.kind === "elevation" || s.view?.kind === "section");
54125
+ const uprightEntities = [];
54126
+ for (const sh of uprights) {
54127
+ const hz = uprightHorizontal(sh);
54128
+ if (!hz) continue;
54129
+ for (const g of sh.groups ?? []) {
54130
+ const z = elevationZExtent(sh, g);
54131
+ const h = worldInterval(sh, g, 0, hz.sign);
54132
+ if (z && h) uprightEntities.push({ sheet: sh, g, z, h, axis: hz.axis });
54133
+ }
54134
+ }
54135
+ const linkedAway = /* @__PURE__ */ new Set();
54136
+ for (const l of links) {
54137
+ if (l.kind !== "same-entity") continue;
54138
+ linkedAway.add(`${l.a.sheet}/${l.a.id ?? ""}`);
54139
+ linkedAway.add(`${l.b.sheet}/${l.b.id ?? ""}`);
54140
+ }
54141
+ function resolveZ(planSheet, g) {
54142
+ if (g.extrude && (g.extrude.from !== void 0 || g.extrude.to !== void 0)) {
54143
+ const exFrom = resolveDatum(g.extrude.from, datums, "z");
54144
+ const exTo = resolveDatum(g.extrude.to, datums, "z");
54145
+ if (exFrom != null && exTo != null) return { z: [exFrom, exTo], how: "extrude" };
54146
+ return { error: "extrude reference unresolved \u2014 each end must be a number or the id of a z-axis datum" };
54147
+ }
54148
+ const key = { sheet: planSheet.id, id: g.id };
54149
+ for (const l of links) {
54150
+ if (l.kind !== "same-entity") continue;
54151
+ const other = l.a.sheet === key.sheet && l.a.id === key.id ? l.b : l.b.sheet === key.sheet && l.b.id === key.id ? l.a : null;
54152
+ if (!other) continue;
54153
+ const hit = uprightEntities.find((u) => u.sheet.id === other.sheet && u.g.id === other.id);
54154
+ if (hit) return { z: hit.z, how: "link" };
54155
+ }
54156
+ const mineX = worldInterval(planSheet, g, 0);
54157
+ const mineY = worldInterval(planSheet, g, 1);
54158
+ if (mineX || mineY) {
54159
+ const candidates = uprightEntities.filter((u) => !linkedAway.has(`${u.sheet.id}/${u.g.id ?? ""}`)).map((u) => {
54160
+ const mine = u.axis === "x" ? mineX : mineY;
54161
+ return { u, r: mine ? overlapRatio(mine, u.h) : 0 };
54162
+ }).filter((x) => x.r >= 0.8);
54163
+ if (candidates.length === 1) return { z: candidates[0].u.z, how: "match" };
54164
+ if (candidates.length > 1) return { ambiguous: candidates.length };
54165
+ }
54166
+ return null;
54167
+ }
54168
+ for (const plan of plans) {
54169
+ const map = mapper(plan);
54170
+ const sheetId = plan.id ?? "?";
54171
+ if (!map) {
54172
+ for (const g of plan.groups ?? []) skipped.push({ id: `${sheetId}/${g.id ?? "?"}`, reason: "sheet has no world transform (transform.scale required)" });
54173
+ continue;
54174
+ }
54175
+ for (const g of plan.groups ?? []) {
54176
+ const gid = `${sheetId}/${g.id ?? "?"}`;
54177
+ const isMember = !!(g.axis && g.section);
54178
+ const isFootprint = !!(g.footprint && g.footprint.length >= 3);
54179
+ if (!isMember && !isFootprint) continue;
54180
+ if (isMember && !(g.section.w > 0 && g.section.d > 0)) {
54181
+ skipped.push({ id: gid, reason: "invalid section \u2014 w and d must be positive world units" });
54182
+ continue;
54183
+ }
54184
+ const zr0 = resolveZ(plan, g);
54185
+ if (!zr0) {
54186
+ skipped.push({ id: gid, reason: "no Z extent \u2014 add extrude datums/values, a same-entity link, or a matching elevation entity" });
54187
+ continue;
54188
+ }
54189
+ if ("ambiguous" in zr0) {
54190
+ skipped.push({ id: gid, reason: `ambiguous Z \u2014 ${zr0.ambiguous} elevation entities share this span; add a same-entity link to pick one` });
54191
+ continue;
54192
+ }
54193
+ if ("error" in zr0) {
54194
+ skipped.push({ id: gid, reason: zr0.error });
54195
+ continue;
54196
+ }
54197
+ const zr = zr0;
54198
+ const key = g.kind || (isMember ? "member" : "footprint");
54199
+ if (!seenKey.has(key)) {
54200
+ seenKey.add(key);
54201
+ groupKeys.push(key);
54202
+ }
54203
+ if (isMember) {
54204
+ const [ax, ay] = map(g.axis[0]);
54205
+ const [bx, by] = map(g.axis[1]);
54206
+ const zFrom = zr.how === "extrude" ? zr.z[0] : (zr.z[0] + zr.z[1]) / 2;
54207
+ const zTo = zr.how === "extrude" ? zr.z[1] : zFrom;
54208
+ elements.push({
54209
+ id: g.id ?? gid,
54210
+ group: key,
54211
+ kind: "box",
54212
+ from: [ax, ay, zFrom],
54213
+ to: [bx, by, zTo],
54214
+ section: { w: g.section.w, d: g.section.d },
54215
+ meta: { sheet: sheetId, ...g.label ? { label: g.label } : {} }
54216
+ });
54217
+ } else {
54218
+ elements.push({
54219
+ id: g.id ?? gid,
54220
+ group: key,
54221
+ kind: "extrusion",
54222
+ footprint: g.footprint.map((p) => map(p)),
54223
+ from: Math.min(zr.z[0], zr.z[1]),
54224
+ to: Math.max(zr.z[0], zr.z[1]),
54225
+ meta: { sheet: sheetId, ...g.label ? { label: g.label } : {} }
54226
+ });
54227
+ }
54228
+ }
54229
+ }
54230
+ const groups = groupKeys.map((key, i) => ({ key, label: key, color: PALETTE[i % PALETTE.length] }));
54231
+ const name = contract.source && typeof contract.source.name === "string" ? String(contract.source.name) : "Reconstructed drawing";
54232
+ return { scene: { meta: { name, units: "mm", up: "z" }, groups, elements }, skipped };
54021
54233
  }
54022
54234
 
54023
54235
  // steel-joints.ts
@@ -54516,12 +54728,13 @@ function contractToScene(contractInput) {
54516
54728
  const ptPerFt = plan.pt_per_ft && plan.pt_per_ft > 0 ? plan.pt_per_ft : 1;
54517
54729
  const defaultTosMm = (plan.default_tos ?? 0) * IN_TO_MM;
54518
54730
  for (const m of plan.members ?? []) {
54519
- const dims = profileDims(m.profile);
54731
+ const explicit = m.section && typeof m.section.w === "number" && m.section.w > 0 && typeof m.section.d === "number" && m.section.d > 0 ? { w: m.section.w, d: m.section.d, approx: false } : null;
54732
+ const dims = explicit ?? profileDims(m.profile);
54520
54733
  if (!dims || !Array.isArray(m.wp) || m.wp.length < 2) {
54521
54734
  skipped.push(m.id);
54522
54735
  continue;
54523
54736
  }
54524
- const profile = (m.profile ?? "").trim().toUpperCase();
54737
+ const profile = (m.profile ?? "").trim().toUpperCase() || (explicit ? "CUSTOM" : "");
54525
54738
  if (!seenProfile.has(profile)) {
54526
54739
  seenProfile.add(profile);
54527
54740
  profileOrder.push(profile);
@@ -63865,6 +64078,15 @@ async function startServer() {
63865
64078
  async (req, reply) => {
63866
64079
  const doc = req.body && "contract" in req.body ? req.body.contract : readContract(req.params.appId);
63867
64080
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to render" });
64081
+ if (doc && typeof doc === "object" && doc.type === "drawing.vector/v1") {
64082
+ const cleaned = postProcess(doc);
64083
+ const v2 = validateContract(cleaned);
64084
+ if (!v2.valid) {
64085
+ const first = v2.errors[0];
64086
+ return reply.status(400).send({ ok: false, error: `contract failed schema validation \u2014 ${first ? `${first.path}: ${first.message}` : "invalid"}` });
64087
+ }
64088
+ return { ok: true, ...viewsToScene(cleaned) };
64089
+ }
63868
64090
  const v = validateSteelTakeoff(doc);
63869
64091
  if (!v.valid) {
63870
64092
  const first = v.errors[0];
@@ -27,6 +27,16 @@
27
27
  "type": "array",
28
28
  "items": { "$ref": "#/$defs/sheet" },
29
29
  "description": "One entry per page/view. Multi-page PDFs and image sets each become a sheet."
30
+ },
31
+ "datums": {
32
+ "type": "array",
33
+ "items": { "$ref": "#/$defs/datum" },
34
+ "description": "READER-EMITTED world reference values (levels, grid lines) harvested from elevations/sections — the Z-ladder (or X/Y grid) that places 2D views in 3D. Top-level because a datum is a WORLD fact shared by every sheet; `sheet` records provenance. Multi-view reconstruction (views-to-scene) resolves groups[].extrude datum refs against these."
35
+ },
36
+ "links": {
37
+ "type": "array",
38
+ "items": { "$ref": "#/$defs/link" },
39
+ "description": "READER-EMITTED cross-view correspondence hints: which entities in two sheets are the same object, which mark is a section cut of which region (the steel callouts[] analog). Keys are {sheet,id} composites — element/group ids are only sheet-unique."
30
40
  }
31
41
  },
32
42
  "$defs": {
@@ -57,7 +67,19 @@
57
67
  "scale": { "type": "number", "description": "World units per display unit (e.g. mm per pt). Uniform." },
58
68
  "origin": { "$ref": "#/$defs/point2", "description": "Display-space point mapped to world [0,0]." },
59
69
  "units": { "type": "string", "description": "World units this maps into (overrides the top-level default for this sheet)." },
60
- "rotation": { "type": "number", "description": "Degrees the display frame is rotated relative to world (page orientation)." }
70
+ "rotation": { "type": "number", "description": "Degrees to realign a rotated display frame with world axes. Applied by the reconstruction mapper as the standard math-positive matrix [cos -sin; sin cos] on display deltas about `origin` (display coords, Y-down), BEFORE scale and the Y-flip — i.e. content drawn rotated by R(-θ) is recovered with rotation: θ (locked by the views-to-scene rotation test)." }
71
+ },
72
+ "additionalProperties": true
73
+ },
74
+ "view": {
75
+ "type": "object",
76
+ "description": "READER-EMITTED view classification + third-axis binding — `transform` maps display->world in TWO axes but never says WHICH world axes; this does (a plan maps to X,Y at some Z; a south elevation maps to X,Z at some Y). Required for multi-view 3D reconstruction (views-to-scene); a sheet without it stays 2D-only. A wrong `dir`/`datum` silently mirrors or misplaces the model — the reader must cross-validate one known dimension per axis pair before emitting.",
77
+ "required": ["kind"],
78
+ "properties": {
79
+ "kind": { "enum": ["plan", "elevation", "section", "detail"], "description": "What this sheet IS. Only plan/elevation/section participate in reconstruction." },
80
+ "dir": { "enum": ["+z", "-z", "+x", "-x", "+y", "-y"], "description": "Observer look direction in world axes (a plan looks -z; a south elevation looks +y). Defaults: plan -z." },
81
+ "datum": { "type": "number", "description": "World offset (units) of the view plane along `dir`'s axis — plan: the projection Z; elevation/section: the plane's X or Y. Default 0." },
82
+ "depth": { "type": ["number", "null"], "description": "Section only: how far past the cut plane the view shows. null/absent = projection (shows everything)." }
61
83
  },
62
84
  "additionalProperties": true
63
85
  },
@@ -115,13 +137,79 @@
115
137
  "type": "object",
116
138
  "required": ["id"],
117
139
  "additionalProperties": true,
118
- "description": "A named, ID-BEARING set of elements (a detected entity or a user grouping) — the unit an insert/modify Request can target.",
140
+ "description": "A named, ID-BEARING set of elements (a detected entity or a user grouping) — the unit an insert/modify Request can target, and the unit multi-view reconstruction lifts to 3D (via `section`/`axis` for member-like entities, `footprint`+`extrude` for extruded ones).",
119
141
  "properties": {
120
142
  "id": { "type": "string", "description": "Stable, sheet-unique group id." },
121
- "kind": { "type": "string", "description": "Free-form entity kind (vocabulary-free; e.g. 'detail', 'wall', 'annotation'). The generic core does not enforce a taxonomy." },
143
+ "kind": { "type": "string", "description": "Free-form entity kind (vocabulary-free; e.g. 'detail', 'wall', 'annotation', 'member', 'footprint'). The generic core does not enforce a taxonomy; views-to-scene keys on `section`/`axis`/`footprint`/`extrude` being present, not on kind." },
122
144
  "label": { "type": "string" },
123
145
  "elementIds": { "type": "array", "items": { "type": "string" }, "description": "The `elements[].id`s in this group." },
124
- "confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "Reader-emitted grouping confidence 0..1." }
146
+ "confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "Reader-emitted grouping confidence 0..1." },
147
+ "section": {
148
+ "type": "object",
149
+ "required": ["w", "d"],
150
+ "description": "READER-EMITTED rectangular cross-section in WORLD units for a member-like entity — the vocabulary-free replacement for steel's AISC lookup; feeds the scene `section` as-is.",
151
+ "properties": { "w": { "type": "number", "exclusiveMinimum": 0 }, "d": { "type": "number", "exclusiveMinimum": 0 } },
152
+ "additionalProperties": true
153
+ },
154
+ "axis": {
155
+ "type": "array",
156
+ "items": { "$ref": "#/$defs/point2" },
157
+ "minItems": 2,
158
+ "maxItems": 2,
159
+ "description": "Member work-line [[x0,y0],[x1,y1]] in THIS SHEET's display coords (mapped to world via transform+view). Paired with `section`."
160
+ },
161
+ "footprint": {
162
+ "type": "array",
163
+ "items": { "$ref": "#/$defs/point2" },
164
+ "minItems": 3,
165
+ "description": "Closed outline (display coords, this sheet) of an extruded entity — a plate, wall, slab. Paired with `extrude`."
166
+ },
167
+ "extrude": {
168
+ "type": "object",
169
+ "description": "How far the entity extends along the sheet's missing axis (a plan's Z). Meaningful ONLY on PLAN-sheet groups — reconstruction builds geometry from plans; on elevation/section groups this field is ignored (those groups contribute extents, not bodies). Ends are datum ids (resolved against the top-level `datums`, z-axis only) or numbers (world units). views-to-scene can also INFER this by fold-line matching against a linked elevation when absent — but a PRESENT extrude that fails to resolve is an error, never silently replaced by inference.",
170
+ "properties": {
171
+ "from": { "type": ["string", "number"], "description": "Datum id or world value — the lower/near end." },
172
+ "to": { "type": ["string", "number"], "description": "Datum id or world value — the upper/far end." }
173
+ },
174
+ "additionalProperties": true
175
+ }
176
+ }
177
+ },
178
+ "datum": {
179
+ "type": "object",
180
+ "required": ["id", "axis", "value"],
181
+ "additionalProperties": true,
182
+ "description": "One world reference value read off a drawing (a level line, a grid line). `value` is WORLD units on `axis`.",
183
+ "properties": {
184
+ "id": { "type": "string", "description": "Document-unique datum id (e.g. 'lv1') — groups[].extrude references it." },
185
+ "kind": { "enum": ["level", "grid"], "description": "level = a horizontal datum (Z); grid = a plan grid line (X or Y)." },
186
+ "label": { "type": "string", "description": "The as-drawn label (e.g. 'T.O.S. 16\\u2032-6\\u2033')." },
187
+ "axis": { "enum": ["x", "y", "z"], "description": "Which world axis the value is on." },
188
+ "value": { "type": "number", "description": "World units (the contract's canonical units)." },
189
+ "sheet": { "type": "string", "description": "Provenance: the sheet id this datum was read from." },
190
+ "confidence": { "type": "number", "minimum": 0, "maximum": 1 }
191
+ }
192
+ },
193
+ "link": {
194
+ "type": "object",
195
+ "required": ["kind", "a", "b"],
196
+ "additionalProperties": true,
197
+ "description": "A cross-view correspondence hint between two {sheet,id} targets (ids are only sheet-unique — always composite keys).",
198
+ "properties": {
199
+ "kind": { "enum": ["same-entity", "cut-line", "datum-ref"], "description": "same-entity = the two targets are one object seen in two views; cut-line = a section mark and the sheet it cuts; datum-ref = an entity bound to a datum." },
200
+ "a": { "$ref": "#/$defs/linkEnd" },
201
+ "b": { "$ref": "#/$defs/linkEnd" },
202
+ "confidence": { "type": "number", "minimum": 0, "maximum": 1 }
203
+ }
204
+ },
205
+ "linkEnd": {
206
+ "type": "object",
207
+ "required": ["sheet"],
208
+ "additionalProperties": true,
209
+ "description": "One side of a link: a sheet and (usually) a group/element id on it.",
210
+ "properties": {
211
+ "sheet": { "type": "string" },
212
+ "id": { "type": "string", "description": "A groups[].id or elements[].id on that sheet; absent = the whole sheet. NOTE: v1 reconstruction resolves same-entity links between GROUP ids only — an element-level link is kept as data but does not drive Z resolution yet." }
125
213
  }
126
214
  },
127
215
  "point2": {
@@ -43,6 +43,24 @@
43
43
  "dims3d": { "type": "array", "items": { "$ref": "#/$defs/dim3" }, "description": "Draft-only 3D dimensions (editor annotations, model-global). World-scene mm. NOT baked into the lock / 3D scene / IFC / BOM." }
44
44
  },
45
45
  "$defs": {
46
+ "grid": {
47
+ "type": ["object", "null"],
48
+ "required": ["origin"],
49
+ "additionalProperties": true,
50
+ "description": "Structural grid: X/Y are RELATIVE spacing strings (Tekla syntax — \"0 3*25' 20'-6\\\"\", n*d repeats a bay; tokens accept ft-in / inches / m / bare mm), Z is ABSOLUTE elevations. X entries space lines along +X (lines vertical on the plan, auto-labels 1,2,3…); Y entries march UP the sheet (lines horizontal, auto-labels A,B,C…). web/grid-core.js is the parser/geometry source of truth.",
51
+ "properties": {
52
+ "on": { "type": "boolean", "description": "Grid visible (2D + 3D). Default true." },
53
+ "origin": { "$ref": "#/$defs/point2", "description": "The [1,A] corner, display space (same space as member wp)." },
54
+ "x": { "type": "string", "description": "Relative spacings along +X, e.g. \"0 3*25'\"." },
55
+ "y": { "type": "string", "description": "Relative spacings up the sheet, e.g. \"0 4*30'\"." },
56
+ "z": { "type": "string", "description": "Absolute level elevations, e.g. \"0 16'-6\\\"\"." },
57
+ "labels_x": { "type": "string", "description": "Whitespace-separated label overrides for X lines; blank = auto 1,2,3…" },
58
+ "labels_y": { "type": "string", "description": "Whitespace-separated label overrides for Y lines; blank = auto A,B,C…" },
59
+ "ext": { "type": "number", "description": "How far lines run past the outermost line, in mm. Default 5'." },
60
+ "ends_x": { "type": "object", "description": "Per-line extent overrides for X lines: { \"<line index>\": [lo, hi] } display px along the line (a partial line like a 5.1 insert covering one wing). null / absent element = the auto full span. Set by dragging a bubble along its line." },
61
+ "ends_y": { "type": "object", "description": "Per-line extent overrides for Y lines — same shape as ends_x." }
62
+ }
63
+ },
46
64
  "filter": {
47
65
  "type": "object",
48
66
  "additionalProperties": true,
@@ -111,18 +129,21 @@
111
129
  },
112
130
  "elements": {
113
131
  "type": "array",
114
- "description": "Every drawing element: lines (with an SVG path `d`) and text spans (with `text`+`bbox`), each tagged layer/thickness/colour.",
132
+ "description": "Every drawing element: lines (with an SVG path `d`), text spans (with `text`+`bbox`) and circles (`cx`/`cy`/`r` — grid bubbles, callout balloons), each tagged layer/thickness/colour.",
115
133
  "items": {
116
134
  "type": "object",
117
135
  "additionalProperties": true,
118
136
  "properties": {
119
- "kind": { "enum": ["line", "text"] },
137
+ "kind": { "enum": ["line", "text", "circle"] },
120
138
  "layer": { "type": "string" },
121
139
  "w": { "type": "number" },
122
140
  "color": { "type": "string" },
123
141
  "d": { "type": "string", "description": "SVG path (line elements)." },
124
142
  "text": { "type": "string", "description": "Label text (text elements)." },
125
143
  "bbox": { "type": "array", "items": { "type": "number" }, "description": "[x0,y0,x1,y1] (text elements)." },
144
+ "cx": { "type": "number", "description": "Circle centre X (circle elements)." },
145
+ "cy": { "type": "number", "description": "Circle centre Y (circle elements)." },
146
+ "r": { "type": "number", "description": "Circle radius (circle elements)." },
126
147
  "dashed": { "type": "boolean" }
127
148
  }
128
149
  }
@@ -150,18 +171,21 @@
150
171
  "steelBbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] of this sheet's steel linework, for Zoom-to-steel." },
151
172
  "elements": {
152
173
  "type": "array",
153
- "description": "This sheet's drawing elements: lines (SVG path `d`) and text spans (`text`+`bbox`), each tagged layer/thickness/colour.",
174
+ "description": "This sheet's drawing elements: lines (SVG path `d`), text spans (`text`+`bbox`) and circles (`cx`/`cy`/`r` — grid bubbles feed the editor's grid-from-drawing detector), each tagged layer/thickness/colour.",
154
175
  "items": {
155
176
  "type": "object",
156
177
  "additionalProperties": true,
157
178
  "properties": {
158
- "kind": { "enum": ["line", "text"] },
179
+ "kind": { "enum": ["line", "text", "circle"] },
159
180
  "layer": { "type": "string" },
160
181
  "w": { "type": "number" },
161
182
  "color": { "type": "string" },
162
183
  "d": { "type": "string" },
163
184
  "text": { "type": "string" },
164
185
  "bbox": { "type": "array", "items": { "type": "number" } },
186
+ "cx": { "type": "number" },
187
+ "cy": { "type": "number" },
188
+ "r": { "type": "number" },
165
189
  "dashed": { "type": "boolean" }
166
190
  }
167
191
  }
@@ -236,7 +260,8 @@
236
260
  "tos_callouts": { "type": "array", "items": { "$ref": "#/$defs/tos_callout" } },
237
261
  "members": { "type": "array", "items": { "$ref": "#/$defs/member" } },
238
262
  "dims": { "type": "array", "items": { "$ref": "#/$defs/dim" }, "description": "Draft-only on-drawing dimensions (editor annotations). NOT baked into the lock / 3D / IFC / BOM." },
239
- "frame": { "$ref": "#/$defs/frame" }
263
+ "frame": { "$ref": "#/$defs/frame" },
264
+ "grid": { "$ref": "#/$defs/grid", "description": "Structural grid lines (Tekla-style). PER-PLAN — its origin/spacings live in this sheet's display space (like frame/dims). A plan REFERENCE the editor renders in 2D + 3D and snaps to — it never constrains members and is NOT baked into the lock / IFC / BOM." }
240
265
  }
241
266
  },
242
267
  "segment": {
@@ -2,7 +2,7 @@
2
2
  name: floless-app-steel-takeoff
3
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-model` 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-model", "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
4
  metadata:
5
- version: 0.3.0
5
+ version: 0.4.0
6
6
  ---
7
7
 
8
8
  # Steel Takeoff — read a drawing into a steel.takeoff/v1 contract
@@ -80,6 +80,11 @@ native/display coords):
80
80
  pt>, color:<#rrggbb stroke>, d:<SVG path>, dashed:<bool> }`.
81
81
  2. **Text** — `page.get_text("dict")`: per span keep `{ kind:"text", layer:<OCG>, w:0, color:<#hex>,
82
82
  text:<string>, bbox:[x0,y0,x1,y1] }`.
83
+ 2b. **Circles** — a closed bezier path whose bbox is round (width ≈ height, ≥ ~8 pt across) becomes
84
+ `{ kind:"circle", cx:<centre x>, cy:<centre y>, r:<radius> }` in display space. These are the grid
85
+ bubbles and callout balloons; the editor's **grid-from-drawing** detector (`web/grid-core.js`
86
+ `gridFromDrawing`) anchors grid lines to them, so skipping circles silently disables the Grid
87
+ panel's "From drawing" button.
83
88
  3. **Per-sheet entry** — push `{ sheet:<number>, title:<name?>, page:{ w, h, bg_b64, rect }, steelBbox,
84
89
  elements:[…] }` onto `filter.sheets`. `bg_b64` is a dimmed raster of THIS sheet's framing area
85
90
  (**title block excluded** — CONFIDENTIAL, machine-local, never committed). **`page.rect` = the exact
@@ -96,6 +101,29 @@ native/display coords):
96
101
  5. Set `filter.mode: "layer"`, `filter.active: 0`. **PUT** the contract
97
102
  (`PUT /api/contract/steel-model`). On a re-read, preserve any existing per-facet `on` flags.
98
103
 
104
+ ### 1c. Grid lines — read the printed grid (quick, per sheet)
105
+
106
+ With the filter elements in hand, populate each plan's **`plan.grid`** (Tekla-style grid lines the
107
+ editor renders in 2D + 3D and snaps to). The geometry is deterministic — the editor's Grid panel has
108
+ a "From drawing" button running the same detector (`web/grid-core.js` `gridFromDrawing`: grid lines
109
+ are the long dash-dot line fields that AIM at a bubble circle's centre and STOP at its edge; bubbles
110
+ line up in a row along one sheet edge and columns along the sides). What the detector usually can't
111
+ get is the **labels** — CAD sets outline their text, so bubble marks like `5.1` / `K.1` aren't in the
112
+ text layer. You CAN read them: you already have vision on the sheet. So:
113
+
114
+ 1. Derive the line positions (or let the user press "From drawing" later — same result).
115
+ 2. **Vision-read each bubble's mark** from the sheet render, in line order.
116
+ 3. Write `plan.grid = { on:true, origin:[x,y], x:"0 3*25' …", y:"…", z:"0", labels_x:"K J H …",
117
+ labels_y:"0.5 1 1.1 …", ext:<mm> }` — origin = the left-most × bottom-most line crossing,
118
+ spacings are RELATIVE ft-in tokens (`n*d` repeats a bay), labels are whitespace-separated
119
+ overrides in line order (X: left→right; Y: bottom→up). Sanity-check a bay or two against the
120
+ printed dimension strings between grid lines — a mismatch means the sheet scale (`pt_per_ft`) is
121
+ wrong, which corrupts the whole takeoff, not just the grid. **Then PUT the contract again** —
122
+ the 1b PUT already happened, and the user's next Filter-node Save writes back the contract *it
123
+ loaded*; an un-PUT grid (and its vision-read labels) would be silently dropped.
124
+
125
+ Skip silently only when the sheet genuinely has no grid bubbles (rare for framing plans); say so.
126
+
99
127
  **Then STOP and hand off — checkpoint before the member read.** Tell the user to open the **Filter**
100
128
  node, flip through the sheets, narrow to the steel, and **Save**. The saved `on` flags + `mode` are
101
129
  what step 2 reads — **per sheet** (each sheet's elements passing `mode` + the global `on` flags). Only