@floless/app 0.72.1 → 0.72.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.72.1" : void 0,
53025
+ define: true ? "0.72.2" : 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.72.1" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.72.2" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -848,7 +848,12 @@ function fitCamera(box, dir) {
848
848
  perspCam.position.copy(c).addScaledVector(d, dist);
849
849
  orthoCam.position.copy(c).addScaledVector(d, dist);
850
850
  const r = box.getBoundingSphere(new THREE.Sphere()).radius || dist;
851
- const near = Math.max(dist / 2000, 0.5), far = dist + r * 4; // small near so the wheel can zoom right up to a connection detail without clipping
851
+ const near = Math.max(dist / 2000, 0.5); // small near so the wheel can zoom right up to a connection detail without clipping
852
+ // far must always clear the WHOLE model, not just the framed box — else a tight detail-zoom (dbl-click) puts the
853
+ // rest of the model behind the far plane and slices it with a view-perpendicular cut that reads as a skewed
854
+ // section. Size far from the scene bounds seen from the final camera position; the fit box only sets distance/zoom.
855
+ const sceneSph = sceneBox.getBoundingSphere(new THREE.Sphere());
856
+ const far = Math.max(dist + r * 4, perspCam.position.distanceTo(sceneSph.center) + sceneSph.radius) * 1.02;
852
857
  perspCam.near = near; perspCam.far = far; perspCam.updateProjectionMatrix();
853
858
  orthoCam.near = near; orthoCam.far = far; orthoCam.zoom = 1;
854
859
  orthoBaseH = Math.max(extU, extR / aspect) * 1.15; // tight, aspect-preserving box fit (no ortho under-frame)
@@ -915,13 +920,25 @@ function applyDisplayMode() {
915
920
  mat.needsUpdate = true;
916
921
  }
917
922
  }
923
+ // A work area in "show whole parts" mode acts as a spatial filter: a part whose world AABB touches the box shows
924
+ // in FULL (never sliced), a part entirely outside hides. Only evaluated when that mode is active (setFromObject
925
+ // per mesh is cheap here — this runs on toggles, not per frame).
926
+ const _waBox = new THREE.Box3();
927
+ function meshInWorkArea(m) {
928
+ if (!(workArea && workArea.enabled && workArea.whole)) return true;
929
+ _waBox.setFromObject(m); return !_waBox.isEmpty() && workArea.box.intersectsBox(_waBox);
930
+ }
931
+ // A mesh's visibility EXCEPT the work-area whole-parts filter — i.e. only the group/solo/isolate/legend hides.
932
+ // Split out so "Define from selection" can still bound a part that's hidden ONLY by the current work area (the
933
+ // new box replaces that area), and so applyGroupVisibility reads as one line. connHidden = per-part legend hide.
934
+ function visibleIgnoringWorkArea(m) {
935
+ const k = m.userData && m.userData.group;
936
+ const byGroup = !groupHidden.has(k) && (soloGroups.size === 0 || soloGroups.has(k));
937
+ const byIso = isolatedIds === null || isolatedIds.has(m.userData.id); // isolate-selected: only the isolated ids show
938
+ return byGroup && byIso && !connHidden.has(m.userData.id);
939
+ }
918
940
  function applyGroupVisibility() {
919
- for (const m of meshById.values()) {
920
- const k = m.userData && m.userData.group;
921
- const byGroup = !groupHidden.has(k) && (soloGroups.size === 0 || soloGroups.has(k));
922
- const byIso = isolatedIds === null || isolatedIds.has(m.userData.id); // isolate-selected: only the isolated ids show
923
- m.visible = byGroup && byIso && !connHidden.has(m.userData.id); // connHidden: per-part legend hide (connection rows split a shared part-kind by connection)
924
- }
941
+ for (const m of meshById.values()) m.visible = visibleIgnoringWorkArea(m) && meshInWorkArea(m); // meshInWorkArea: work-area whole-parts spatial filter
925
942
  }
926
943
  function toggleGroup(k) { if (groupHidden.has(k)) groupHidden.delete(k); else groupHidden.add(k); soloGroups.clear(); applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); }
927
944
  // Hide/show a SET of groups in one pass (a legend type-category master toggle) — deterministic (not a flip),
@@ -961,7 +978,7 @@ const CLIP_PLANE_COLOR = 0x3b82f6, CLIP_BOX_COLOR = 0x93c5fd; // brand blue (p
961
978
  function applyClips() {
962
979
  if (!renderer) return;
963
980
  const active = clips.filter((c) => c.enabled).flatMap((c) => c.planes);
964
- if (workArea && workArea.enabled) active.push(...workArea.planes); // the work area sections the view too
981
+ if (workArea && workArea.enabled && !workArea.whole) active.push(...workArea.planes); // the work area sections the view too — unless it's in "show whole parts" mode (parts are hidden/shown whole via applyGroupVisibility, never sliced)
965
982
  renderer.clippingPlanes = active.length ? active : EMPTY_CLIPS;
966
983
  }
967
984
  // 6 inward planes (keep INSIDE) for an axis-aligned Box3 → a section/clip box.
@@ -1046,7 +1063,7 @@ function clipState() {
1046
1063
  clips: clips.map((c) => c.kind === 'box'
1047
1064
  ? { id: c.id, kind: 'box', enabled: c.enabled, label: c.label, box: { min: c.box.min.toArray(), max: c.box.max.toArray() } }
1048
1065
  : { id: c.id, kind: 'plane', enabled: c.enabled, label: c.label, n: c.n.toArray(), point: c.point.toArray() }),
1049
- workArea: workArea ? { enabled: workArea.enabled, box: { min: workArea.box.min.toArray(), max: workArea.box.max.toArray() } } : null,
1066
+ workArea: workArea ? { enabled: workArea.enabled, whole: !!workArea.whole, box: { min: workArea.box.min.toArray(), max: workArea.box.max.toArray() } } : null,
1050
1067
  selected: [...selectedClipIds], seq: clipSeq,
1051
1068
  };
1052
1069
  }
@@ -1058,12 +1075,13 @@ function setClipState(s) {
1058
1075
  : { id: d.id, kind: 'plane', enabled: d.enabled, label: d.label, n: new THREE.Vector3(...d.n), point: new THREE.Vector3(...d.point), planes: [] };
1059
1076
  rebuildClipPlanes(c); return c;
1060
1077
  });
1061
- if (s.workArea) { const b = new THREE.Box3(new THREE.Vector3(...s.workArea.box.min), new THREE.Vector3(...s.workArea.box.max)); workArea = { enabled: s.workArea.enabled, box: b, planes: boxToPlanes(b) }; }
1078
+ if (s.workArea) { const b = new THREE.Box3(new THREE.Vector3(...s.workArea.box.min), new THREE.Vector3(...s.workArea.box.max)); workArea = { enabled: s.workArea.enabled, whole: s.workArea.whole !== false, box: b, planes: boxToPlanes(b) }; } // whole !== false → older snapshots (no field) restore as show-whole
1062
1079
  else workArea = null;
1063
1080
  selectedClipIds = new Set(s.selected || []);
1064
1081
  if (typeof s.seq === 'number') clipSeq = Math.max(clipSeq, s.seq);
1065
- applyClips(); renderClipGizmo(); renderWorkArea();
1082
+ applyClips(); renderClipGizmo(); renderWorkArea(); refreshWorkAreaVis();
1066
1083
  if (api && api.onClipsChange) api.onClipsChange();
1084
+ if (api && api.onWorkAreaChange) api.onWorkAreaChange(workAreaState()); // undo/redo of a work-area edit must resync the Work-area button + its sliders, not just the model
1067
1085
  }
1068
1086
 
1069
1087
  // ---- clip gizmo: the selected clip's outline + draggable handles (a plane move-arrow, or 6 box face handles) ----
@@ -1237,29 +1255,41 @@ function renderWorkArea() {
1237
1255
  workAreaHelper.material.depthTest = false; workAreaHelper.renderOrder = 995; // unclipped overlay pass keeps it visible through any clip
1238
1256
  overlayScene.add(workAreaHelper);
1239
1257
  }
1258
+ function refreshWorkAreaVis() { applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); } // whole-parts mode hides/shows whole meshes → recompute visibility + dependent dims/endpoints (same trio the legend toggles use)
1240
1259
  function setWorkAreaBox(box) {
1241
1260
  if (!box || box.isEmpty()) return false;
1242
1261
  if (api && api.beginClipEdit) api.beginClipEdit(); // undoable
1243
- workArea = { enabled: true, planes: boxToPlanes(box), box: box.clone() };
1244
- applyClips(); renderWorkArea();
1262
+ const whole = workArea ? workArea.whole : true; // keep the current cut/whole mode across a re-define; a brand-new work area defaults to "show whole parts" (no surprise slicing)
1263
+ workArea = { enabled: true, whole, planes: boxToPlanes(box), box: box.clone() };
1264
+ applyClips(); renderWorkArea(); refreshWorkAreaVis();
1245
1265
  if (api && api.onWorkAreaChange) api.onWorkAreaChange(workAreaState());
1246
1266
  return true;
1247
1267
  }
1248
1268
  function workAreaSetAll() { return setWorkAreaBox(sceneBox.clone()); } // "set work area to all objects" = model bounds
1249
1269
  function workAreaFromSelection(pad = 150) { // define a new work area around the selection
1250
- const box = new THREE.Box3(); for (const id of selIds) { const m = meshById.get(id); if (m && m.visible) box.expandByObject(m); }
1270
+ const box = new THREE.Box3(); for (const id of selIds) { const m = meshById.get(id); if (m && visibleIgnoringWorkArea(m)) box.expandByObject(m); } // include a part hidden ONLY by the current work area — the new box supersedes it (a group/isolate-hidden part is still excluded)
1251
1271
  if (box.isEmpty()) return false; box.expandByScalar(pad); return setWorkAreaBox(box);
1252
1272
  }
1253
1273
  function workAreaToggle(on) {
1254
1274
  if (!workArea) return on === false ? false : workAreaSetAll(); // first toggle-on with no box → bound the whole model
1255
1275
  if (api && api.beginClipEdit) api.beginClipEdit(); // undoable
1256
1276
  workArea.enabled = on === undefined ? !workArea.enabled : !!on;
1257
- applyClips(); renderWorkArea();
1277
+ applyClips(); renderWorkArea(); refreshWorkAreaVis();
1258
1278
  if (api && api.onWorkAreaChange) api.onWorkAreaChange(workAreaState());
1259
1279
  return workArea.enabled;
1260
1280
  }
1261
- function clearWorkArea() { if (api && api.beginClipEdit && workArea) api.beginClipEdit(); workArea = null; applyClips(); renderWorkArea(); if (api && api.onWorkAreaChange) api.onWorkAreaChange(null); }
1262
- function workAreaState() { return workArea ? { on: workArea.enabled } : null; }
1281
+ // "Show whole parts" toggle: whole a part touching the box shows in full (nothing sliced); cut hard planar
1282
+ // section at the box faces (the Tekla "cut parts by work area" behaviour). No-op without a work area.
1283
+ function workAreaSetWhole(on) {
1284
+ if (!workArea) return false;
1285
+ if (api && api.beginClipEdit) api.beginClipEdit(); // undoable
1286
+ workArea.whole = on === undefined ? !workArea.whole : !!on;
1287
+ applyClips(); refreshWorkAreaVis();
1288
+ if (api && api.onWorkAreaChange) api.onWorkAreaChange(workAreaState());
1289
+ return workArea.whole;
1290
+ }
1291
+ function clearWorkArea() { if (api && api.beginClipEdit && workArea) api.beginClipEdit(); workArea = null; applyClips(); renderWorkArea(); refreshWorkAreaVis(); if (api && api.onWorkAreaChange) api.onWorkAreaChange(null); }
1292
+ function workAreaState() { return workArea ? { on: workArea.enabled, whole: !!workArea.whole } : null; }
1263
1293
 
1264
1294
  function frameAll() { fitCamera(sceneBox); }
1265
1295
  const VIEWS = { top: [0, 0, 1], bottom: [0, 0, -1], front: [0, -1, 0], back: [0, 1, 0], right: [1, 0, 0], left: [-1, 0, 0], iso: [0.55, -0.8, 0.5] };
@@ -1469,7 +1499,8 @@ function endpointUnseen(p) {
1469
1499
  return true;
1470
1500
  }
1471
1501
  function dimHiddenByIsolation(d) {
1472
- if (isolatedIds === null && soloGroups.size === 0 && groupHidden.size === 0) return false; // nothing hiddenevery dim shows
1502
+ const waHides = !!(workArea && workArea.enabled && workArea.whole); // a whole-parts work area hides meshes too a placed dim on a hidden part must drop, not float
1503
+ if (isolatedIds === null && soloGroups.size === 0 && groupHidden.size === 0 && !waHides) return false; // nothing hidden → every dim shows
1473
1504
  return endpointUnseen(d.a) || endpointUnseen(d.b);
1474
1505
  }
