@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.
package/dist/floless-server.cjs
CHANGED
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
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
|
-
|
|
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;
|
|
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||[]);}
|
|
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(
|
|
616
|
-
function doRedo(){if(!redo.length)return;undo.push(
|
|
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 & 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
|
})();}
|