@floless/app 0.66.1 → 0.67.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.67.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.67.0" : void 0 });
|
|
53036
53036
|
}
|
|
53037
53037
|
|
|
53038
53038
|
// workflow-update.ts
|
|
@@ -53892,6 +53892,7 @@ function bakeContractIntoApp(sourcePath, contract) {
|
|
|
53892
53892
|
doc.setIn(["nodes", idx, "config", "contract"], contract.type);
|
|
53893
53893
|
const baked = { ...contract };
|
|
53894
53894
|
delete baked.dims3d;
|
|
53895
|
+
delete baked.detail_placements;
|
|
53895
53896
|
if (Array.isArray(contract.plans)) {
|
|
53896
53897
|
baked.plans = contract.plans.map((p) => {
|
|
53897
53898
|
if (p && typeof p === "object") {
|
|
@@ -64852,7 +64853,7 @@ async function startServer() {
|
|
|
64852
64853
|
"/api/contract-request",
|
|
64853
64854
|
{ bodyLimit: 25 * 1024 * 1024 },
|
|
64854
64855
|
async (req, reply) => {
|
|
64855
|
-
const { appId, instruction, snapshots } = req.body ?? {};
|
|
64856
|
+
const { appId, instruction, intent, target, snapshots } = req.body ?? {};
|
|
64856
64857
|
if (!appId || !instruction) {
|
|
64857
64858
|
return reply.status(400).send({ ok: false, error: "appId, instruction required" });
|
|
64858
64859
|
}
|
|
@@ -64862,7 +64863,10 @@ async function startServer() {
|
|
|
64862
64863
|
} catch (e) {
|
|
64863
64864
|
return reply.status(400).send({ ok: false, error: e instanceof Error ? e.message : "bad snapshot" });
|
|
64864
64865
|
}
|
|
64865
|
-
const request = addRequest(
|
|
64866
|
+
const request = addRequest(
|
|
64867
|
+
{ type: "tweak-contract", appId, instruction, ...intent ? { intent } : {}, ...target ? { target } : {} },
|
|
64868
|
+
decoded
|
|
64869
|
+
);
|
|
64866
64870
|
broadcast({ type: "request-added", request });
|
|
64867
64871
|
return { ok: true, request };
|
|
64868
64872
|
}
|
|
@@ -40,9 +40,27 @@
|
|
|
40
40
|
"filter": { "$ref": "#/$defs/filter", "description": "Layer/thickness/colour filter pre-stage. `sheets[]` holds per-framing-plan geometry; the facets + `mode` + on-flag selection are GLOBAL across sheets. The saved selection scopes which lines the reader binds profile labels to per sheet; replaces the length heuristic, fallback when absent. CONFIDENTIAL: each sheet's `page.bg_b64` is machine-local, never committed." },
|
|
41
41
|
"connections": { "type": "array", "items": { "$ref": "#/$defs/connection" }, "description": "Vendor-neutral connection library: each row maps a connection type → the firm's design detail# → per-platform component ids. Member ends/columns reference a row by id via `ends[].conn` / `col.conn` (legacy `note`/`detail` = fallback when `conn` is empty)." },
|
|
42
42
|
"joints": { "type": "array", "items": { "$ref": "#/$defs/joint" }, "description": "Placed connections (base plates, …) the connection engine expands into real 3D parts (plate/bolt/weld/cut) bound to their members. A joint may cite a `connections[]` row via `conn` for its type/detail identity; `params` carries the geometry settings (engine defaults when absent)." },
|
|
43
|
-
"dims3d": { "type": "array", "items": { "$ref": "#/$defs/dim3" }, "description": "Draft-only 3D dimensions (editor annotations, model-global). World-scene mm. NOT baked into the lock / 3D scene / IFC / BOM." }
|
|
43
|
+
"dims3d": { "type": "array", "items": { "$ref": "#/$defs/dim3" }, "description": "Draft-only 3D dimensions (editor annotations, model-global). World-scene mm. NOT baked into the lock / 3D scene / IFC / BOM." },
|
|
44
|
+
"detail_placements": { "type": "array", "items": { "$ref": "#/$defs/detailPlacement" }, "description": "Draft-only placed 2D detail images (Slice 4 — insert a vectored detail into the model). Flat image planes the editor renders client-side against the model; metadata only — the image bytes live in custom_details. Model-global. NOT baked into the lock / 3D scene / IFC / BOM." }
|
|
44
45
|
},
|
|
45
46
|
"$defs": {
|
|
47
|
+
"detailPlacement": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"required": ["id"],
|
|
50
|
+
"properties": {
|
|
51
|
+
"id": { "type": "string", "description": "Stable, sheet-unique placement id." },
|
|
52
|
+
"detailName": { "type": "string", "description": "Key into custom_details for the image bytes." },
|
|
53
|
+
"sheet": { "type": "string", "description": "Sheet the detail was placed against (targeting scope)." },
|
|
54
|
+
"anchorId": { "type": ["string", "null"], "description": "Host member id the detail was dropped onto, or null (free placement)." },
|
|
55
|
+
"pos": { "type": "array", "items": { "type": "number" }, "description": "World-scene mm [x,y,z] of the plane centre." },
|
|
56
|
+
"u": { "type": "array", "items": { "type": "number" }, "description": "In-plane x-axis basis from the picked face." },
|
|
57
|
+
"n": { "type": "array", "items": { "type": "number" }, "description": "Plane-normal basis from the picked face." },
|
|
58
|
+
"rotZ": { "type": "number", "description": "In-plane rotation about the plane normal, degrees." },
|
|
59
|
+
"size": { "type": "number", "description": "Plane width in mm (height follows the image aspect)." },
|
|
60
|
+
"opacity": { "type": "number", "minimum": 0, "maximum": 1 }
|
|
61
|
+
},
|
|
62
|
+
"additionalProperties": true
|
|
63
|
+
},
|
|
46
64
|
"grid": {
|
|
47
65
|
"type": ["object", "null"],
|
|
48
66
|
"required": ["origin"],
|
|
@@ -29,6 +29,11 @@ let epGeom = null, epMatStart = null, epMatEnd = null; // shared across all endp
|
|
|
29
29
|
let epRing = null, epPreview = null; // white ring on the active end node + a rubber line while dragging it
|
|
30
30
|
let hoverEp = null, dragEp = null; // {id,end} of the hovered / dragged end node
|
|
31
31
|
let refGroup = null; let refLineOn = false; // optional reference (work) lines between each member's end points
|
|
32
|
+
let insertMode = false; // armed "place a vectored detail" pick (mirrors clipMode)
|
|
33
|
+
let insertPending = null; // {name,url} — the detail the editor queued for the next placement click
|
|
34
|
+
let labelsOnFlag = false; // member mark/id overlay labels toggle (a readability aid)
|
|
35
|
+
let memberLabelHost = null; // fixed-position container for the member labels (positioned each frame)
|
|
36
|
+
const memberLabelPool = []; // reused <div> labels, one per member, positioned in the loop
|
|
32
37
|
const EP_PX = 4; // end-dot radius in screen px (screen-constant via pxToWorld)
|
|
33
38
|
let sceneBox = new THREE.Box3(); // current model bounds (Fit / ViewCube)
|
|
34
39
|
let displayMode = 'solid'; // solid | wire | xray
|
|
@@ -148,6 +153,9 @@ function init(canvas, theApi) {
|
|
|
148
153
|
dimLabelHost = document.createElement('div');
|
|
149
154
|
dimLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:57';
|
|
150
155
|
document.body.appendChild(dimLabelHost);
|
|
156
|
+
memberLabelHost = document.createElement('div'); // member mark/id labels (Slice 4) — below the dim labels
|
|
157
|
+
memberLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:56;display:none';
|
|
158
|
+
document.body.appendChild(memberLabelHost);
|
|
151
159
|
// persistent hover/selection status chip, bottom-centre of the canvas (mirrors the viewer's readout)
|
|
152
160
|
hoverChip = document.createElement('div');
|
|
153
161
|
hoverChip.style.cssText = 'position:fixed;display:none;pointer-events:none;z-index:55;background:var(--panel,#0f172a);color:var(--mut,#94a3b8);border:1px solid var(--line,#334155);border-radius:6px;padding:5px 12px;font:12px system-ui;white-space:nowrap;max-width:60vw;overflow:hidden;text-overflow:ellipsis;box-shadow:0 2px 8px rgba(0,0,0,.45)';
|
|
@@ -191,6 +199,7 @@ function loop() {
|
|
|
191
199
|
sizeClipHandles();
|
|
192
200
|
positionDimLabels();
|
|
193
201
|
positionOverlayLabels();
|
|
202
|
+
positionMemberLabels();
|
|
194
203
|
renderer.render(scene, camera);
|
|
195
204
|
if (overlayScene && overlayScene.children.length) { // 2nd pass with clipping OFF → the clip/work-area gizmos are never sectioned by any clip
|
|
196
205
|
const saved = renderer.clippingPlanes;
|
|
@@ -332,6 +341,34 @@ function placeExtrusion(mesh, el) {
|
|
|
332
341
|
mesh.position.set(0, 0, z0); // the shape carries world XY; lift the slab to its bottom face
|
|
333
342
|
}
|
|
334
343
|
|
|
344
|
+
// A placed detail (Slice 4 — insert a vectored detail into the model): a flat image plane the user
|
|
345
|
+
// drops into the 3D scene to reference a 2D detail against real members. `url` is a data: image (from
|
|
346
|
+
// the editor's detail library); the plane sits at the element's world point, oriented to the picked
|
|
347
|
+
// face's basis (u = in-plane x, n = normal) or a default vertical facing -Y, sized by `size` mm with
|
|
348
|
+
// the image's own aspect applied once the texture loads. Injected CLIENT-SIDE by the editor into the
|
|
349
|
+
// scene it feeds this view; the server's contract-to-scene never emits them, so they stay out of the
|
|
350
|
+
// reconstructed scene / IFC / Tekla export (the bytes ride the contract to the localhost host only).
|
|
351
|
+
function placeImage(mesh, el) {
|
|
352
|
+
const size = Math.max(el.size || 1000, 1);
|
|
353
|
+
mesh.geometry = new THREE.PlaneGeometry(size, size);
|
|
354
|
+
const tex = new THREE.TextureLoader().load(el.url || '', (t) => {
|
|
355
|
+
const iw = t.image && t.image.width, ih = t.image && t.image.height;
|
|
356
|
+
if (iw && ih) mesh.scale.set(1, ih / iw, 1); // geometry is square; scale local Y to the image aspect
|
|
357
|
+
});
|
|
358
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
359
|
+
mesh.material = new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: el.opacity == null ? 1 : el.opacity, side: THREE.DoubleSide, depthWrite: false });
|
|
360
|
+
const u = el.u ? new THREE.Vector3(el.u[0], el.u[1], el.u[2]).normalize() : new THREE.Vector3(1, 0, 0);
|
|
361
|
+
const n = el.n ? new THREE.Vector3(el.n[0], el.n[1], el.n[2]).normalize() : new THREE.Vector3(0, -1, 0);
|
|
362
|
+
const vAxis = new THREE.Vector3().crossVectors(n, u).normalize();
|
|
363
|
+
mesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(u, vAxis, n));
|
|
364
|
+
if (el.rotZ) mesh.rotateZ((el.rotZ * Math.PI) / 180); // in-plane spin about the plane normal, degrees
|
|
365
|
+
// Float the plane a hair off the picked surface (along its normal) so it reads clearly instead of
|
|
366
|
+
// z-fighting the member it was dropped on, and so a click selects the detail (frontmost), not the member.
|
|
367
|
+
const OFF = 25;
|
|
368
|
+
mesh.position.set(((el.pos && el.pos[0]) || 0) + n.x * OFF, ((el.pos && el.pos[1]) || 0) + n.y * OFF, ((el.pos && el.pos[2]) || 0) + n.z * OFF);
|
|
369
|
+
mesh.renderOrder = 995;
|
|
370
|
+
}
|
|
371
|
+
|
|
335
372
|
// A cylinder between two points (anchor rod / bolt shank).
|
|
336
373
|
function placeBar(mesh, a, b, r, radial) {
|
|
337
374
|
const A = V(a[0], a[1], a[2]), B = V(b[0], b[1], b[2]);
|
|
@@ -384,6 +421,7 @@ function placeElement(mesh, el) {
|
|
|
384
421
|
switch (el.kind || 'box') {
|
|
385
422
|
case 'box': placeMember(mesh, el.from, el.to, el); return true;
|
|
386
423
|
case 'extrusion': placeExtrusion(mesh, el); return true;
|
|
424
|
+
case 'image': placeImage(mesh, el); return true;
|
|
387
425
|
case 'plate': placePlate(mesh, el); return true;
|
|
388
426
|
case 'rod': placeBar(mesh, el.from, el.to, Math.max((el.d || 20) / 2, 0.5), 16); return true;
|
|
389
427
|
case 'nut': placeNut(mesh, el); return true;
|
|
@@ -405,9 +443,12 @@ function materialFor(groupKey) {
|
|
|
405
443
|
}
|
|
406
444
|
|
|
407
445
|
function clearRoot() {
|
|
408
|
-
for (const m of meshById
|
|
446
|
+
for (const [id, m] of meshById) {
|
|
409
447
|
m.geometry.dispose();
|
|
410
448
|
if (m.userData._selMat) m.userData._selMat.dispose(); // per-mesh selection clone (else it leaks per rebuild)
|
|
449
|
+
// A placed detail (Slice 4) owns a per-mesh MeshBasicMaterial + its image texture (NOT a shared baseMat);
|
|
450
|
+
// dispose both or every rebuild — which happens on each edit — leaks the old material + GPU texture.
|
|
451
|
+
if (String(id).startsWith('det:') && m.material) { if (m.material.map) m.material.map.dispose(); m.material.dispose(); }
|
|
411
452
|
root.remove(m);
|
|
412
453
|
}
|
|
413
454
|
meshById.clear();
|
|
@@ -511,6 +552,7 @@ function buildFromScene(sc) {
|
|
|
511
552
|
buildGrid(box);
|
|
512
553
|
buildStructGrid();
|
|
513
554
|
buildRefLines();
|
|
555
|
+
syncMemberLabels(); // refresh the member-label pool from the (possibly edited) member set
|
|
514
556
|
if (isolatedIds) { isolatedIds = new Set([...isolatedIds].filter((id) => meshById.has(id))); if (!isolatedIds.size) { isolatedIds = null; if (api && api.onIsolateChange) api.onIsolateChange(false); } } // drop ids an edit removed; if none survive, exit isolation + refresh the host's Show-all button
|
|
515
557
|
if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
|
|
516
558
|
applyGroupVisibility(); applyDisplayMode();
|
|
@@ -551,6 +593,74 @@ function buildRefLines() {
|
|
|
551
593
|
}
|
|
552
594
|
function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
|
|
553
595
|
|
|
596
|
+
// ---- Insert a vectored detail (Slice 4): arm a placement pick; the editor owns the entry ----
|
|
597
|
+
// Mirrors setClipMode's arm/disarm. `pending` = {name,url} the editor's Insert menu queued; the
|
|
598
|
+
// placement click raycasts a member face (anchor) or the ground plane and hands the point + basis +
|
|
599
|
+
// anchor id back to the editor, which creates + selects the detail and records the create request.
|
|
600
|
+
function setInsertMode(on, pending) {
|
|
601
|
+
insertMode = !!on;
|
|
602
|
+
insertPending = insertMode ? (pending || insertPending) : null;
|
|
603
|
+
if (insertMode) { if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); setClipMode(null); }
|
|
604
|
+
if (canvasEl) canvasEl.style.cursor = insertMode ? 'crosshair' : 'default';
|
|
605
|
+
if (api && api.onInsertModeChange) api.onInsertModeChange(insertMode);
|
|
606
|
+
}
|
|
607
|
+
function insertModeOn() { return insertMode; }
|
|
608
|
+
// The placement raycast: a member face under the cursor gives the point + its basis + the member id to
|
|
609
|
+
// anchor to; empty space drops the detail on the ground plane (z=0) with a default vertical orientation.
|
|
610
|
+
function insertPick(cx, cy) {
|
|
611
|
+
camera.updateMatrixWorld(); root.updateMatrixWorld(true);
|
|
612
|
+
const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
|
|
613
|
+
const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
|
|
614
|
+
raycaster.setFromCamera(ndc, camera);
|
|
615
|
+
const hit = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible && !String(m.userData.id || '').startsWith('det:')), false)[0];
|
|
616
|
+
if (hit && hit.face) {
|
|
617
|
+
const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld).normalize();
|
|
618
|
+
const b = planeBasis([n.x, n.y, n.z]);
|
|
619
|
+
return { point: [hit.point.x, hit.point.y, hit.point.z], u: b.u, n: [n.x, n.y, n.z], anchorId: hit.object.userData.id || null };
|
|
620
|
+
}
|
|
621
|
+
const g = new THREE.Vector3();
|
|
622
|
+
if (!raycaster.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 0, 1), 0), g)) return null;
|
|
623
|
+
return { point: [g.x, g.y, g.z], u: [1, 0, 0], n: [0, -1, 0], anchorId: null };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---- Member labels (Slice 4): a toggled overlay of each member's mark/id, glued to the model ----
|
|
627
|
+
function setLabelsOn(on) { labelsOnFlag = on === undefined ? !labelsOnFlag : !!on; syncMemberLabels(); }
|
|
628
|
+
// Build/refresh one themed label div per member (reused across rebuilds); text = mark or id.
|
|
629
|
+
function syncMemberLabels() {
|
|
630
|
+
if (!memberLabelHost || !api) return;
|
|
631
|
+
const ms = labelsOnFlag ? members() : [];
|
|
632
|
+
while (memberLabelPool.length < ms.length) {
|
|
633
|
+
const el = document.createElement('div');
|
|
634
|
+
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:none;background:var(--panel,#0f172a);color:var(--mut,#94a3b8);border:1px solid var(--line,#1e293b);border-radius:4px;padding:1px 5px;font:11px system-ui;white-space:nowrap;box-shadow:0 1px 4px rgba(0,0,0,.4)';
|
|
635
|
+
memberLabelHost.appendChild(el); memberLabelPool.push(el);
|
|
636
|
+
}
|
|
637
|
+
const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
|
|
638
|
+
for (let i = 0; i < memberLabelPool.length; i++) {
|
|
639
|
+
const el = memberLabelPool[i], m = ms[i];
|
|
640
|
+
if (!m || !Array.isArray(m.wp) || m.wp.length < 2) { el.style.display = 'none'; el._mid = null; continue; }
|
|
641
|
+
const g = memberGeometry(m, ppf, dtos);
|
|
642
|
+
el._mid = [(g.line[0][0] + g.line[1][0]) / 2, (g.line[0][1] + g.line[1][1]) / 2, (g.line[0][2] + g.line[1][2]) / 2];
|
|
643
|
+
el.textContent = m.mark || m.id || '';
|
|
644
|
+
el.style.display = 'block';
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Project each member label's 3D midpoint → screen px (called from the render loop, like the dim labels).
|
|
648
|
+
function positionMemberLabels() {
|
|
649
|
+
if (!memberLabelHost) return;
|
|
650
|
+
if (!labelsOnFlag || (canvasEl && canvasEl.style.display === 'none')) { memberLabelHost.style.display = 'none'; return; }
|
|
651
|
+
memberLabelHost.style.display = 'block';
|
|
652
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
653
|
+
for (const el of memberLabelPool) {
|
|
654
|
+
if (el.style.display === 'none' || !el._mid) continue;
|
|
655
|
+
if (isPointClipped(el._mid)) { el.style.visibility = 'hidden'; continue; }
|
|
656
|
+
const v = new THREE.Vector3(el._mid[0], el._mid[1], el._mid[2]).project(camera);
|
|
657
|
+
if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) { el.style.visibility = 'hidden'; continue; }
|
|
658
|
+
el.style.left = (rect.left + (v.x * 0.5 + 0.5) * rect.width) + 'px';
|
|
659
|
+
el.style.top = (rect.top + (-v.y * 0.5 + 0.5) * rect.height) + 'px';
|
|
660
|
+
el.style.visibility = 'visible';
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
554
664
|
// ---- structural grid (Tekla-style): dashed lines per Z level live in the SCENE (so clips section
|
|
555
665
|
// them, like Tekla); the label bubbles + level tags are sprites in the UNCLIPPED overlay pass, so the
|
|
556
666
|
// wayfinding survives sectioning/work-area (the UX review's clip-regression guard). Data comes from
|
|
@@ -738,8 +848,9 @@ function setDisplayMode(m) { displayMode = m; applyDisplayMode(); }
|
|
|
738
848
|
// solid → opaque; wire → wireframe; xray → translucent, no depth write. Applied to whatever material
|
|
739
849
|
// each mesh currently carries (shared profile material OR its selection clone).
|
|
740
850
|
function applyDisplayMode() {
|
|
741
|
-
for (const mesh of meshById
|
|
851
|
+
for (const [id, mesh] of meshById) {
|
|
742
852
|
const mat = mesh.material; if (!mat) continue;
|
|
853
|
+
if (String(id).startsWith('det:')) continue; // placed details (Slice 4) own their transparency + opacity — display mode must not override them
|
|
743
854
|
if (displayMode === 'wire') { mat.wireframe = true; mat.transparent = false; mat.opacity = 1; mat.depthWrite = true; }
|
|
744
855
|
else if (displayMode === 'xray') { mat.wireframe = false; mat.transparent = true; mat.opacity = 0.25; mat.depthWrite = false; }
|
|
745
856
|
else { mat.wireframe = false; mat.transparent = false; mat.opacity = 1; mat.depthWrite = true; }
|
|
@@ -1132,6 +1243,7 @@ function onKey(e) {
|
|
|
1132
1243
|
const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
|
|
1133
1244
|
if (pending && pending.clipDrag && e.key === 'Escape') { e.preventDefault(); const p = pending; if (p.plane) p.clip.point = p.prePoint; else p.clip.box.copy(p.preBox); rebuildClipPlanes(p.clip); applyClips(); renderClipGizmo(); pending = dragging = null; if (controls) controls.enabled = true; if (readout) readout.style.display = 'none'; return; } // Esc mid-drag → undo the handle move
|
|
1134
1245
|
if (wpMode && e.key === 'Escape') { e.preventDefault(); if (wpMode === '3pt' && wpDraft && wpDraft.length) { wpDraft.pop(); updateStatusChip(); } else { wpMode = null; wpDraft = null; marker.visible = false; canvasEl.style.cursor = 'default'; reflectWpBar(); updateStatusChip(); } return; } // Esc steps a 3pt pick back, else disarms the set-plane mode
|
|
1246
|
+
if (insertMode && e.key === 'Escape') { e.preventDefault(); setInsertMode(false); if (api && api.toast) api.toast('Insert cancelled'); return; } // Esc disarms the detail-placement pick
|
|
1135
1247
|
if (clipMode && e.key === 'Escape') { e.preventDefault(); if (clipMode === 'box' && clipBoxDraft) { if (clipBoxDraft.b) clipBoxDraft.b = null; else clipBoxDraft = null; setClipPreview(null); updateStatusChip(); } else setClipMode(null); return; } // Esc steps back: height→footprint→cancel, else disarms the pick
|
|
1136
1248
|
if (isolatedIds && e.key === 'Escape' && !dimMode3d) { e.preventDefault(); clearIsolation(); return; } // Esc exits isolate-selected (the dim tool's own Esc wins while it's armed)
|
|
1137
1249
|
if ((e.key === ' ' && e.shiftKey) || ((e.key === 'z' || e.key === 'Z') && e.altKey)) { e.preventDefault(); frameSelection(); return; } // zoom-selected (Tekla Shift+Space / viewer Alt+Z)
|
|
@@ -1198,6 +1310,10 @@ function setSelection(ids) {
|
|
|
1198
1310
|
const set = ids instanceof Set ? ids : new Set(ids || []);
|
|
1199
1311
|
for (const [id, mesh] of meshById) {
|
|
1200
1312
|
const on = set.has(id);
|
|
1313
|
+
// A placed detail (Slice 4) uses a textured MeshBasicMaterial with no `.emissive` — the member
|
|
1314
|
+
// material swap below would drop its texture (and MeshBasicMaterial has no emissive to glow). Keep
|
|
1315
|
+
// its plane and tint the map (blue when selected, white = untinted otherwise) as the selection cue.
|
|
1316
|
+
if (String(id).startsWith('det:')) { if (mesh.material && mesh.material.color) mesh.material.color.setHex(on ? 0x93c5fd : 0xffffff); continue; }
|
|
1201
1317
|
// Selected → a per-mesh clone that glows (so one selected member doesn't tint its whole profile
|
|
1202
1318
|
// group). Deselected → back to the SHARED profile material (its real colour).
|
|
1203
1319
|
mesh.material = on ? selectedMaterial(mesh) : materialFor(mesh.userData.group);
|
|
@@ -2046,6 +2162,7 @@ function onDown(e) {
|
|
|
2046
2162
|
return; }
|
|
2047
2163
|
if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
|
|
2048
2164
|
if (cmActive()) { if (tfClick(e)) { e.stopPropagation(); controls.enabled = true; return; } } // Move/Copy armed (editor state) → picks land on the working plane; an empty-selection click falls through to select
|
|
2165
|
+
if (insertMode) { e.stopPropagation(); const r = insertPick(e.clientX, e.clientY); if (r && api && api.onInsertPlace) api.onInsertPlace(r, insertPending); setInsertMode(false); return; } // armed: place the queued detail at the pick, then disarm (one shot)
|
|
2049
2166
|
if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
|
|
2050
2167
|
if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
|
|
2051
2168
|
if (selectedClipIds.size) { let ch = null; try { ch = pickClipHandle(e.clientX, e.clientY); } catch { ch = null; } if (ch) { e.stopPropagation(); controls.enabled = false; downXY = [e.clientX, e.clientY]; startClipDrag(ch, e); return; } } // grab a clip handle (plane dot / box face) → drag it
|
|
@@ -2357,9 +2474,11 @@ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasE
|
|
|
2357
2474
|
function hide() {
|
|
2358
2475
|
if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
|
|
2359
2476
|
if (clipMode) setClipMode(null); // disarm the clip-plane pick too (no stale armed state on return)
|
|
2477
|
+
if (insertMode) setInsertMode(false); // disarm a pending detail placement — else the first click on return would place it
|
|
2360
2478
|
if (wpMode) { wpMode = null; wpDraft = null; if (marker) marker.visible = false; reflectWpBar(); } // drop an in-progress set-plane pick — else a stale face/3pt pick steals the first click on return
|
|
2361
2479
|
cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
|
|
2362
2480
|
if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
|
|
2481
|
+
if (memberLabelHost) memberLabelHost.style.display = 'none'; // member labels ride the same about-to-stop loop — hide them too, or they strand over the 2D UI
|
|
2363
2482
|
clearCopyGhost(); // drop a stale Ctrl+drag copy ghost on the way out
|
|
2364
2483
|
if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
2365
2484
|
}
|
|
@@ -2376,6 +2495,8 @@ function dispose() {
|
|
|
2376
2495
|
window.removeEventListener('keydown', onKey);
|
|
2377
2496
|
for (const ovl of [readout, hoverChip, rubber, dimLabelHost]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
|
|
2378
2497
|
dimLabelHost = null; dimLabelPool.length = 0;
|
|
2498
|
+
if (memberLabelHost && memberLabelHost.parentNode) memberLabelHost.parentNode.removeChild(memberLabelHost);
|
|
2499
|
+
memberLabelHost = null; memberLabelPool.length = 0; labelsOnFlag = false; insertMode = false; insertPending = null;
|
|
2379
2500
|
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
|
|
2380
2501
|
if (!w) continue;
|
|
2381
2502
|
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(); } });
|
|
@@ -2456,6 +2577,9 @@ window.Steel3DView = {
|
|
|
2456
2577
|
init, show, hide, rebuild, setSelection, isReady, dispose,
|
|
2457
2578
|
setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
|
|
2458
2579
|
setRefLine, refLine: () => refLineOn,
|
|
2580
|
+
setInsertMode, insertMode: insertModeOn, // arm/query the detail-placement pick (Slice 4)
|
|
2581
|
+
setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
|
|
2582
|
+
syncMemberLabels, // editor calls after a mark/id edit to refresh labels
|
|
2459
2583
|
refreshGrid: buildStructGrid, // grid edited in the panel → re-render without a full rebuild
|
|
2460
2584
|
gridInfo: () => ({ lines: structGridGroup ? 1 : 0, labels: gridLabelGroup ? gridLabelGroup.children.length : 0 }), // test helper
|
|
2461
2585
|
toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
|
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
#zoombar{position:absolute;left:12px;bottom:12px;display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 10px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:5}
|
|
26
26
|
#zoombar button{padding:1px 9px;font-size:15px}
|
|
27
27
|
#zoombar input[type=range]{width:120px;padding:0;accent-color:var(--brand)}
|
|
28
|
+
/* placed-detail inspector (Slice 4): ft-in fields in a row + a themed opacity slider (no native widgets) */
|
|
29
|
+
.detrow{display:flex;gap:6px;margin:2px 0}
|
|
30
|
+
.detf{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;font-size:11px;color:var(--mut)}
|
|
31
|
+
.detf input{width:100%}
|
|
32
|
+
#detOpacity{accent-color:var(--brand);flex:1;min-width:0}
|
|
28
33
|
#zoombar #zPct{min-width:40px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
|
|
29
34
|
aside{width:240px;flex:none;background:var(--panel);border-left:1px solid var(--line);padding:12px;overflow:auto}
|
|
30
35
|
aside h3{margin:0 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.05em}
|
|
@@ -433,6 +438,7 @@
|
|
|
433
438
|
<span class=tb-sep></span>
|
|
434
439
|
<!-- Measure / annotate -->
|
|
435
440
|
<button id=m3dRef data-tip="Show each member's reference line (centreline)">Ref line</button>
|
|
441
|
+
<button id=m3dLabels data-tip="Show each member's mark/id label in the 3D view">Labels</button>
|
|
436
442
|
<button id=m3dDim data-tip="Measure — click two snapped points (D); axis Free/X/Y/Z, Alt = vertical; right-click = restrict snap">Dimension</button>
|
|
437
443
|
<div class=seg-group id=m3dDimAxis style="display:none"><button data-d3axis=free class=on data-tip="Free 3D measurement (F)">Free</button><button data-d3axis=x data-tip="Lock measurement to X (X)">X</button><button data-d3axis=y data-tip="Lock measurement to Y (Y)">Y</button><button data-d3axis=z data-tip="Lock to Z / vertical (Z); Alt = quick vertical">Z</button></div>
|
|
438
444
|
<button id=m3dDimShow data-tip="Show or hide placed 3D dimensions" style="display:none">Hide dims</button>
|
|
@@ -447,6 +453,11 @@
|
|
|
447
453
|
<button data-clip=clear class=mdanger data-tip="Remove every clip">Clear all clips</button>
|
|
448
454
|
</div>
|
|
449
455
|
</div>
|
|
456
|
+
<div class=m3dwrap>
|
|
457
|
+
<button id=m3dInsert data-tip="Insert a 2D detail image into the 3D scene, near a beam">Insert ▾</button>
|
|
458
|
+
<div id=m3dInsertMenu class=m3dmenu role=menu></div>
|
|
459
|
+
<input id=insFile type=file accept="image/*" style="display:none">
|
|
460
|
+
</div>
|
|
450
461
|
<div class=m3dwrap>
|
|
451
462
|
<button id=m3dWp data-tip="Working plane — every 3D pick (Move/Copy, next: drawing) lands on it. Set it from a face, 3 points, or a principal plane.">◇ Plane ▾</button>
|
|
452
463
|
<div id=m3dWpMenu class=m3dmenu role=menu>
|
|
@@ -594,6 +605,7 @@ async function boot() {
|
|
|
594
605
|
C.custom_details = C.custom_details || {};
|
|
595
606
|
C.profile_colors = C.profile_colors || {};
|
|
596
607
|
if(!Array.isArray(C.dims3d))C.dims3d=[]; // model-global draft-only 3D dimensions
|
|
608
|
+
if(!Array.isArray(C.detail_placements))C.detail_placements=[]; // model-global draft-only placed detail images (Slice 4)
|
|
597
609
|
if(!C.dim_overlays||typeof C.dim_overlays!=='object'||Array.isArray(C.dim_overlays))C.dim_overlays={bolt_pitch:true,edge_clearance:true,cope_size:true,base_plate:true,anchor_depth:true}; // model-global legend DIMENSIONS toggles — all on by default
|
|
598
610
|
main();
|
|
599
611
|
// SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
|
|
@@ -762,7 +774,7 @@ const autofillTOS=syncDefaults;
|
|
|
762
774
|
// live on one foundation sheet; the reader's ids are unique within the set that gets detailed). The one
|
|
763
775
|
// unsupported edge is two DIFFERENT sheets each detailing a column that happens to share an id — then the
|
|
764
776
|
// slice would over-match. Tracked as a reader-side id-uniqueness hardening. dims3d rides its own persistence.
|
|
765
|
-
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,grid:P.grid||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; grid is per-plan (like frame/dims) so the per-plan stack restores it safely
|
|
777
|
+
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,grid:P.grid||null,joints:(C.joints||[]).filter(j=>j&&pm.has(j.main)),clips:(window.Steel3DView&&window.Steel3DView.clipState)?window.Steel3DView.clipState():null,details:(C.detail_placements||[])});} // clips/work area are view-state but ride the contract undo stack; grid is per-plan (like frame/dims) so the per-plan stack restores it safely. details = model-global placed images, captured whole each snapshot so undo/redo is cross-plan-safe.
|
|
766
778
|
// Cross-plan undo (spec §4.5): a commit that also touches ANOTHER plan captures that plan's member
|
|
767
779
|
// slice too; undo/redo restore it by sheet. The entry lives on the CURRENT plan's stack — undoing
|
|
768
780
|
// from the plan where the command ran is the supported path (same per-plan-stack rule as everything).
|
|
@@ -797,8 +809,9 @@ function apply(json){const d=JSON.parse(json);
|
|
|
797
809
|
if(Array.isArray(d.others))for(const o of d.others){const tp=C.plans.find(p=>p.sheet===o.sheet);
|
|
798
810
|
if(tp&&tp!==P)tp.members=(o.members||[]).map(ensureMeta);
|
|
799
811
|
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. grid is per-plan (restored above); 'others' = the cross-plan slice a to-level commit captured. dims3d rides its own path.
|
|
812
|
+
if('details' in d)C.detail_placements=Array.isArray(d.details)?d.details:[]; // restore the model-global placed details (Slice 4)
|
|
800
813
|
updCS();
|
|
801
|
-
selIds=new Set([...selIds].filter(id=>byId(id)));
|
|
814
|
+
selIds=new Set([...selIds].filter(id=>byId(id)||/^det:/.test(id))); // keep a selected placed-detail id (det:…) — it isn't a member
|
|
802
815
|
selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
|
|
803
816
|
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
|
|
804
817
|
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)
|
|
@@ -819,7 +832,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
|
|
|
819
832
|
else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
|
|
820
833
|
else el.textContent=msg||'Auto-save on';}
|
|
821
834
|
function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
|
|
822
|
-
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, joints:C.joints,
|
|
835
|
+
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, joints:C.joints, detail_placements:C.detail_placements,
|
|
823
836
|
plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details,dims:p.dims,frame:p.frame||null,grid:p.grid||null}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
|
|
824
837
|
// --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
|
|
825
838
|
// localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
|
|
@@ -851,6 +864,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
|
|
|
851
864
|
if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
|
|
852
865
|
C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;if(Array.isArray(s.dims))p.dims=s.dims;if('frame' in s)p.frame=s.frame||null;if('grid' in s)p.grid=s.grid||null;p.autofilled=true;}});
|
|
853
866
|
if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
|
|
867
|
+
if(Array.isArray(d.detail_placements))C.detail_placements=d.detail_placements; // restore placed details
|
|
854
868
|
if(d.dim_overlays&&typeof d.dim_overlays==='object'&&!Array.isArray(d.dim_overlays)){const o=d.dim_overlays;C.dim_overlays={bolt_pitch:o.bolt_pitch!==false,edge_clearance:o.edge_clearance!==false,cope_size:o.cope_size!==false,base_plate:o.base_plate!==false,anchor_depth:o.anchor_depth!==false};} // restore the legend DIMENSIONS toggles — sanitised to the known boolean keys (a corrupt/partial draft can't desync the legend from what's drawn; on unless explicitly false)
|
|
855
869
|
if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
|
|
856
870
|
if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
|
|
@@ -1358,6 +1372,40 @@ function panel(){
|
|
|
1358
1372
|
const ap=document.getElementById('addProf');
|
|
1359
1373
|
ap.oninput=ap.onchange=e=>{addProfile=e.target.value.toUpperCase().replace(/ /g,'');};
|
|
1360
1374
|
return;}
|
|
1375
|
+
// A placed detail image (Slice 4) selected — its own inspector: Position / Orientation / Appearance +
|
|
1376
|
+
// "Ask AI to build this" (records a modify request) + Remove. Every field mutation rides edit() → undoable.
|
|
1377
|
+
// HTML built exactly like the connection-part inspector below: every dynamic value is esc()-escaped
|
|
1378
|
+
// (the file's HTML-escaper), numbers are cast to Number — no untrusted markup reaches innerHTML.
|
|
1379
|
+
{const dsel=[...selIds];
|
|
1380
|
+
const detId=dsel.length===1&&/^det:/.test(dsel[0])?dsel[0].slice(4):null;
|
|
1381
|
+
const dp=detId?(C.detail_placements||[]).find(x=>x&&x.id===detId):null;
|
|
1382
|
+
if(dp){
|
|
1383
|
+
const inMM=v=>{const pl=parseLen(v);return pl==null?NaN:pl*25.4;}; // ft-in field → mm
|
|
1384
|
+
const ftin=mm=>esc(fmtFtIn(Number(mm||0)/25.4));
|
|
1385
|
+
const sec=t=>`<div class=divrow><hr><span class=sect style="margin:0">${esc(t)}</span><hr></div>`;
|
|
1386
|
+
const num=(id,lbl,val)=>`<label class=detf><span>${esc(lbl)}</span><input id=${esc(id)} inputmode=decimal value="${val}" autocomplete=off></label>`; // id/lbl are literals; val is ftin() (already esc'd)
|
|
1387
|
+
const opPct=Math.round((dp.opacity==null?1:dp.opacity)*100);
|
|
1388
|
+
const host=dp.anchorId?('On <b>'+esc(dp.anchorId)+'</b> · '):'';
|
|
1389
|
+
const html=`<h3>Placed detail <span style="color:var(--mut);font-weight:400">· ${esc(dp.detailName||'')}</span></h3>`
|
|
1390
|
+
+`<div class="row hint" style="margin-top:2px">${host}flat image plane — a reference (v1). Ask the AI to build real geometry.</div>`
|
|
1391
|
+
+sec('Position')+`<div class=detrow>${num('detX','X',ftin(dp.pos&&dp.pos[0]))}${num('detY','Y',ftin(dp.pos&&dp.pos[1]))}${num('detZ','Z',ftin(dp.pos&&dp.pos[2]))}</div>`
|
|
1392
|
+
+sec('Orientation')+`<div class=detrow><label class=detf><span>Rotation °</span><input id=detRot inputmode=decimal value="${esc(String(dp.rotZ||0))}" autocomplete=off></label><label class=detf><span>Size</span><input id=detSize inputmode=decimal value="${ftin(dp.size||1000)}" autocomplete=off></label></div>`
|
|
1393
|
+
+sec('Appearance')+`<div class=detrow style="align-items:center"><span class=detf style="flex:none">Opacity</span><input id=detOpacity type=range min=0 max=100 value="${opPct}"><span id=detOpacityV class=edec style="min-width:36px;text-align:right;font-variant-numeric:tabular-nums">${opPct}%</span></div>`
|
|
1394
|
+
+`<div class=divrow><hr></div>`
|
|
1395
|
+
+`<div class="row f" style="gap:6px;flex-wrap:wrap"><button class=ghostw id=detAsk title="Record a request for your terminal AI to build/adjust this detail">Ask AI to build this…</button><button class=danger id=detRemove>Remove detail</button></div>`;
|
|
1396
|
+
p.innerHTML=html;
|
|
1397
|
+
const find=()=>(C.detail_placements||[]).find(x=>x&&x.id===detId);
|
|
1398
|
+
const wr=(id,fn)=>{const i=document.getElementById(id);if(i)i.onchange=e=>fn(e.target.value);};
|
|
1399
|
+
const setPos=(k,val)=>{const mm=inMM(val);if(!isFinite(mm))return;edit(()=>{const d=find();if(d){d.pos=(d.pos||[0,0,0]).slice();d.pos[k]=mm;}});};
|
|
1400
|
+
wr('detX',v=>setPos(0,v));wr('detY',v=>setPos(1,v));wr('detZ',v=>setPos(2,v));
|
|
1401
|
+
wr('detRot',v=>{const n=Number((v||'').replace(',','.'));if(!isFinite(n))return;edit(()=>{const d=find();if(d)d.rotZ=n;});});
|
|
1402
|
+
wr('detSize',v=>{const mm=inMM(v);if(!isFinite(mm)||mm<=0)return;edit(()=>{const d=find();if(d)d.size=mm;});});
|
|
1403
|
+
{const r=document.getElementById('detOpacity'),ov=document.getElementById('detOpacityV');
|
|
1404
|
+
if(r){r.oninput=()=>{ov.textContent=r.value+'%';};r.onchange=()=>{const op=Math.max(0,Math.min(1,Number(r.value)/100));edit(()=>{const d=find();if(d)d.opacity=op;});};}}
|
|
1405
|
+
{const ab=document.getElementById('detAsk');if(ab)ab.onclick=()=>detailRequest('modify',dp);}
|
|
1406
|
+
{const rm=document.getElementById('detRemove');if(rm)rm.onclick=()=>edit(()=>{C.detail_placements=(C.detail_placements||[]).filter(x=>x&&x.id!==detId);selIds.clear();});}
|
|
1407
|
+
return;
|
|
1408
|
+
}}
|
|
1361
1409
|
// A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
|
|
1362
1410
|
// read-only (parts have no own state; their params live on the parent joint) + a jump to that member.
|
|
1363
1411
|
{const selList=[...selIds];
|
|
@@ -2275,6 +2323,7 @@ addEventListener('keydown',e=>{
|
|
|
2275
2323
|
if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
|
|
2276
2324
|
if((e.key==='Delete'||e.key==='Backspace')&&view3d&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length&&!selIds.size&&!sel3dDimIds.size&&!selDimIds.size&&!inForm){e.preventDefault();window.Steel3DView.deleteSelectedClips();return;} // 3D: Del removes the selected clip plane(s)/box(es) — only when nothing else is selected (clip selection is exclusive)
|
|
2277
2325
|
if((e.key==='Delete'||e.key==='Backspace')&&view3d&&sel3dDimIds.size&&!selIds.size&&!inForm){e.preventDefault();[...sel3dDimIds].forEach(id=>view3dApi.onDeleteDim3d(id));return;} // 3D: Delete removes ALL selected 3D dims — only when no member is selected
|
|
2326
|
+
if((e.key==='Delete'||e.key==='Backspace')&&selIds.size&&!selDimIds.size&&[...selIds].every(id=>/^det:/.test(id))&&!inForm){e.preventDefault();edit(()=>{const rm=new Set([...selIds].map(id=>id.slice(4)));C.detail_placements=(C.detail_placements||[]).filter(x=>x&&!rm.has(x.id));selIds.clear();});return;} // a placed-detail-only selection deletes from detail_placements — Delete now matches every other object's remove gesture (not just the inspector button)
|
|
2278
2327
|
if((e.key==='Delete'||e.key==='Backspace')&&(selDimIds.size||selIds.size)&&!inForm){e.preventDefault();edit(()=>{ // deletes everything selected — dims and/or members (a marquee can hold both)
|
|
2279
2328
|
if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
|
|
2280
2329
|
if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
|
|
@@ -2292,7 +2341,7 @@ addEventListener('keydown',e=>{
|
|
|
2292
2341
|
if(ck==='m'){e.preventDefault();armCm('move',false);return;}
|
|
2293
2342
|
if(ck==='c'){e.preventDefault();armCm('copy',false);return;}}
|
|
2294
2343
|
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)
|
|
2295
|
-
if(!inForm&&selIds.
|
|
2344
|
+
if(!inForm&&[...selIds].some(id=>!/^det:/.test(id))&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase(); // member-only shortcuts — require a real member selected, not a placed-detail-only selection
|
|
2296
2345
|
if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();setLastCmd('Extend/Trim',()=>{geoMode='el';setGeo();render();});return;} // Extend/Trim — any selection
|
|
2297
2346
|
if(kk==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
|
|
2298
2347
|
if(kk==='s'&&selIds.size===1&&!selDimIds.size){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();setLastCmd('Split',()=>{geoMode='split';setGeo();render();});return;}} // member Split — single member only, and not while dims are also selected (mixed → ambiguous, so neither split fires)
|
|
@@ -2348,7 +2397,15 @@ document.getElementById('cpLevelB').onclick=()=>openLevelModal('copy');
|
|
|
2348
2397
|
let copesByMember={}, partsById={};
|
|
2349
2398
|
const view3dApi={
|
|
2350
2399
|
// POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
|
|
2351
|
-
fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');const cm={},pb={};
|
|
2400
|
+
fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');const cm={},pb={};const sc=j.scene||{};sc.elements=Array.isArray(sc.elements)?sc.elements:[];for(const el of sc.elements){if(!el)continue;if(el.id)pb[el.id]=el;if(el.kind==='cut'&&el.member){(cm[el.member]||(cm[el.member]=[])).push((el.meta&&el.meta.label)||'Cope');}}
|
|
2401
|
+
// Slice 4: append placed detail images as CLIENT-SIDE scene elements (kind:'image'), resolving each
|
|
2402
|
+
// placement's detailName → custom_details base64 in the browser. The server's contract-to-scene never
|
|
2403
|
+
// emits these, so they stay out of the reconstructed scene / IFC / Tekla export and the baked lock.
|
|
2404
|
+
// (The image bytes live in custom_details, which — as it always has — rides in the contract to the
|
|
2405
|
+
// localhost host; the point is that they are never turned into exported geometry.)
|
|
2406
|
+
for(const p of (C.detail_placements||[])){if(!p||!p.id)continue;const raw=C.custom_details[p.detailName];if(raw==null)continue;const url=String(raw).startsWith('data:')?raw:'data:image/jpeg;base64,'+raw;const el={kind:'image',id:'det:'+p.id,group:'detail',url,pos:p.pos||[0,0,0],u:p.u,n:p.n,rotZ:p.rotZ||0,size:p.size||1000,opacity:p.opacity==null?1:p.opacity};sc.elements.push(el);pb[el.id]=el;}
|
|
2407
|
+
copesByMember=cm;partsById=pb;return sc;}, // cache resolved parts by id (real dims for the part Inspector) AND cope labels per member; placed details ride in as image elements
|
|
2408
|
+
|
|
2352
2409
|
onSelect:(id,additive)=>{selDimIds.clear();sel3dDimIds.clear();if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D pick → shared selection (clears 2D + 3D dim selection so Delete is unambiguous)
|
|
2353
2410
|
onSelectMany:(ids)=>{selDimIds.clear();sel3dDimIds.clear();selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D box-select → shared selection
|
|
2354
2411
|
getMembers:()=>P.members, // raw members (wp) for snap geometry
|
|
@@ -2415,12 +2472,44 @@ const view3dApi={
|
|
|
2415
2472
|
onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
|
|
2416
2473
|
beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
|
|
2417
2474
|
onClipModeChange:(m)=>{const b=document.getElementById('m3dClip');if(b){b.classList.toggle('on',!!m);b.textContent=m?'Clip ✕':'Clip ▾';}}, // armed → button fills brand-blue + becomes a cancel target (✕ = cancel)
|
|
2475
|
+
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'Insert ✕':'Insert ▾';}}, // mirror the clip arm/cancel affordance
|
|
2476
|
+
onInsertPlace:(pick,pending)=>{if(!pending||!pending.name){toast('Pick a detail to insert first');return;} // Slice 4: place the queued detail at the pick, select it, record the create intent
|
|
2477
|
+
const id='det'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);const sheet=(P&&P.sheet)||'';
|
|
2478
|
+
const place={id,detailName:pending.name,sheet,anchorId:pick.anchorId||null,pos:pick.point,u:pick.u,n:pick.n,rotZ:0,size:1000,opacity:1};
|
|
2479
|
+
edit(()=>{if(!Array.isArray(C.detail_placements))C.detail_placements=[];C.detail_placements.push(place);selIds=new Set(['det:'+id]);});
|
|
2480
|
+
toast('Detail placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — adjust it in the panel or ask the AI to build it');
|
|
2481
|
+
detailRequest('create',place);},
|
|
2418
2482
|
onIsolateChange:()=>{updateIsolateBtn();refresh3DLegend();}, // isolate / show-all (incl. Esc, legend "show all", showAllGroups clearing connHidden) → refresh the toolbar button + the legend rows' hidden state
|
|
2419
2483
|
onWorkAreaChange:()=>updateWorkBtn(), // work area set / toggled → refresh the button + its checkbox
|
|
2420
2484
|
};
|
|
2421
2485
|
// Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
|
|
2422
2486
|
// changes go through render()'s setSelection — only geometry mutations need a rebuild.
|
|
2423
2487
|
function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{window.Steel3DView.setSelection(selIds);build3DLegend();}).catch(()=>{});}} // rebuild also refreshes the legend (an edit may add/remove a profile)
|
|
2488
|
+
// Insert-a-detail helpers (Slice 4). armInsert queues a detail image + arms the 3D placement pick;
|
|
2489
|
+
// detailRequest records a create/modify request on the SAME tweak-contract channel, adding intent+target
|
|
2490
|
+
// so the terminal AI knows whether to build a new detail or update a placed one, and where.
|
|
2491
|
+
function armInsert(name){if(!name)return;const raw=(C.custom_details||{})[name];if(raw==null){toast('That detail image is missing');return;}
|
|
2492
|
+
if(!view3d){toast('Switch to the 3D view to place a detail');return;}
|
|
2493
|
+
window.Steel3DView.setInsertMode(true,{name});
|
|
2494
|
+
toast('Click a beam or the model to place “'+name+'” — Esc to cancel');}
|
|
2495
|
+
async function detailRequest(intent,place,note){
|
|
2496
|
+
// flushContract PUTs C to the server so the terminal AI reads the latest contract — but it clears the
|
|
2497
|
+
// debounced autosave (saveT) WITHOUT writing localStorage, which would drop the just-placed detail from
|
|
2498
|
+
// this browser's draft (server would keep it, localStorage wouldn't → restoreSaved clobbers it on reload).
|
|
2499
|
+
// Persist the local draft too so server + localStorage stay in lockstep.
|
|
2500
|
+
try{await window.flushContract();}catch(_){/* a failed save still lets the request queue; the AI reads the current draft */}
|
|
2501
|
+
try{persist();}catch(_){}
|
|
2502
|
+
const raw=(C.custom_details||{})[place.detailName];
|
|
2503
|
+
const snaps=raw?[{name:String(place.detailName||'detail')+'.jpg',dataUrl:String(raw).startsWith('data:')?raw:'data:image/jpeg;base64,'+raw}]:[];
|
|
2504
|
+
const pos=(place.pos||[0,0,0]).map(n=>Math.round(Number(n)||0)).join(', ');
|
|
2505
|
+
const instruction=note||(intent==='create'
|
|
2506
|
+
? 'Insert the "'+place.detailName+'" detail as real geometry on '+(place.anchorId?('member '+place.anchorId):'the model')+' (sheet '+(place.sheet||'?')+'). A flat reference image is placed at '+pos+' mm — build the actual detail there.'
|
|
2507
|
+
: 'Update the placed "'+place.detailName+'" detail (id '+place.id+') on sheet '+(place.sheet||'?')+' to match the attached image / my adjustments.');
|
|
2508
|
+
const ids=[intent==='create'?(place.anchorId||('det:'+place.id)):('det:'+place.id)];
|
|
2509
|
+
try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
|
|
2510
|
+
body:JSON.stringify({appId:APP_ID,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
|
|
2511
|
+
toast(res.ok?(intent==='create'?'Insert queued for your terminal AI session':'Change queued for your terminal AI session'):'Could not queue the request');
|
|
2512
|
+
}catch(_){toast('Could not queue the request');}}
|
|
2424
2513
|
// Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
|
|
2425
2514
|
// double-click isolate — mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
|
|
2426
2515
|
let leg3dClickT=null,legendAnchor=null;
|
|
@@ -2686,6 +2775,30 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
|
2686
2775
|
else{clipMenu.classList.add('open');document.addEventListener('mousedown',clipMenuOutside,true);}};
|
|
2687
2776
|
clipMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{clipMenuClose();const a=b.dataset.clip;
|
|
2688
2777
|
if(a==='plane'){d3.setClipMode('plane');setLastCmd('Clip plane',()=>d3.setClipMode('plane'));}else if(a==='box'){d3.setClipMode('box');setLastCmd('Clip box',()=>d3.setClipMode('box'));}else if(a==='clear')d3.clearClips();});
|
|
2778
|
+
// Labels: a plain toggle (mirrors Ref line) — show each member's mark/id in the 3D view.
|
|
2779
|
+
document.getElementById('m3dLabels').onclick=()=>{const on=!d3.labelsOn();d3.setLabelsOn(on);document.getElementById('m3dLabels').classList.toggle('on',on);};
|
|
2780
|
+
// Insert a detail: the ▾ menu lists the detail library + an "add image" option; picking one arms a
|
|
2781
|
+
// placement pick (onInsertPlace drops it). While armed the button is a cancel target (onInsertModeChange).
|
|
2782
|
+
// The menu is built with DOM nodes (textContent) — the detail names are user text, never innerHTML.
|
|
2783
|
+
const insBtn=document.getElementById('m3dInsert'),insMenu=document.getElementById('m3dInsertMenu');
|
|
2784
|
+
function insMenuOutside(e){if(!insMenu.contains(e.target)&&e.target!==insBtn)insMenuClose();}
|
|
2785
|
+
function insMenuClose(){insMenu.classList.remove('open');document.removeEventListener('mousedown',insMenuOutside,true);}
|
|
2786
|
+
function insMenuBuild(){const names=Object.keys(C.custom_details||{}).sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
|
|
2787
|
+
const frag=document.createDocumentFragment();
|
|
2788
|
+
const lab=document.createElement('div');lab.className='mlabel';lab.textContent='Place a detail';frag.appendChild(lab);
|
|
2789
|
+
if(names.length){for(const n of names){const b=document.createElement('button');b.textContent=n;b.title='Place '+n;b.onclick=()=>{insMenuClose();armInsert(n);};frag.appendChild(b);}}
|
|
2790
|
+
else{const note=document.createElement('div');note.style.cssText='padding:4px 10px;color:var(--mut);font-size:12px';note.textContent='No saved details yet — add one below.';frag.appendChild(note);}
|
|
2791
|
+
frag.appendChild(document.createElement('hr'));
|
|
2792
|
+
const add=document.createElement('button');add.textContent='+ Add an image…';add.onclick=()=>{insMenuClose();document.getElementById('insFile').click();};frag.appendChild(add);
|
|
2793
|
+
insMenu.replaceChildren(frag);}
|
|
2794
|
+
insBtn.onclick=e=>{e.stopPropagation();
|
|
2795
|
+
if(d3.insertMode()){d3.setInsertMode(false);return;} // armed → cancel the pick
|
|
2796
|
+
if(insMenu.classList.contains('open'))insMenuClose();
|
|
2797
|
+
else{insMenuBuild();insMenu.classList.add('open');document.addEventListener('mousedown',insMenuOutside,true);}};
|
|
2798
|
+
document.getElementById('insFile').onchange=e=>{const f=e.target.files&&e.target.files[0];e.target.value='';if(!f)return;
|
|
2799
|
+
readImageCompressed(f,b64=>{if(!b64){toast('Could not read that image — try another.');return;}
|
|
2800
|
+
let base=(f.name||'Detail').replace(/\.[^.]+$/,'').trim()||'Detail',name=base,i=2;while(C.custom_details[name]!=null)name=base+' '+(i++);
|
|
2801
|
+
edit(()=>{C.custom_details[name]=b64;});armInsert(name);});};
|
|
2689
2802
|
document.getElementById('m3dIso').onclick=()=>{if(d3.isIsolated())d3.clearIsolation();else d3.isolateSelected();}; // onIsolateChange refreshes the button label/visibility
|
|
2690
2803
|
// Work area: the ▢ Work area button opens a menu (Set to all objects / Define from selection / Show work area).
|
|
2691
2804
|
const workBtn=document.getElementById('m3dWork'),workMenu=document.getElementById('m3dWorkMenu');
|