1475
1506
  function refreshDims() {
@@ -2744,7 +2775,7 @@ window.Steel3DView = {
2744
2775
  toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
2745
2776
  setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
2746
2777
  isolateSelected, clearIsolation, isIsolated,
2747
- workAreaSetAll, workAreaFromSelection, workAreaToggle, clearWorkArea, workAreaState,
2778
+ workAreaSetAll, workAreaFromSelection, workAreaToggle, workAreaSetWhole, clearWorkArea, workAreaState,
2748
2779
  armWorkPlanePick, setWorkPlanePrincipal, clearWorkPlane, toggleWorkPlaneVisible, workPlaneInfo,
2749
2780
  cmEscape, cmHasBase, cmClear3d, setCmAxis, cmLastClient, cmHudApply,
2750
2781
  drClear3d, drEscape: () => { if (drDraft) { drDraft = null; drClear3d(); return true; } return false; },
@@ -126,10 +126,12 @@
126
126
  body:not(.v3d) #moreMenu #insWrap{display:none} /* Insert detail places into the 3D scene — hide it in 2D (needs 2 ids to beat the .m3dwrap.ins-in-menu display:block) */
127
127
  #moreMenu button.msnap{display:flex;align-items:center;gap:0}
128
128
  #moreMenu button.msnap.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool elsewhere in this menu) */
129
- #moreMenu .mck,.cmmenu .mck{position:relative;width:26px;height:14px;margin-right:9px;border-radius:7px;border:1px solid var(--line);background:#0b1220;flex:none;transition:background-color .15s,border-color .15s} /* delicate CSS-only slider switch — shared by the ⋯ Snapping rows and the Move/Copy → Drag-to-move/copy toggle */
130
- #moreMenu .mck::after,.cmmenu .mck::after{content:'';position:absolute;top:1px;left:1px;width:10px;height:10px;border-radius:50%;background:var(--mut);transition:transform .15s,background-color .15s}
131
- #moreMenu button.msnap.on .mck,.cmmenu #dragMoveB.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
132
- #moreMenu button.msnap.on .mck::after,.cmmenu #dragMoveB.on .mck::after{transform:translateX(12px);background:var(--brand)}
129
+ #moreMenu .mck,.cmmenu .mck,.m3dmenu .mck{position:relative;width:26px;height:14px;margin-right:9px;border-radius:7px;border:1px solid var(--line);background:#0b1220;flex:none;transition:background-color .15s,border-color .15s} /* delicate CSS-only slider switch — shared by the ⋯ Snapping rows, the Move/Copy → Drag-to-move/copy toggle, and the Work-area toggles */
130
+ #moreMenu .mck::after,.cmmenu .mck::after,.m3dmenu .mck::after{content:'';position:absolute;top:1px;left:1px;width:10px;height:10px;border-radius:50%;background:var(--mut);transition:transform .15s,background-color .15s}
131
+ #moreMenu button.msnap.on .mck,.cmmenu #dragMoveB.on .mck,.m3dmenu button.wtog.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
132
+ #moreMenu button.msnap.on .mck::after,.cmmenu #dragMoveB.on .mck::after,.m3dmenu button.wtog.on .mck::after{transform:translateX(12px);background:var(--brand)}
133
+ .m3dmenu button.wtog{display:flex;align-items:center;justify-content:flex-start;gap:0}
134
+ .m3dmenu button.wtog.on{color:var(--text)} /* the slider carries the on-state — don't also brand the label text */
133
135
  #moreMenu button.msnap .sg{display:inline-block;width:17px;color:#22d3ee;opacity:.5;flex:none;transition:opacity .15s}
134
136
  #moreMenu button.msnap.on .sg{opacity:1}
135
137
  /* Quick-access snap bar — always-expanded row of glyph toggles, bottom-right of the canvas (both views); reuses the brand-fill "on" toolbar language */
@@ -308,7 +310,7 @@
308
310
  .m3dmenu button{display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap;font-size:12px;box-shadow:none}
309
311
  .m3dmenu button:hover{background:#334155}
310
312
  .m3dmenu button.on{color:var(--brand)} /* active choice in a radio-style menu (Camera / Display) */
311
- .m3dmenu button.on::after{content:'✓';float:right;margin-left:16px;color:var(--brand)}
313
+ .m3dmenu button.on:not(.wtog)::after{content:'✓';float:right;margin-left:16px;color:var(--brand)} /* radio-style tick for Camera/Display choices — NOT the .wtog slider toggles (their .mck knob carries state) */
312
314
  .m3dmenu button:disabled{opacity:.4;cursor:default;background:transparent}
313
315
  .m3dmenu button.mdanger{color:#fca5a5} .m3dmenu button.mdanger:hover{background:#7f1d1d;color:#fecaca}
314
316
  .m3dmenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
@@ -569,7 +571,8 @@
569
571
  <button data-wa=all data-tip="Bound the work area to the whole model">Set to all objects</button>
570
572
  <button data-wa=sel data-tip="Bound the work area to the current selection">Define from selection</button>
571
573
  <hr>
572
- <label data-tip="Show or hide the work-area box"><input type=checkbox id=m3dWorkOn> Show work area</label>
574
+ <button id=m3dWorkOn class=wtog role=menuitemcheckbox aria-checked=false data-tip="Show or hide the work-area box"><span class=mck aria-hidden=true></span>Show work area</button>
575
+ <button id=m3dWorkWhole class=wtog role=menuitemcheckbox aria-checked=true style=display:none data-tip="When ON, any part that touches the work area is shown in full — nothing gets cut. When OFF, the work area slices parts cleanly at its box faces (a section cut)."><span class=mck aria-hidden=true></span>Show whole parts</button>
573
576
  </div>
574
577
  </div>
575
578
  </div>
@@ -3091,10 +3094,14 @@ function updateIsolateBtn(){const b=document.getElementById('m3dIso');if(!b||!wi
3091
3094
  b.classList.toggle('on',iso);
3092
3095
  b.textContent=iso?'Show all':'Isolate';
3093
3096
  b.setAttribute('data-tip', iso?'Restore all hidden members':'Isolate selected — hide everything else (Esc to exit)');} // themed tooltip, updated with state (no native title)
3094
- // The Work area button + its "Show work area" checkbox reflect whether a work area is on (api.onWorkAreaChange).
3095
- function updateWorkBtn(){const b=document.getElementById('m3dWork'),ck=document.getElementById('m3dWorkOn');if(!b||!window.Steel3DView||!window.Steel3DView.workAreaState)return;
3097
+ // The Work area button + its two slider toggles reflect the live work-area state (api.onWorkAreaChange).
3098
+ // "Show work area" = box drawn / sectioning on; "Show whole parts" = touching parts shown whole vs cut at the box.
3099
+ // The whole-parts row only appears once a work area exists (nothing to act on before that — no dimmed dead control).
3100
+ function updateWorkBtn(){const b=document.getElementById('m3dWork'),ck=document.getElementById('m3dWorkOn'),wh=document.getElementById('m3dWorkWhole');if(!b||!window.Steel3DView||!window.Steel3DView.workAreaState)return;
3096
3101
  const st=window.Steel3DView.workAreaState(),on=!!(st&&st.on);
3097
- b.classList.toggle('on',on);if(ck)ck.checked=on;}
3102
+ b.classList.toggle('on',on);
3103
+ if(ck){ck.classList.toggle('on',on);ck.setAttribute('aria-checked',String(on));}
3104
+ if(wh){wh.style.display=st?'':'none';wh.classList.toggle('on',!!(st&&st.whole));wh.setAttribute('aria-checked',String(!!(st&&st.whole)));}}
3098
3105
  // A type category's master toggle reads its profiles' hide state: all visible = ■ on, all hidden = □ off,
3099
3106
  // some = ◪ mixed. Colour comes from data-state (CSS); the glyph from the textContent.
3100
3107
  function updateCatTog(hdr){const tog=hdr&&hdr.querySelector('.cat-tog');if(!tog||!hdr._getState||tog.style.display==='none')return;
@@ -3178,7 +3185,8 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
3178
3185
  function workMenuClose(){workMenu.classList.remove('open');document.removeEventListener('mousedown',workMenuOutside,true);}
3179
3186
  workBtn.onclick=e=>{e.stopPropagation();if(workMenu.classList.contains('open'))workMenuClose();else{updateWorkBtn();workMenu.classList.add('open');document.addEventListener('mousedown',workMenuOutside,true);}};
3180
3187
  workMenu.querySelectorAll('button[data-wa]').forEach(b=>b.onclick=()=>{workMenuClose();const a=b.dataset.wa;if(a==='all')d3.workAreaSetAll();else if(a==='sel'){if(!d3.workAreaFromSelection())d3.workAreaSetAll();}}); // define from selection; fall back to whole model if nothing's selected
3181
- document.getElementById('m3dWorkOn').onchange=e=>{d3.workAreaToggle(e.target.checked);};
3188
+ document.getElementById('m3dWorkOn').onclick=()=>d3.workAreaToggle(); // flip box on/off (creates one bound to the whole model on first turn-on); onWorkAreaChange refreshes the slider
3189
+ document.getElementById('m3dWorkWhole').onclick=()=>d3.workAreaSetWhole(); // flip show-whole vs cut-at-boundary; stays open so the state flip is visible
3182
3190
  // Working plane: ◇ Plane menu (face pick / 3 points / principal+offset / show / reset). While a pick
3183
3191
  // mode is armed the button is a cancel target (reflectWpBar renders '✕' — same pattern as Clip).
3184
3192
  const wpBtn=document.getElementById('m3dWp'),wpMenu=document.getElementById('m3dWpMenu');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.72.1",
3
+ "version": "0.72.2",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {