@floless/app 0.61.0 → 0.62.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.62.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.62.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": {
@@ -36,6 +36,7 @@ let soloGroups = new Set(); // profile keys isolated via the leg
36
36
  let isolatedIds = null; // Tekla "isolate selected": Set of ids shown exclusively, or null for off
37
37
  let connHidden = new Set(); // explicit per-PART hide (legend connection rows) — lets a shared part-kind (weld/nut) hide per-connection by id, not per group
38
38
  let cube = null; // ViewCube { renderer, scene, cam, mesh, faces }
39
+ let triad = null; // world-axis triad { renderer, scene, cam, group } — passive X/Y/Z readout
39
40
  const DRAG_TOL_PX = 4; // movement past this = a drag (not a click)
40
41
  const SNAP_TOL_PX = 10; // snap an endpoint to a target within this screen distance
41
42
  const FT_MM = 304.8; // mm per foot (the dimension readout shows feet, matching the editor)
@@ -150,6 +151,7 @@ function init(canvas, theApi) {
150
151
  window.addEventListener('keydown', onKey); // Tekla keyboard nav: arrows pan, Ctrl/Shift+arrows rotate
151
152
  ro = new ResizeObserver(resize); ro.observe(canvas.parentElement || canvas);
152
153
  initCube();
154
+ initTriad();
153
155
  // Resize once more on the next frame: the stage may still be laying out when init() runs (the
154
156
  // designer flagged a first-render size mismatch as the common rough edge here).
155
157
  requestAnimationFrame(resize);
@@ -181,6 +183,7 @@ function loop() {
181
183
  renderer.autoClear = true; renderer.clippingPlanes = saved;
182
184
  }
183
185
  if (cube) { syncCube(); cube.renderer.render(cube.scene, cube.cam); }
186
+ if (triad) { syncTriad(); triad.renderer.render(triad.scene, triad.cam); }
184
187
  }
185
188
 
186
189
  const V = (x, y, z) => new THREE.Vector3(x, y, z);
@@ -1414,6 +1417,46 @@ function initCube() {
1414
1417
  });
1415
1418
  cube = { renderer: cr, scene: cs, cam: cc, mesh };
1416
1419
  }
1420
+
1421
+ // ---- World-axis triad (Tekla-style) — a passive bottom-right gizmo showing where world X/Y/Z point.
1422
+ // Same sync-to-camera pattern as the ViewCube, but pointer-events:none: orientation CHANGES stay the
1423
+ // cube's job; this only reads out. Colors are the CAD convention (X red, Y green, Z blue = --brand).
1424
+ const TRIAD_AXES = [['X', '#ef4444', [1, 0, 0]], ['Y', '#22c55e', [0, 1, 0]], ['Z', '#3b82f6', [0, 0, 1]]];
1425
+ function triadTip(label, color, pos) { // free-standing colored letter with a dark halo — legible over any geometry, no disc
1426
+ const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d');
1427
+ g.font = 'bold 46px ui-sans-serif,system-ui,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
1428
+ g.lineWidth = 8; g.lineJoin = 'round'; g.strokeStyle = 'rgba(2,8,23,.9)'; g.strokeText(label, 32, 34);
1429
+ g.fillStyle = color; g.fillText(label, 32, 34);
1430
+ const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c) }));
1431
+ s.position.copy(pos); s.scale.setScalar(0.85);
1432
+ return s;
1433
+ }
1434
+ function initTriad() {
1435
+ const host = document.getElementById('m3dAxes'); if (!host) return;
1436
+ const PX = 78;
1437
+ const tr = new THREE.WebGLRenderer({ antialias: true, alpha: true });
1438
+ tr.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); tr.setSize(PX, PX);
1439
+ host.appendChild(tr.domElement);
1440
+ const ts = new THREE.Scene();
1441
+ const tc = new THREE.OrthographicCamera(-2.1, 2.1, 2.1, -2.1, 0.1, 20); tc.position.set(0, 0, 5);
1442
+ const g = new THREE.Group();
1443
+ const Y = new THREE.Vector3(0, 1, 0); // Cylinder/ConeGeometry's own axis
1444
+ for (const [label, color, dir] of TRIAD_AXES) {
1445
+ const d = new THREE.Vector3(dir[0], dir[1], dir[2]);
1446
+ const mat = new THREE.MeshBasicMaterial({ color });
1447
+ const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 1.05, 8), mat);
1448
+ shaft.quaternion.setFromUnitVectors(Y, d);
1449
+ shaft.position.copy(d).multiplyScalar(0.525);
1450
+ const head = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.34, 12), mat); // arrowhead at the shaft end
1451
+ head.quaternion.copy(shaft.quaternion);
1452
+ head.position.copy(d).multiplyScalar(1.22);
1453
+ g.add(shaft, head, triadTip(label, color, d.clone().multiplyScalar(1.62)));
1454
+ }
1455
+ g.add(new THREE.Mesh(new THREE.SphereGeometry(0.1, 12, 8), new THREE.MeshBasicMaterial({ color: 0xe2e8f0 }))); // origin dot
1456
+ ts.add(g);
1457
+ triad = { renderer: tr, scene: ts, cam: tc, group: g };
1458
+ }
1459
+ function syncTriad() { triad.group.quaternion.copy(camera.quaternion).invert(); }
1417
1460
  // Mirror the scene from the main camera's direction (the FRONT face turns toward the viewer in a
1418
1461
  // front view, etc.) by orienting the cube by the inverse of the camera's world rotation.
1419
1462
  function syncCube() { cube.mesh.quaternion.copy(camera.quaternion).invert(); }
@@ -1826,10 +1869,12 @@ function dispose() {
1826
1869
  window.removeEventListener('keydown', onKey);
1827
1870
  for (const ovl of [readout, hoverChip, rubber, dimLabelHost]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
1828
1871
  dimLabelHost = null; dimLabelPool.length = 0;
1829
- if (cube) {
1830
- cube.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
1831
- cube.renderer.dispose(); if (cube.renderer.domElement.parentNode) cube.renderer.domElement.parentNode.removeChild(cube.renderer.domElement); cube = null;
1872
+ for (const w of [cube, triad]) { // both mini-widgets own a WebGL context — leak one and re-init eventually hits the browser's context cap
1873
+ if (!w) continue;
1874
+ w.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
1875
+ w.renderer.dispose(); if (w.renderer.domElement.parentNode) w.renderer.domElement.parentNode.removeChild(w.renderer.domElement);
1832
1876
  }
1877
+ cube = triad = null;
1833
1878
  if (epGeom) epGeom.dispose(); if (epMatStart) epMatStart.dispose(); if (epMatEnd) epMatEnd.dispose();
1834
1879
  if (dims3dGroup) { if (scene) scene.remove(dims3dGroup); for (const c of dims3dGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // placed-dim lines
1835
1880
  if (overlayDimsGroup) { if (scene) scene.remove(overlayDimsGroup); for (const c of overlayDimsGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // derived dim-overlay lines
@@ -129,7 +129,33 @@
129
129
  #comboPop .opt{padding:6px 10px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap}
130
130
  #comboPop .opt:hover,#comboPop .opt.active{background:#334155}
131
131
  /* "More" overflow menu — themed popup (reuses the #comboPop look); flat rows keep each button's id + handler. */
132
- #moreWrap{position:relative;display:inline-flex}
132
+ /* Move/Copy split buttons + transform-suite chrome — all within the locked baseline tokens. */
133
+ .cmwrap{position:relative;display:inline-flex}
134
+ .cmwrap>button:first-child{border-radius:6px 0 0 6px}
135
+ .cmwrap .cmcaret{border-radius:0 6px 6px 0;border-left:0;padding:0 5px;min-width:22px;color:var(--mut)}
136
+ .cmwrap>button.on,.cmwrap .cmcaret.on{background:var(--brand);border-color:var(--brand);color:#fff}
137
+ .cmmenu{display:none;position:absolute;left:0;top:calc(100% + 6px);min-width:200px;background:var(--panel);border:1px solid #475569;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px 0;z-index:30}
138
+ .cmmenu.open{display:block}
139
+ .cmmenu button{display:flex;justify-content:space-between;gap:12px;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap}
140
+ .cmmenu button:hover{background:#334155}
141
+ .cmmenu .mkbd{color:var(--mut);font-size:11px}
142
+ .cmghost{stroke-dasharray:6 4;opacity:.4;pointer-events:none;vector-effect:non-scaling-stroke;stroke-width:3}
143
+ .cmghostbox{fill:none;stroke:#22d3ee;stroke-dasharray:4 4;opacity:.5;pointer-events:none;vector-effect:non-scaling-stroke}
144
+ .cmrub{stroke:#22d3ee;stroke-width:1.5;stroke-dasharray:5 4;pointer-events:none;vector-effect:non-scaling-stroke}
145
+ .cmarrow{fill:#22d3ee;pointer-events:none}
146
+ .cmchip{fill:var(--panel);stroke:#22d3ee;opacity:.85;pointer-events:none} /* .85: reads "in progress", full opacity stays reserved for committed dims */
147
+ .cmtx{fill:var(--text);text-anchor:middle;dominant-baseline:central;opacity:.85;pointer-events:none;font-family:system-ui}
148
+ #cmHud{position:fixed;z-index:70;display:none;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--brand);border-radius:8px;padding:6px 8px;box-shadow:0 6px 20px rgba(0,0,0,.55);font:12px system-ui;color:var(--mut)}
149
+ #cmHud.err{border-color:#fca5a5}
150
+ #cmHud input{width:110px;height:24px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 7px;font:12px system-ui}
151
+ #cmHud input:focus{outline:none;border-color:var(--brand)}
152
+ #cmHud.err input{border-color:#fca5a5}
153
+ #lvModal .lvrow{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid transparent;border-radius:7px;cursor:pointer}
154
+ #lvModal .lvrow:hover{background:var(--line)}
155
+ #lvModal .lvrow.pick{background:var(--line);border-color:var(--brand)}
156
+ #lvModal .lvrow.cur{opacity:.45;cursor:default}
157
+ #lvModal .lvrow .lvtos{margin-left:auto;color:var(--mut);font-variant-numeric:tabular-nums;font-size:12px;white-space:nowrap}
158
+ #moreWrap{position:relative;display:inline-flex}
133
159
  #moreMenu{position:absolute;right:0;top:calc(100% + 6px);min-width:210px;background:var(--panel);border:1px solid #475569;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px 0;z-index:30;display:none}
134
160
  #moreMenu.open{display:block}
135
161
  #moreMenu .mlabel{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);padding:8px 12px 2px}
@@ -252,7 +278,10 @@
252
278
  #m3dLegend .lrow.flash{background:rgba(59,130,246,.12)}
253
279
  .leg-drag-ghost{position:fixed;pointer-events:none;z-index:70;background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:3px 8px;display:flex;align-items:center;gap:7px;font:12px system-ui;color:var(--text);width:200px;opacity:.88;box-shadow:0 4px 16px rgba(0,0,0,.6)}
254
280
  #m3dLegend .ldiv{height:1px;background:var(--line);margin:5px 2px}
255
- #m3dCube{position:absolute;right:12px;bottom:12px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))}
281
+ #m3dCube{position:absolute;right:12px;top:56px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))} /* top-right (Revit-style), below the toolbar row */
282
+ /* Tekla-style world-axis triad, bottom-right (where the cube used to sit). Passive readout
283
+ (pointer-events:none) — orientation is the ViewCube's job; this only SHOWS where world X/Y/Z point. */
284
+ #m3dAxes{position:absolute;right:15px;bottom:12px;width:78px;height:78px;display:none;z-index:5;pointer-events:none;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))}
256
285
  </style>
257
286
  <script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/","three-bvh-csg":"./vendor/three-bvh-csg.module.js"}}</script>
258
287
  <script type="module" src="./steel-3d-view.js"></script>
@@ -272,6 +301,21 @@
272
301
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
273
302
  <button id=mAdd title="Toggle add-member mode">Add member</button>
274
303
  <button id=dimB title="Dimension tool (D) — click two snapped points, then a third to place. Default Free (aligned); hold Shift to lock to an axis, X/Y force horizontal/vertical, F free.">⊢ Dimension</button>
304
+ <div class=cmwrap>
305
+ <button id=mvB title="Move (M) — pick a base point, then a destination. Type a distance or dx,dy after the first pick for an exact move.">↔ Move</button><button id=mvCaret class=cmcaret aria-haspopup=menu aria-expanded=false aria-label="Move options">▾</button>
306
+ <div class=cmmenu id=mvMenu role=menu>
307
+ <button id=mvTwoB>Move — two points <span class=mkbd>M</span></button>
308
+ <button id=mvLevelB>Move to level…</button>
309
+ </div>
310
+ </div>
311
+ <div class=cmwrap>
312
+ <button id=cpB title="Copy (C) — pick a base point, then a destination. Set ×N in the panel for a linear row; type a value for an exact offset.">⧉ Copy</button><button id=cpCaret class=cmcaret aria-haspopup=menu aria-expanded=false aria-label="Copy options">▾</button>
313
+ <div class=cmmenu id=cpMenu role=menu>
314
+ <button id=cpTwoB>Copy — two points <span class=mkbd>C</span></button>
315
+ <button id=cpArrB>Copy array…</button>
316
+ <button id=cpLevelB>Copy to level…</button>
317
+ </div>
318
+ </div>
275
319
  <button id=askAiBtn>Ask AI ▸</button>
276
320
  <div id=moreWrap>
277
321
  <button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
@@ -345,6 +389,7 @@
345
389
  </div>
346
390
  <div id=m3dLegend></div>
347
391
  <div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
392
+ <div id=m3dAxes></div>
348
393
  <div id=zoombar>
349
394
  <button id=zOut title="Zoom out">−</button>
350
395
  <input id=zRange type=range min=10 max=400 step=1 value=100>
@@ -582,6 +627,31 @@ function swapMemberEnds(m){
582
627
  if(Array.isArray(m.ends)&&m.ends.length>=2)m.ends=[m.ends[1],m.ends[0]];
583
628
  if(m.col){const b=(m.col.bos!=null?m.col.bos:0),t=(m.col.tos!=null?m.col.tos:defaultTOS);m.col.bos=t;m.col.tos=b;m.col.tosDef=false;}
584
629
  return m;}
630
+ // --- Move/Copy transform core (spec §4.1): offsets are [dx,dy,dzIn] — dx/dy plan px, dz inches. ---
631
+ let cmSeq=0;
632
+ function cloneMember(m){const c=JSON.parse(JSON.stringify(m));c.id='m'+Date.now()+'_c'+(cmSeq++);return c;}
633
+ // Rigid translate: wp shift; dz materializes the level default first then shifts both ends together
634
+ // (same contract as the 3D vertical-drag path) — a slope is preserved, tosDef flips off only when dz≠0.
635
+ function translateMembers(ms,off){const dz=off[2]||0;for(const m of ms){ensureMeta(m);
636
+ m.wp[0]=[m.wp[0][0]+off[0],m.wp[0][1]+off[1]];m.wp[1]=[m.wp[1][0]+off[0],m.wp[1][1]+off[1]];
637
+ if(dz){if(m.role==='column'){m.col.tos=(m.col.tos!=null?m.col.tos:defaultTOS)+dz;m.col.bos=(m.col.bos!=null?m.col.bos:0)+dz;m.col.tosDef=false;}
638
+ else m.ends.forEach(en=>{en.tos=(en.tos!=null?en.tos:defaultTOS)+dz;en.tosDef=false;});}}}
639
+ function linearOffsets(v,n){const o=[];for(let k=1;k<=n;k++)o.push([v[0]*k,v[1]*k,(v[2]||0)*k]);return o;}
640
+ // Rectangular array: counts INCLUDE the original position; (0,0) is skipped so the grid adds n*m-1 copies.
641
+ function arrayOffsets(vA,nA,vB,nB){const o=[];for(let i=0;i<nA;i++)for(let j=0;j<nB;j++){if(!i&&!j)continue;
642
+ o.push([vA[0]*i+vB[0]*j,vA[1]*i+vB[1]*j,(vA[2]||0)*i+(vB[2]||0)*j]);}return o;}
643
+ // HUD input → {dist} (inches, along the live direction) or {comp:[dx,dy,dz] inches}. null = unparseable.
644
+ function parseVec(s){s=String(s==null?'':s).trim();if(!s)return null;
645
+ if(s.includes(',')){const p=s.split(',').map(t=>t.trim()===''?0:parseLen(t));if(p.some(x=>x==null)||p.length>3)return null;
646
+ return {comp:[p[0]||0,p[1]||0,p[2]||0]};}
647
+ const d=parseLen(s);return d==null?null:{dist:d};}
648
+ // To-level TOS mapping (spec §4.5): default-follow ends land on the target level automatically;
649
+ // explicit overrides shift by Δ so relative offsets survive. Columns keep their height (bos shifts by Δ).
650
+ function retargetTos(m,srcDef,dstDef){const d=dstDef-srcDef;ensureMeta(m);
651
+ if(m.role==='column'){if(m.col.tosDef!==false)m.col.tos=dstDef;else if(m.col.tos!=null)m.col.tos+=d;
652
+ if(m.col.bos!=null)m.col.bos+=d;}
653
+ else for(const en of m.ends){if(en.tosDef!==false)en.tos=dstDef;else if(en.tos!=null)en.tos+=d;}
654
+ return m;}
585
655
  // best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
586
656
  // 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
587
657
  let defaultTOS=198, addProfile='';
@@ -598,22 +668,50 @@ const autofillTOS=syncDefaults;
598
668
  // unsupported edge is two DIFFERENT sheets each detailing a column that happens to share an id — then the
599
669
  // slice would over-match. Tracked as a reader-side id-uniqueness hardening. dims3d rides its own persistence.
600
670
  function snapshot(){const pm=new Set((P.members||[]).map(m=>m.id));return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null,joints:(C.joints||[]).filter(j=>j&&pm.has(j.main)),clips:(window.Steel3DView&&window.Steel3DView.clipState)?window.Steel3DView.clipState():null});} // clips/work area are view-state but ride the contract undo stack
671
+ // Cross-plan undo (spec §4.5): a commit that also touches ANOTHER plan captures that plan's member
672
+ // slice too; undo/redo restore it by sheet. The entry lives on the CURRENT plan's stack — undoing
673
+ // from the plan where the command ran is the supported path (same per-plan-stack rule as everything).
674
+ function snapshotWith(T){const base=JSON.parse(snapshot());base.others=[{sheet:T.sheet,members:T.members}];return JSON.stringify(base);}
675
+ // Mirror an undo entry's coverage when building the opposite stack's entry — a plain snapshot()
676
+ // would silently drop the other plan's slice and redo would corrupt it.
677
+ function snapshotLike(json){let d=null;try{d=JSON.parse(json);}catch(_){console.error('undo: unparsable stack entry — cross-plan coverage may be reduced');}
678
+ if(!d||!Array.isArray(d.others))return snapshot();
679
+ const base=JSON.parse(snapshot());
680
+ base.others=d.others.map(o=>{const tp=C.plans.find(p=>p.sheet===o.sheet);return {sheet:o.sheet,members:tp?tp.members:[]};});
681
+ return JSON.stringify(base);}
682
+ // The mirror entry for the TARGET plan's own stack: shaped as if T were current (so a later undo
683
+ // FROM T applies cleanly), with an others-slice pointing back at the source — undoing the arrival
684
+ // from the target side restores the source plan too (a moved member is never stranded/lost).
685
+ // extraIds = ids MOVING onto T (a move keeps ids, so their joints must ride this slice — else the
686
+ // apply-from-T own-filter drops them and the member returns to the source stripped of connections).
687
+ // Per-plan stacks are not cross-invalidated: if the op was already undone from the other side, this
688
+ // mirror degrades to a benign one-step no-op undo (never data loss).
689
+ function snapshotForPlan(T,src,extraIds){const tm=new Set([...(T.members||[]).map(m=>m.id),...(extraIds||[])]);
690
+ return JSON.stringify({members:T.members||[],dims:T.dims||[],frame:T.frame||null,
691
+ joints:(C.joints||[]).filter(j=>j&&tm.has(j.main)),
692
+ clips:(window.Steel3DView&&window.Steel3DView.clipState)?window.Steel3DView.clipState():null,
693
+ others:[{sheet:src.sheet,members:src.members}]});}
694
+ function pushUndoFor(T,entry){(T.undo=T.undo||[]).push(entry);if(T.undo.length>200)T.undo.shift();(T.redo=T.redo||[]).length=0;}
601
695
  function refreshDims3d(){if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();} // repaint the 3D dim lines/labels from C.dims3d
602
696
  function refreshOverlayDims3d(){if(window.Steel3DView&&window.Steel3DView.refreshOverlayDims)window.Steel3DView.refreshOverlayDims();} // re-derive the legend DIMENSIONS overlays from C.dim_overlays
603
697
  function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
604
698
  function apply(json){const d=JSON.parse(json);
605
699
  if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
606
700
  else{const pmOld=new Set((P.members||[]).map(m=>m.id));P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;if('frame' in d)P.frame=d.frame||null;
607
- if('joints' in d){const own=new Set([...pmOld,...P.members.map(m=>m.id)]);C.joints=(C.joints||[]).filter(j=>!(j&&own.has(j.main))).concat(d.joints||[]);}} // restore THIS plan's joint slice: drop joints owned by this plan (old ∪ restored member ids), re-add the snapshot's — OTHER sheets' joints stay untouched. Legacy member-only snapshots lack 'joints' → left alone. dims3d rides its own path.
701
+ if('joints' in d){const own=new Set([...pmOld,...P.members.map(m=>m.id)]);C.joints=(C.joints||[]).filter(j=>!(j&&own.has(j.main))).concat(d.joints||[]);}
702
+ if(Array.isArray(d.others))for(const o of d.others){const tp=C.plans.find(p=>p.sheet===o.sheet);
703
+ if(tp&&tp!==P)tp.members=(o.members||[]).map(ensureMeta);
704
+ else if(!tp){console.error('undo: sheet '+o.sheet+' not found — cross-plan slice skipped');toast('Undo couldn’t restore sheet '+o.sheet);}}} // restore THIS plan's joint slice: drop joints owned by this plan (old ∪ restored member ids), re-add the snapshot's — OTHER sheets' joints stay untouched. Legacy member-only snapshots lack 'joints' → left alone. dims3d rides its own path. 'others' = the cross-plan slice a to-level commit captured.
608
705
  updCS();
609
706
  selIds=new Set([...selIds].filter(id=>byId(id)));
610
707
  selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
611
708
  if(sel3dDimIds.size){const ok=new Set((C.dims3d||[]).map(x=>x.id));sel3dDimIds=new Set([...sel3dDimIds].filter(id=>ok.has(id)));} // drop stale 3D-dim selections
612
709
  dimDraft=null;dimChainPrev=null;dimPrevClear(); // undo/redo changed geometry under the tool → abandon any in-progress dim placement / chain (else the chain head points at a removed segment)
710
+ cmDraft=null;cmPrevClear();cmHudClose(); // same for an in-progress Move/Copy pick — the base may reference removed geometry
613
711
  if(window.Steel3DView&&window.Steel3DView.setClipState&&'clips' in d)window.Steel3DView.setClipState(d.clips); // restore clips + work area alongside the contract
614
712
  scheduleSave();render();sync3D();}
615
- function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
616
- function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
713
+ function doUndo(){if(!undo.length)return;redo.push(snapshotLike(undo[undo.length-1]));apply(undo.pop());}
714
+ function doRedo(){if(!redo.length)return;undo.push(snapshotLike(redo[redo.length-1]));apply(redo.pop());}
617
715
  function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();sync3D();}
618
716
  // --- auto-save: edits persist to this browser (localStorage). Raster stays embedded; only members + TOS are stored. ---
619
717
  const LSKEY = 'steeltakeoff:edits:v1:' + APP_ID;
@@ -754,7 +852,7 @@ function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
754
852
  let lastCmd=null;
755
853
  function setLastCmd(label,run){lastCmd={label,run};}
756
854
  function anyToolActive(){
757
- if(dimMode||dimChain||dimSplitMode||geoMode||csaxisMode||mode==='add'||picking)return true; // 2D tools armed
855
+ if(dimMode||dimChain||dimSplitMode||geoMode||csaxisMode||mode==='add'||picking||cmTool)return true; // 2D tools armed (cmTool = Move/Copy)
758
856
  if(view3d&&window.Steel3DView){if(window.Steel3DView.clipMode&&window.Steel3DView.clipMode())return true;const dm=document.getElementById('m3dDim');if(dm&&dm.classList.contains('on'))return true;} // 3D dim/clip armed
759
857
  return false;
760
858
  }
@@ -929,6 +1027,16 @@ function panel(){
929
1027
  if(csaxisMode){p.innerHTML=`<span class=badge>Set local axes</span>
930
1028
  <div class=hint id=csHint style="margin-top:8px">${csDraft?'Click the <b>second</b> point — the local-X direction. Y follows at 90°.':'Click the <b>origin</b> point to start.'}</div>
931
1029
  <div class=hint style="margin-top:8px">Two clicks define the frame; snaps to member ends &amp; grid (<b>Alt</b> off). <b>Esc</b> cancels.</div>`;return;}
1030
+ if(cmTool){const nsel=selIds.size;
1031
+ p.innerHTML=`<span class=badge>${cmTool==='move'?'Move':(cmArrayMode?'Copy array':'Copy')}</span>
1032
+ <div class=hint id=cmHint style="margin-top:8px">${esc(cmHintText())}</div>
1033
+ ${cmTool==='copy'&&!cmArrayMode?`<div class=sect style="margin-top:12px">Copies</div><input id=cmN inputmode=numeric value="${cmCount}" autocomplete=off><div class=hint style="margin-top:4px">×N places a row — each copy one step further along the pick (linear).</div>`:''}
1034
+ ${cmArrayMode?`<div class=sect style="margin-top:12px">Array counts</div><div class=elab>Direction A</div><input id=cmNA inputmode=numeric value="${cmCountA}" autocomplete=off><div class=elab>Direction B</div><input id=cmNB inputmode=numeric value="${cmCountB}" autocomplete=off><div class=hint id=cmCapHint style="margin-top:4px">Counts include the original — 4 × 6 fills a 4-by-6 grid (max 100).</div>`:''}
1035
+ <div class=hint style="margin-top:8px">${nsel} member${nsel===1?'':'s'} selected. <b>Shift</b>=straight · <b>X/Y/F</b> axis · <b>Alt</b>=no snap · type after the first pick for an exact value · <b>Esc</b> cancels. Connections don't copy.</div>`;
1036
+ const wireN=(id,set)=>{const i=document.getElementById(id);if(i)i.onchange=e=>{const n=Math.max(1,Math.min(99,Math.round(Number(e.target.value)||1)));set(n);e.target.value=n;
1037
+ const ch=document.getElementById('cmCapHint');if(ch)ch.style.color=(cmArrayMode&&cmCountA*cmCountB>100)?'#fca5a5':'';cmRefreshPrev();};};
1038
+ wireN('cmN',n=>cmCount=n);wireN('cmNA',n=>cmCountA=n);wireN('cmNB',n=>cmCountB=n);
1039
+ return;}
932
1040
  if(dimMode){p.innerHTML=`<span class=badge>Dimension</span>
933
1041
  <div class=hint id=dimHint style="margin-top:8px">${esc(dimHintText())}</div>
934
1042
  <div class=sect style="margin-top:12px">Direction</div>
@@ -1268,7 +1376,7 @@ function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove()
1268
1376
  // svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
1269
1377
  const SVGNS='http://www.w3.org/2000/svg';
1270
1378
  function setDimMode(){document.body.classList.toggle('dimon',dimMode);document.getElementById('dimB').classList.toggle('on',dimMode);
1271
- if(dimMode){if(mode==='add'){mode='sel';setMode();}if(csaxisMode){csaxisMode=false;setCsMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();dimsVisible=true;updDimToggle();} // arming clears conflicting modes (add/set-axes) + ensures dims are visible — covers the D-key path too, not just the button
1379
+ if(dimMode){if(mode==='add'){mode='sel';setMode();}if(csaxisMode){csaxisMode=false;setCsMode();}if(cmTool)disarmCm();geoMode=null;setGeo();selIds.clear();selDimIds.clear();dimsVisible=true;updDimToggle();} // arming clears conflicting modes (add/set-axes/move-copy) + ensures dims are visible — covers the D-key path too, not just the button
1272
1380
  dimDraft=null;dimChainPrev=null;dimPrevClear();}
1273
1381
  function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
1274
1382
  if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;return {x,y,raw:[q.x,q.y],hit:sn.hit};}
@@ -1341,11 +1449,172 @@ function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
1341
1449
  g.innerHTML=dimSvg({a:dimDraft.a,b,axis:dimDraft.axis,off:dimDefaultOff(dimDraft.a,b,dimDraft.axis,rot),rot},{preview:true});return;}
1342
1450
  const off=dimOffFromPointer(dimDraft.a,dimDraft.b,dimDraft.axis,dimLastPtr,rot);dimDraft._ax=dimDraft.axis; // stage 2: keep the offset the pointer last set; sync _ax so the 3rd-click commit uses the axis now on screen
1343
1451
  g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:dimDraft.axis,off,rot},{preview:true});}
1452
+ // --- Move/Copy engine (spec §3): one pick→preview→commit pipeline drives two-point, linear ×N and
1453
+ // array N×M. Same shell as the Dimension tool: armed flag routes the pointer handlers, the preview
1454
+ // lives in its own <g> (no render() until commit), commits go through edit()/pushUndo. ---
1455
+ let cmTool=null,cmArrayMode=false,cmDraft=null,cmAxis='free',cmCount=1,cmCountA=2,cmCountB=2,cmLastPtr=null,cmLastEvt=null;
1456
+ // cmDraft: {base:[x,y], vA:null} — array mode sets vA=[dx,dy,0] once direction A is picked/typed
1457
+ function setCmUi(){document.body.classList.toggle('cmon',!!cmTool);
1458
+ document.getElementById('mvB').classList.toggle('on',cmTool==='move');
1459
+ document.getElementById('mvCaret').classList.toggle('on',cmTool==='move');
1460
+ document.getElementById('cpB').classList.toggle('on',cmTool==='copy');
1461
+ document.getElementById('cpCaret').classList.toggle('on',cmTool==='copy');}
1462
+ function disarmCm(){cmTool=null;cmArrayMode=false;cmDraft=null;cmHudClose();cmPrevClear();setCmUi();}
1463
+ function armCm(tool,array){ // re-arming the same config toggles the tool off
1464
+ if(view3d){toast('Move/Copy in 3D lands with the working plane (next slice) — switch to 2D');return;}
1465
+ if(cmTool===tool&&cmArrayMode===!!array){disarmCm();render();return;}
1466
+ if(!selIds.size){toast('Select members first, then '+(tool==='move'?'Move':'Copy'));return;}
1467
+ if(mode==='add'){mode='sel';setMode();}
1468
+ if(dimMode){dimMode=false;setDimMode();}
1469
+ if(csaxisMode){csaxisMode=false;setCsMode();}
1470
+ geoMode=null;setGeo();selDimIds.clear();dimSplitMode=false;picking=false;pickEnd=null;
1471
+ cmHudClose();cmPrevClear(); // switching Move⇄Copy mid-pick must drop the old HUD/preview — a stale HUD Enter would commit for the previous tool
1472
+ cmTool=tool;cmArrayMode=!!array&&tool==='copy';cmDraft=null;cmAxis='free';setCmUi();render();
1473
+ setLastCmd(tool==='move'?'Move':(cmArrayMode?'Copy array':'Copy'),()=>{if(!cmTool)armCm(tool,array);});}
1474
+ function cmHintText(){if(!cmDraft)return (cmTool==='move'?'Move':'Copy')+' — click the base point (a snapped end, grid or line point).';
1475
+ if(cmArrayMode&&!cmDraft.vA)return 'Click the direction-A point — or type its spacing.';
1476
+ if(cmArrayMode)return 'Click the direction-B point (grid previews live) — or type its spacing.';
1477
+ return 'Click the destination — or just start typing an exact distance / dx,dy. The tool stays armed; Esc finishes.';}
1478
+ // picks: snap (Alt off) → Shift ortho from the base → X/Y axis force projects onto the (local-frame) axis
1479
+ function cmSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y,hit=false;
1480
+ if(e.shiftKey&&cmDraft){[x,y]=orthoLock(cmDraft.base[0],cmDraft.base[1],x,y);snapClear();}
1481
+ else if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;hit=sn.hit;}
1482
+ if(cmDraft&&cmAxis!=='free'){const b=cmDraft.base,u=P.frame?P.frame.u:[1,0],v=[u[1],-u[0]],dx=x-b[0],dy=y-b[1];
1483
+ if(cmAxis==='x'){const du=dx*u[0]+dy*u[1];x=b[0]+du*u[0];y=b[1]+du*u[1];}
1484
+ else{const dv=dx*v[0]+dy*v[1];x=b[0]+dv*v[0];y=b[1]+dv*v[1];}hit=false;}
1485
+ return {x,y,hit};}
1486
+ function cmOffsetsFor(v){if(cmArrayMode)return cmDraft&&cmDraft.vA?arrayOffsets(cmDraft.vA,cmCountA,v,cmCountB):linearOffsets(v,Math.max(0,cmCountA-1)); // stage A previews countA-1 NEW copies — counts include the original, so the ghost count matches the commit
1487
+ return cmTool==='copy'?linearOffsets(v,cmCount):[v];}
1488
+ function cmSelBbox(){let x0=1e15,y0=1e15,x1=-1e15,y1=-1e15;for(const m of selArr())for(const p of m.wp){x0=Math.min(x0,p[0]);y0=Math.min(y0,p[1]);x1=Math.max(x1,p[0]);y1=Math.max(y1,p[1]);}return [x0,y0,x1,y1];}
1489
+ function cmPrevClear(){const g=document.getElementById('cmPrevG');if(g)g.remove();snapClear();}
1490
+ function cmPrev(e){if(!cmTool)return;cmLastEvt=e;
1491
+ {const h=document.getElementById('cmHint');if(h)h.textContent=cmHintText();}
1492
+ let g=document.getElementById('cmPrevG');
1493
+ const s=cmSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear();
1494
+ if(!cmDraft){if(g)g.remove();return;} // before the base pick: marker only
1495
+ cmLastPtr=[s.x,s.y];
1496
+ if(!g){g=document.createElementNS(SVGNS,'g');g.id='cmPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
1497
+ g.innerHTML=cmPrevSvg([s.x-cmDraft.base[0],s.y-cmDraft.base[1],0],[s.x,s.y]);}
1498
+ function cmRefreshPrev(){if(cmTool&&cmDraft&&cmLastEvt)cmPrev(cmLastEvt);}
1499
+ // ghosts: every selected member at every offset (cap 600 lines → per-offset bbox), then the rubber
1500
+ // line + arrowhead + ft-in chip. Array stage B keeps direction A's rubber dimmed for context.
1501
+ function cmPrevSvg(v,cur){const sel=selArr(),offs=cmOffsetsFor(v);let s='';
1502
+ const many=sel.length*offs.length>600,bb=many?cmSelBbox():null;
1503
+ for(const off of offs){
1504
+ if(many){s+=`<rect class=cmghostbox x="${bb[0]+off[0]}" y="${bb[1]+off[1]}" width="${bb[2]-bb[0]}" height="${bb[3]-bb[1]}"/>`;continue;}
1505
+ for(const m of sel)s+=`<line class=cmghost stroke="${esc(colorFor(m.profile))}" x1="${+m.wp[0][0]+off[0]}" y1="${+m.wp[0][1]+off[1]}" x2="${+m.wp[1][0]+off[0]}" y2="${+m.wp[1][1]+off[1]}"/>`;} // + coerces: a malformed contract string can't reach the attribute
1506
+ const b=cmDraft.base;
1507
+ if(cmArrayMode&&cmDraft.vA)s+=cmRubSvg(b,[b[0]+cmDraft.vA[0],b[1]+cmDraft.vA[1]],true);
1508
+ s+=cmRubSvg(b,cur,false);
1509
+ return s;}
1510
+ function cmRubSvg(a,c,dimmed){const L=Math.hypot(c[0]-a[0],c[1]-a[1]);if(L<1e-6)return '';
1511
+ const ux=(c[0]-a[0])/L,uy=(c[1]-a[1])/L,ah=10/zoom;
1512
+ const p1=[c[0]-ah*ux+ah*.45*uy,c[1]-ah*uy-ah*.45*ux],p2=[c[0]-ah*ux-ah*.45*uy,c[1]-ah*uy+ah*.45*ux];
1513
+ const mid=[(a[0]+c[0])/2,(a[1]+c[1])/2],txt=esc(fmtFtIn(L/FT*12)),cw=txt.length*7+14,ch=17;
1514
+ return `<line class=cmrub${dimmed?' style="opacity:.35"':''} x1="${a[0]}" y1="${a[1]}" x2="${c[0]}" y2="${c[1]}"/>`
1515
+ +`<path class=cmarrow${dimmed?' style="opacity:.35"':''} d="M ${c[0]} ${c[1]} L ${p1[0]} ${p1[1]} L ${p2[0]} ${p2[1]} Z"/>`
1516
+ +(dimmed?'':`<rect class=cmchip x="${mid[0]-cw/2/zoom}" y="${mid[1]-ch/2/zoom}" width="${cw/zoom}" height="${ch/zoom}" rx="${4/zoom}"/><text class=cmtx x="${mid[0]}" y="${mid[1]}" style="font-size:${12/zoom}px">${txt}</text>`);}
1517
+ function cmClick(e){const s=cmSnapAt(e);
1518
+ if(!cmDraft){cmDraft={base:[s.x,s.y],vA:null};cmPrev(e);panel();return;}
1519
+ const v=[s.x-cmDraft.base[0],s.y-cmDraft.base[1],0];
1520
+ if(Math.hypot(v[0],v[1])<0.5)return; // ignore a zero-distance double-click
1521
+ if(cmArrayMode&&!cmDraft.vA){cmDraft.vA=v;cmPrev(e);panel();return;} // array: this click fixes direction A
1522
+ cmCommit(v);}
1523
+ function cmCommit(v){const sel=selArr();if(!sel.length){toast('Selection is empty — '+(cmTool==='move'?'Move':'Copy')+' ended');disarmCm();render();return;}
1524
+ if(cmArrayMode&&cmCountA*cmCountB>100){toast('Array capped at 100 instances — lower the counts (two passes reach further)');return;}
1525
+ const offs=cmOffsetsFor(v);
1526
+ edit(()=>{
1527
+ if(cmTool==='move'){translateMembers(sel,v);}
1528
+ else{const ns=new Set();
1529
+ for(const off of offs)for(const m of sel){const c=cloneMember(m);translateMembers([c],off);P.members.push(c);ns.add(c.id);}
1530
+ selIds=ns;}}); // copy selects the new set (numbered badges)
1531
+ toast((cmTool==='move'?'Moved ':'Copied ')+sel.length+' member'+(sel.length===1?'':'s')+(offs.length>1?' ×'+offs.length:'')+' · '+fmtFtIn(Math.hypot(v[0],v[1])/FT*12));
1532
+ cmHudClose();cmDraft=null;cmPrevClear();panel();} // tool stays armed for the next pick pair; Esc finishes
1533
+ // --- numeric HUD (spec §3.3): type after the base pick → exact distance or dx,dy[,dz] (dz inches).
1534
+ // Live parse echo while typing; unparseable Enter flags the chip red (inline, not just a toast). ---
1535
+ let cmHudEl=null;
1536
+ function cmHudIsOpen(){return !!(cmHudEl&&cmHudEl.style.display!=='none');}
1537
+ function cmHudClose(){if(cmHudEl){cmHudEl.style.display='none';cmHudEl.classList.remove('err');const i=cmHudEl.querySelector('input');if(i)i.value='';}try{stage.focus({preventScroll:true});}catch(_){}}
1538
+ function cmHudEcho(){const inp=cmHudEl.querySelector('input'),lbl=cmHudEl.querySelector('#cmHudLbl');
1539
+ const pv=parseVec(inp.value),n=(cmTool==='copy'&&!cmArrayMode&&cmCount>1)?(' ×'+cmCount):'';
1540
+ lbl.textContent=(pv?(pv.dist!=null?('= '+fmtFtIn(pv.dist)):('= '+pv.comp.map(fmtFtIn).join(', ')))+n:n.trim());
1541
+ cmHudEl.classList.remove('err');}
1542
+ function cmHudShow(seed){if(!cmDraft)return;
1543
+ if(!cmHudEl){cmHudEl=document.createElement('div');cmHudEl.id='cmHud';
1544
+ const inp=document.createElement('input');inp.placeholder="12'-6 · or dx,dy";inp.autocomplete='off';inp.spellcheck=false;
1545
+ const lbl=document.createElement('span');lbl.id='cmHudLbl';
1546
+ cmHudEl.append(inp,lbl);document.body.appendChild(cmHudEl);
1547
+ inp.addEventListener('input',cmHudEcho);
1548
+ inp.addEventListener('keydown',ev=>{ev.stopPropagation();
1549
+ if(ev.key==='Enter'){ev.preventDefault();cmHudCommit(inp.value);}
1550
+ else if(ev.key==='Escape'){ev.preventDefault();cmHudClose();}});}
1551
+ const scr=cmLastEvt?[cmLastEvt.clientX,cmLastEvt.clientY]:[innerWidth/2,innerHeight/2];
1552
+ cmHudEl.style.left=Math.min(scr[0]+16,innerWidth-200)+'px';cmHudEl.style.top=Math.min(scr[1]+16,innerHeight-46)+'px';
1553
+ cmHudEl.style.display='flex';cmHudEl.classList.remove('err');
1554
+ const inp=cmHudEl.querySelector('input');inp.value=seed||'';cmHudEcho();inp.focus();
1555
+ try{inp.setSelectionRange(inp.value.length,inp.value.length);}catch(_){}}
1556
+ function cmHudCommit(str){const pv=parseVec(str);
1557
+ if(!pv){cmHudEl.classList.add('err');toast('Enter a length (12\'-6) or components (dx,dy)');return;}
1558
+ let v;
1559
+ if(pv.comp){const u=P.frame?P.frame.u:[1,0],vv=[u[1],-u[0]]; // typed components live in the local frame; +Y = up the plan
1560
+ const dxp=pv.comp[0]*FT/12,dyp=pv.comp[1]*FT/12;
1561
+ v=[dxp*u[0]+dyp*vv[0],dxp*u[1]+dyp*vv[1],pv.comp[2]||0];}
1562
+ else{if(!cmLastPtr){cmHudEl.classList.add('err');toast('Move the mouse to aim, or type dx,dy');return;}
1563
+ const dir=[cmLastPtr[0]-cmDraft.base[0],cmLastPtr[1]-cmDraft.base[1]],L=Math.hypot(dir[0],dir[1]);
1564
+ if(L<1e-6){cmHudEl.classList.add('err');toast('Move the mouse to aim, or type dx,dy');return;}
1565
+ const d=pv.dist*FT/12;v=[dir[0]/L*d,dir[1]/L*d,0];}
1566
+ if(Math.hypot(v[0],v[1])<0.5&&Math.abs(v[2]||0)<1e-9){cmHudEl.classList.add('err');toast('Zero distance — type a non-zero value');return;} // a pure-dz move is legit; a true zero would stack clones / push a no-op undo
1567
+ if(cmArrayMode&&!cmDraft.vA){cmDraft.vA=v;cmHudClose();cmRefreshPrev();panel();return;} // typed spacing commits direction A
1568
+ cmHudClose();cmCommit(v);}
1569
+ // --- Move/Copy to level (spec §4.5): themed plan picker, click-row selection (no native radios),
1570
+ // double-click commits. Cross-plan undo via snapshotWith. Joints stay behind (same as Ctrl+D). ---
1571
+ let lvModal=null;
1572
+ function lvOpen(){return !!lvModal;}
1573
+ function closeLevelModal(){if(lvModal){lvModal.remove();lvModal=null;}}
1574
+ function openLevelModal(tool){if(view3d){toast('Switch to 2D to move/copy between levels');return;}
1575
+ if(!selIds.size){toast('Select members first, then '+(tool==='move'?'Move':'Copy')+' to level');return;}
1576
+ if(C.plans.length<2){toast('Only one plan is loaded — nothing to '+tool+' to');return;}
1577
+ closeLevelModal();
1578
+ lvModal=document.createElement('div');lvModal.id='lvModal';lvModal.style.cssText='position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center'; // same flex-centering + layer as the sibling modals — the toast (z 60) must stay visible above it
1579
+ const bd=document.createElement('div');bd.className='mbackdrop';bd.onclick=closeLevelModal;
1580
+ const pn=document.createElement('div');pn.className='mpanel';pn.style.maxWidth='440px';
1581
+ const hd=document.createElement('div');hd.className='mhead';
1582
+ const tt=document.createElement('b');tt.textContent=(tool==='move'?'Move':'Copy')+' '+selIds.size+' member'+(selIds.size===1?'':'s')+' to level';
1583
+ const xb=document.createElement('button');xb.textContent='✕';xb.onclick=closeLevelModal;
1584
+ hd.append(tt,xb);
1585
+ const body=document.createElement('div');body.style.cssText='padding:12px 14px;max-height:50vh;overflow:auto;display:flex;flex-direction:column;gap:2px';
1586
+ let pick=-1;const rows=[];
1587
+ const go=document.createElement('button');go.className='primary';go.disabled=true;go.textContent=tool==='move'?'Move here':'Copy here';
1588
+ C.plans.forEach((pl,i)=>{const row=document.createElement('div');row.className='lvrow'+(pl===P?' cur':'');
1589
+ const nm=document.createElement('span');nm.textContent=pl.sheet+(pl.title?' · '+pl.title:'')+(pl===P?' (current)':'');
1590
+ const ts=document.createElement('span');ts.className='lvtos';const nM=(pl.members||[]).length;ts.textContent=nM+' member'+(nM===1?'':'s')+' · TOS '+fmtFtIn(pl.default_tos!=null?pl.default_tos:198);
1591
+ row.append(nm,ts);
1592
+ if(pl!==P){row.onclick=()=>{pick=i;rows.forEach(r=>r.classList.remove('pick'));row.classList.add('pick');go.disabled=false;};
1593
+ row.ondblclick=()=>{pick=i;toLevelCommit(tool,i);closeLevelModal();};}
1594
+ rows.push(row);body.appendChild(row);});
1595
+ const note=document.createElement('div');note.className='hint';note.style.cssText='padding:0 14px 4px';
1596
+ note.textContent=tool==='move'?'A move keeps each member’s connections attached.':'Copies don’t carry connections — detail them on the target level.';
1597
+ const ft=document.createElement('div');ft.style.cssText='display:flex;justify-content:flex-end;gap:8px;padding:12px 14px;border-top:1px solid var(--line)';
1598
+ const cancel=document.createElement('button');cancel.textContent='Cancel';cancel.onclick=closeLevelModal;
1599
+ go.onclick=()=>{if(pick>=0){toLevelCommit(tool,pick);closeLevelModal();}};
1600
+ ft.append(cancel,go);pn.append(hd,body,note,ft);lvModal.append(bd,pn);document.body.appendChild(lvModal);}
1601
+ function toLevelCommit(tool,ti){const sel=selArr();const T=C.plans[ti];
1602
+ if(!sel.length){toast('Selection is empty — nothing to '+(tool==='move'?'move':'copy'));return;}
1603
+ if(!T||T===P)return;
1604
+ if(!Array.isArray(T.members))T.members=[];
1605
+ const dstDef=(T.default_tos!=null?T.default_tos:198);
1606
+ const pv=snapshotWith(T),pvT=snapshotForPlan(T,P,tool==='move'?[...selIds]:[]); // both captured pre-mutation; a move's ids ride the mirror so their joints survive a target-side undo
1607
+ const moved=[];
1608
+ for(const m of sel){const c=(tool==='copy')?cloneMember(m):m;retargetTos(c,defaultTOS,dstDef);moved.push(c);}
1609
+ if(tool==='move'){P.members=P.members.filter(m=>!selIds.has(m.id));selIds=new Set();}
1610
+ T.members.push(...moved);T.members.forEach(ensureMeta);
1611
+ pushUndo(pv);pushUndoFor(T,pvT);disarmCm();render();sync3D(); // disarm BEFORE render so the panel never advertises a dead tool
1612
+ toast((tool==='move'?'Moved ':'Copied ')+moved.length+' member'+(moved.length===1?'':'s')+' to '+T.sheet);}
1344
1613
  // --- Local coordinate system "set axes" tool: two snapped clicks (origin, then X-direction) define
1345
1614
  // P.frame={o,u}. Shares buildSnap/snap/snapMark; preview lives in its own <g> (render() rebuilds wholesale). ---
1346
1615
  function setCsMode(){document.body.classList.toggle('csaxison',csaxisMode);
1347
1616
  const b=document.getElementById('csSetB');if(b)b.classList.toggle('on',csaxisMode);
1348
- if(csaxisMode){if(mode==='add'){mode='sel';setMode();}if(dimMode){dimMode=false;setDimMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();} // arming disarms conflicting tools + clears selection
1617
+ if(csaxisMode){if(mode==='add'){mode='sel';setMode();}if(dimMode){dimMode=false;setDimMode();}if(cmTool)disarmCm();geoMode=null;setGeo();selIds.clear();selDimIds.clear();} // arming disarms conflicting tools + clears selection
1349
1618
  csDraft=null;csPrevClear();}
1350
1619
  function csSnapAt(e){const q=toSvg(e);if(e.altKey)return [q.x,q.y];buildSnap(null);const sn=snap(q.x,q.y);return [sn.x,sn.y];}
1351
1620
  function csClick(e){const p=csSnapAt(e);
@@ -1395,6 +1664,7 @@ function toast(msg){let t=document.getElementById('toast');
1395
1664
  svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1396
1665
  if(csaxisMode){csClick(e);e.preventDefault();return;} // set-local-axes armed → clicks define origin then X-direction
1397
1666
  if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
1667
+ if(cmTool){cmClick(e);e.preventDefault();return;} // Move/Copy armed → clicks pick base/destination
1398
1668
  if(dimSplitMode&&selDimIds.size&&dimsVisible&&mode==='sel'){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;}snapClear();dimSplitAt([x,y]);e.preventDefault();return;} // split mode → each click inserts a point into the selected dim under it
1399
1669
  if(t.dataset.dimend!=null&&mode==='sel'){const id=t.dataset.dim,end=+t.dataset.dimend;buildSnap(id);drag={type:'dimend',id,end,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // drag a selected dim's anchor handle → re-measure (snaps; exclude this dim so the end can't snap to its own line/midpoint)
1400
1670
  if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
@@ -1443,6 +1713,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1443
1713
  svg.addEventListener('pointermove',e=>{
1444
1714
  if(csaxisMode){csPrev(e);return;}
1445
1715
  if(dimMode){dimPrev(e);return;}
1716
+ if(cmTool){cmPrev(e);return;}
1446
1717
  if(dimSplitMode&&dimsVisible){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);sn.hit?snapMark(sn.x,sn.y):snapClear();}else snapClear();return;} // split mode → show the snap marker for the prospective split point
1447
1718
  if(geoMode==='split'&&selIds.size===1&&!drag){const m=byId([...selIds][0]);if(m){const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
1448
1719
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
@@ -1485,7 +1756,7 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
1485
1756
  drag.rect.remove();drag=null;render();return;}
1486
1757
  if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
1487
1758
  function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
1488
- document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();setLastCmd('Add member',()=>{if(mode!=='add'){if(dimMode){dimMode=false;setDimMode();}mode='add';selIds.clear();setMode();render();}});}; // entering add/sel disarms the Dimension + set-axes tools (else they keep eating canvas clicks) and drops any dim selection
1759
+ document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}if(cmTool)disarmCm();selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();setLastCmd('Add member',()=>{if(mode!=='add'){if(dimMode){dimMode=false;setDimMode();}mode='add';selIds.clear();setMode();render();}});}; // entering add/sel disarms the Dimension + set-axes tools (else they keep eating canvas clicks) and drops any dim selection
1489
1760
  document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();setLastCmd('Dimension',()=>{if(!dimMode){dimMode=true;setDimMode();render();}});};
1490
1761
  document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
1491
1762
  document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
@@ -1511,6 +1782,8 @@ document.getElementById('redoB').onclick=doRedo;
1511
1782
  addEventListener('keydown',e=>{
1512
1783
  const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
1513
1784
  if(e.key==='Escape'&&moreOpen()){closeMore();moreBtn.focus();return;}
1785
+ if(e.key==='Escape'&&(mvMenuC.isOpen()||cpMenuC.isOpen())){mvMenuC.close();cpMenuC.close();return;}
1786
+ if(lvOpen()){if(e.key==='Escape')closeLevelModal();return;} // the level modal is modal: no Delete/undo/tool keys mutate state underneath it
1514
1787
  if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
1515
1788
  if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
1516
1789
  if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
@@ -1521,6 +1794,7 @@ addEventListener('keydown',e=>{
1521
1794
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
1522
1795
  if(!view3d&&!inForm&&(((e.key==='z'||e.key==='Z')&&e.altKey)||(e.key===' '&&e.shiftKey))){e.preventDefault();zoomToSelection();return;} // 2D zoom-to-selected (Tekla Shift+Space / Alt+Z); 3D handles its own (steel-3d-view onKey)
1523
1796
  if((e.key===' '||e.key==='Enter')&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&!e.shiftKey){if(!anyToolActive()&&lastCmd){e.preventDefault();repeatLast();}return;} // idle Space/Enter → repeat the last command (AutoCAD/Tekla)
1797
+ if(e.key==='Escape'&&cmTool){if(cmHudIsOpen())cmHudClose();else if(cmDraft&&cmDraft.vA){cmDraft.vA=null;cmRefreshPrev();panel();}else if(cmDraft){cmDraft=null;cmPrevClear();panel();}else{disarmCm();render();}return;} // Move/Copy Esc ladder: HUD → array dir A → base pick → exit tool
1524
1798
  if(e.key==='Escape'&&csaxisMode){if(csDraft){csDraft=null;csPrevClear();}else{csaxisMode=false;setCsMode();}render();return;} // 1st Esc drops the in-progress origin, 2nd exits set-axes
1525
1799
  if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
1526
1800
  if(e.key==='Escape'&&dimMode){if(dimChainPrev){dimChainPrev=null;dimPrevClear();}else if(dimDraft){dimDraft=null;dimPrevClear();}else{dimMode=false;setDimMode();}render();return;} // Esc: end the chain → drop the in-progress dim → exit the tool
@@ -1535,6 +1809,12 @@ addEventListener('keydown',e=>{
1535
1809
  if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
1536
1810
  if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
1537
1811
  if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
1812
+ if(cmTool&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const ak=e.key.toLowerCase(); // Move/Copy armed: X/Y/F force the axis, typing opens the exact-value HUD
1813
+ if(ak==='x'||ak==='y'||ak==='f'){e.preventDefault();cmAxis=ak==='f'?'free':ak;cmRefreshPrev();return;}
1814
+ if(cmDraft&&/^[0-9.\-']$/.test(e.key)){e.preventDefault();cmHudShow(e.key);return;}}
1815
+ if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&!view3d&&!dimMode){const ck=e.key.toLowerCase(); // M / C — arm Move / Copy (mirrors D for dims; dim-armed keys win above)
1816
+ if(ck==='m'){e.preventDefault();armCm('move',false);return;}
1817
+ if(ck==='c'){e.preventDefault();armCm('copy',false);return;}}
1538
1818
  if(!inForm&&selDimIds.size>=1&&!selIds.size&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='s'){e.preventDefault();toggleDimSplit();return;} // S — toggle "add split point" on the selected dimension(s)
1539
1819
  if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
1540
1820
  if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();setLastCmd('Extend/Trim',()=>{geoMode='el';setGeo();render();});return;} // Extend/Trim — any selection
@@ -1558,6 +1838,21 @@ function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)clos
1558
1838
  function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
1559
1839
  moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
1560
1840
  moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu
1841
+ // --- Move/Copy split-button dropdowns: same open/close discipline as the ⋯ menu ---
1842
+ function wireCmMenu(caretId,menuId){const b=document.getElementById(caretId),m=document.getElementById(menuId);
1843
+ const close=()=>{m.classList.remove('open');b.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',out,true);};
1844
+ const out=e=>{if(!m.contains(e.target)&&e.target!==b)close();};
1845
+ b.onclick=e=>{e.stopPropagation();if(m.classList.contains('open'))close();else{m.classList.add('open');b.setAttribute('aria-expanded','true');document.addEventListener('mousedown',out,true);}};
1846
+ m.addEventListener('click',e=>{if(e.target.closest('button'))close();});
1847
+ return {close,isOpen:()=>m.classList.contains('open')};}
1848
+ const mvMenuC=wireCmMenu('mvCaret','mvMenu'),cpMenuC=wireCmMenu('cpCaret','cpMenu');
1849
+ document.getElementById('mvB').onclick=()=>armCm('move',false);
1850
+ document.getElementById('cpB').onclick=()=>armCm('copy',false);
1851
+ document.getElementById('mvTwoB').onclick=()=>armCm('move',false);
1852
+ document.getElementById('cpTwoB').onclick=()=>armCm('copy',false);
1853
+ document.getElementById('cpArrB').onclick=()=>armCm('copy',true);
1854
+ document.getElementById('mvLevelB').onclick=()=>openLevelModal('move');
1855
+ document.getElementById('cpLevelB').onclick=()=>openLevelModal('copy');
1561
1856
  // --- 2D|3D view toggle. 3D is rendered by Steel3DView (Three.js, loaded as a module → window). It
1562
1857
  // fetches the SAME scene the bake uses (/api/contract/:id/scene) so 2D and 3D show one contract. ---
1563
1858
  // Coped beams (auto, clash-driven) — cache the cut labels per member as the scene is fetched so the
@@ -1876,11 +2171,13 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
1876
2171
  document.getElementById('planSel').style.display=on?'none':''; // plan selector is 2D-only (3D shows the whole model)
1877
2172
  document.getElementById('m3dBar').style.display=on?'flex':'none';
1878
2173
  document.getElementById('m3dCube').style.display=on?'block':'none';
2174
+ document.getElementById('m3dAxes').style.display=on?'block':'none';
1879
2175
  if(!on)document.getElementById('m3dLegend').style.display='none'; // legend is shown by build3DLegend when entering 3D
1880
2176
  }
1881
2177
  async function setView(on){
1882
2178
  if(on){
1883
2179
  if(dimMode||dimChain||dimSplitMode){dimMode=dimChain=dimSplitMode=false;selDimIds.clear();setDimMode();document.body.classList.remove('dimsplit');} // entering 3D disarms the 2D dim/chain/split tools so their X/Y/F/S/Esc keys don't double-fire with the 3D tool
2180
+ if(cmTool){disarmCm();render();} // Move/Copy is 2D-only until the working-plane slice — disarm so its keys don't shadow the 3D tools
1884
2181
  if(selIds.size)sel3dDimIds.clear(); // a member selected in 2D wins over a stale 3D-dim selection
1885
2182
  if(!window.Steel3DView){toast('3D view unavailable (renderer failed to load)');return;} // stay in 2D
1886
2183
  try{
@@ -2375,6 +2672,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
2375
2672
  selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
2376
2673
  dimMode=false;dimChain=false;dimSplitMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain + split); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
2377
2674
  csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
2675
+ disarmCm(); // Move/Copy resets per plan too (its counts persist — they're session prefs, not plan data)
2378
2676
  defaultTOS=(P.default_tos!=null?P.default_tos:198);
2379
2677
  P.default_tos=defaultTOS; // make the default explicit so the 3D scene + bake use the SAME TOS the editor/dots do (contractToScene falls back to 0, not 198 — keeping them in sync stops the end dots floating off the steel)
2380
2678
  if(!P.autofilled){autofillTOS();P.autofilled=true;}
@@ -2428,6 +2726,40 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
2428
2726
  const d1=snap(50,18);ok(d1.hit&&d1.kind==='mid'&&pnear([d1.x,d1.y],[50,20]),'snap → dimension-line midpoint (△)');
2429
2727
  buildSnap('td');const d2=snap(50,18);ok(!d2.hit,'snap → excluded dim cannot self-snap');
2430
2728
  P.members=sm;P.segments=ss;P.dims=sd;dimsVisible=sdv;zoom=sz;}
2729
+ // 7) Move/Copy transform core (spec §4.1)
2730
+ ok((()=>{const o=linearOffsets([10,0,2],3);return o.length===3&&pnear([o[2][0],o[2][1]],[30,0])&&near(o[2][2],6);})(),'linearOffsets scales all 3 comps');
2731
+ ok((()=>{const o=arrayOffsets([10,0,0],2,[0,5,0],3);return o.length===5&&o.every(q=>!(q[0]===0&&q[1]===0))&&pnear([o[4][0],o[4][1]],[10,10]);})(),'arrayOffsets n*m-1, no zero, grid sum');
2732
+ ok((()=>{const m={id:'t',role:'beam',wp:[[0,0],[10,0]],ends:[{tos:null,tosDef:true},{tos:100,tosDef:false}]};translateMembers([m],[5,-3,12]);
2733
+ return pnear(m.wp[0],[5,-3])&&pnear(m.wp[1],[15,-3])&&near(m.ends[0].tos,defaultTOS+12)&&m.ends[0].tosDef===false&&near(m.ends[1].tos,112);})(),'translateMembers rigid dz');
2734
+ ok((()=>{const m={id:'t2',role:'beam',wp:[[0,0],[10,0]],ends:[{tos:null,tosDef:true},{tos:null,tosDef:true}]};translateMembers([m],[5,0,0]);
2735
+ return m.ends[0].tosDef===true&&pnear(m.wp[0],[5,0]);})(),'translateMembers dz=0 keeps default-follow');
2736
+ ok((()=>{const m={id:'tc',role:'column',wp:[[0,0],[0,0]],ends:[{},{}],col:{bos:0,tos:198,tosDef:false}};translateMembers([m],[0,0,24]);
2737
+ return near(m.col.bos,24)&&near(m.col.tos,222);})(),'translateMembers column rigid (bos+tos)');
2738
+ ok((()=>{const src={id:'a',wp:[[1,2],[3,4]],ends:[{tos:1}]};const c=cloneMember(src);c.wp[0][0]=99;
2739
+ return c.id!=='a'&&src.wp[0][0]===1;})(),'cloneMember new id, deep copy');
2740
+ ok((()=>{const v=parseVec("16'");return !!v&&near(v.dist,192);})(),'parseVec single ft → inches');
2741
+ ok((()=>{const v=parseVec("12,-6,3");return !!v&&near(v.comp[0],12)&&near(v.comp[1],-6)&&near(v.comp[2],3);})(),'parseVec components');
2742
+ ok(parseVec('abc')===null&&parseVec('1,x')===null&&parseVec('')===null&&parseVec('1,2,3,4')===null,'parseVec rejects garbage');
2743
+ ok((()=>{const m={id:'c1',role:'column',wp:[[0,0],[0,0]],ends:[{},{}],col:{bos:0,tos:null,tosDef:true}};retargetTos(m,198,396);
2744
+ return near(m.col.tos,396)&&near(m.col.bos,198);})(),'retargetTos column keeps height');
2745
+ ok((()=>{const m={id:'b1',role:'beam',wp:[[0,0],[1,0]],ends:[{tos:null,tosDef:true},{tos:190,tosDef:false}]};retargetTos(m,198,396);
2746
+ return m.ends[0].tosDef===true&&near(m.ends[0].tos,396)&&near(m.ends[1].tos,388);})(),'retargetTos beam: default follows, override Δ-shifts');
2747
+ // 8) cross-plan undo slice (snapshotWith / others restore / redo mirror) — fake plans carry every
2748
+ // field apply()→render() touches; state is restored (and re-rendered) in finally.
2749
+ ok((()=>{const sp=C.plans,sA=C.active,sP=P,su=undo,sr=redo,ss=selIds,sj=C.joints;let r=false;
2750
+ try{const a1={id:'a1',wp:[[0,0],[1,0]]};
2751
+ const A={sheet:'__tA',members:[a1],segments:[],dims:[]},B={sheet:'__tB',members:[{id:'b1',wp:[[0,0],[1,0]]}],segments:[],dims:[]};
2752
+ C.plans=[A,B];P=A;undo=[];redo=[];selIds=new Set();C.joints=[{main:'a1',type:'__t'}];
2753
+ const pv=snapshotWith(B),pvT=snapshotForPlan(B,A,['a1']); // source entry + the target-side mirror (a MOVE carries a1's joints)
2754
+ A.members=[];B.members.push(a1); // the "commit": a1 MOVES A→B (same id, joint attached)
2755
+ const post=snapshotLike(pv); // what redo must restore
2756
+ apply(pv); r=A.members.length===1&&B.members.length===1; // undo restored BOTH
2757
+ apply(post); r=r&&A.members.length===0&&B.members.length===2; // redo restored BOTH
2758
+ P=B; apply(pvT); // undo FROM THE TARGET side
2759
+ r=r&&B.members.length===1&&A.members.length===1; // both plans restored (no stranded member)
2760
+ r=r&&C.joints.length===1&&C.joints[0].main==='a1'; // …and the moved member kept its connection
2761
+ }finally{C.plans=sp;C.active=sA;P=sP;undo=su;redo=sr;selIds=ss;C.joints=sj;render();}
2762
+ return r;})(),'cross-plan undo+redo restores both plans + moved joints');
2431
2763
  const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (local-frame math)';
2432
2764
  console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
2433
2765
  })();}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {