- added "measurement lines"
This commit is contained in:
+471
-177
@@ -6,7 +6,7 @@ import math
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, Slot, QSize, QSettings
|
||||
from PySide6.QtGui import (
|
||||
@@ -41,7 +41,7 @@ from PySide6.QtWidgets import (
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
from fluency.io.project_io import load_project, project_zip_path, save_project
|
||||
from fluency.models.data_model import Project, Component, Sketch, Body, Workplane
|
||||
from fluency.models.data_model import Project, Component, Sketch, Body, Workplane, Feature
|
||||
|
||||
from fluency.ui.dialogs import (
|
||||
ExtrudeDialog,
|
||||
@@ -574,6 +574,158 @@ def _offset_polygon(
|
||||
return result
|
||||
|
||||
|
||||
# ── Parametric feature replay ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ensure_feature_history(body: Body) -> List[Feature]:
|
||||
"""Return *body*'s feature list, migrating legacy bodies lazily.
|
||||
|
||||
Bodies saved before feature history existed only carry the flat
|
||||
``source_sketch`` / ``extrude_*`` fields describing the LAST
|
||||
operation. Migration synthesizes a feature list from them:
|
||||
|
||||
- plain extrude → a single "extrude" feature (replays cleanly,
|
||||
no information was lost);
|
||||
- cut / union → a frozen "base" snapshot of the current geometry
|
||||
plus the cut/union feature. The previous operation's effect
|
||||
stays baked into the snapshot (a permanent scar), but every
|
||||
FUTURE sketch edit re-applies cleanly instead of duplicating.
|
||||
"""
|
||||
if body.features:
|
||||
return body.features
|
||||
if body.source_sketch is None or body.extrude_length is None:
|
||||
return [] # imported / baked body — nothing parametric to replay
|
||||
if body.extrude_cut or body.extrude_union:
|
||||
if body.geometry is not None:
|
||||
body.features.append(Feature(operation="base", geometry=body.geometry))
|
||||
op = "cut" if body.extrude_cut else "union"
|
||||
else:
|
||||
op = "extrude"
|
||||
body.features.append(
|
||||
Feature(
|
||||
operation=op,
|
||||
sketch=body.source_sketch,
|
||||
length=body.extrude_length,
|
||||
symmetric=body.extrude_symmetric,
|
||||
invert=body.extrude_invert,
|
||||
through_all=body.extrude_through_all,
|
||||
cut_all_bodies=body.extrude_cut_all_bodies,
|
||||
face_index=body.extrude_face_index,
|
||||
)
|
||||
)
|
||||
logger.info(f"Body '{body.name}': migrated legacy params to feature history")
|
||||
return body.features
|
||||
|
||||
|
||||
def _feature_face_geometry(body: Body, feat: Feature, occ_sketch: OCCSketch) -> Optional[Any]:
|
||||
"""Resolve the profile geometry for a feature's sketch.
|
||||
|
||||
Prefers the stored selected face (which can include holes);
|
||||
falls back to the whole-sketch profile when the face topology
|
||||
changed or no face was selected.
|
||||
"""
|
||||
face_geom: Optional[Any] = None
|
||||
if feat.face_index is not None:
|
||||
faces = occ_sketch.detect_faces()
|
||||
if 0 <= feat.face_index < len(faces):
|
||||
face_geom = occ_sketch.build_face_geometry(faces[feat.face_index])
|
||||
elif faces:
|
||||
# Face index out of range (topology changed) — fall back
|
||||
# to the first face.
|
||||
face_geom = occ_sketch.build_face_geometry(faces[0])
|
||||
logger.info(
|
||||
f"Body '{body.name}': face index {feat.face_index} invalid, fell back to face 0"
|
||||
)
|
||||
if face_geom is None:
|
||||
face_geom = occ_sketch.get_geometry()
|
||||
return face_geom
|
||||
|
||||
|
||||
def _replay_body_features(
|
||||
kernel: OCGeometryKernel,
|
||||
body: Body,
|
||||
features: List[Feature],
|
||||
through_all_length_fn: Callable[[Any, Sketch], float],
|
||||
) -> Optional[Any]:
|
||||
"""Replay *features* in order and return the resulting geometry.
|
||||
|
||||
Returns *None* when the replay cannot complete (missing sketch,
|
||||
empty profile, failed kernel op) — the caller then keeps the
|
||||
body's previous geometry.
|
||||
"""
|
||||
geom: Optional[Any] = None
|
||||
for feat in features:
|
||||
if feat.operation == "base":
|
||||
geom = feat.geometry
|
||||
continue
|
||||
|
||||
sketch = feat.sketch
|
||||
if sketch is None or sketch.occ_sketch is None:
|
||||
logger.warning(
|
||||
f"Body '{body.name}': {feat.operation} feature has no sketch, replay aborted"
|
||||
)
|
||||
return None
|
||||
|
||||
# Re-solve the sketch so geometry reflects any edits.
|
||||
sketch.apply_workplane()
|
||||
sketch.solve()
|
||||
|
||||
face_geom = _feature_face_geometry(body, feat, sketch.occ_sketch)
|
||||
if face_geom is None:
|
||||
logger.warning(
|
||||
f"Body '{body.name}': no profile geometry for {feat.operation}, replay aborted"
|
||||
)
|
||||
return None
|
||||
|
||||
if feat.operation == "revolve":
|
||||
geom = kernel.revolve(face_geom, feat.angle)
|
||||
if geom is None:
|
||||
return None
|
||||
continue
|
||||
|
||||
# extrude / cut / union all need the extruded profile as tool.
|
||||
if feat.through_all and geom is not None:
|
||||
# Pass-through: size the tool against the solid built so far.
|
||||
length = through_all_length_fn(geom, sketch)
|
||||
symmetric = True
|
||||
invert = False
|
||||
elif feat.operation == "cut" and geom is not None:
|
||||
# Mirror _compute_extrude_result: a cut tool must go INTO
|
||||
# the solid (the picked face's outward normal points AWAY),
|
||||
# so force the extrude direction inward.
|
||||
length = feat.length if feat.length is not None else 10.0
|
||||
symmetric = feat.symmetric
|
||||
invert = True
|
||||
else:
|
||||
length = feat.length if feat.length is not None else 10.0
|
||||
symmetric = feat.symmetric
|
||||
invert = feat.invert
|
||||
|
||||
tool_geom = kernel.extrude(face_geom, -length if invert else length, symmetric=symmetric)
|
||||
if tool_geom is None:
|
||||
return None
|
||||
|
||||
if feat.operation == "extrude":
|
||||
geom = tool_geom # plain extrude: the tool IS the result
|
||||
elif feat.operation == "cut":
|
||||
if geom is None:
|
||||
logger.warning(f"Body '{body.name}': cut feature has no base, replay aborted")
|
||||
return None
|
||||
geom = kernel.boolean_difference(geom, tool_geom)
|
||||
elif feat.operation == "union":
|
||||
if geom is None:
|
||||
logger.warning(f"Body '{body.name}': union feature has no base, replay aborted")
|
||||
return None
|
||||
geom = kernel.boolean_union(geom, tool_geom)
|
||||
else:
|
||||
logger.warning(f"Body '{body.name}': unknown feature op '{feat.operation}', skipped")
|
||||
|
||||
if geom is None:
|
||||
return None
|
||||
|
||||
return geom
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window."""
|
||||
|
||||
@@ -1021,6 +1173,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self._sketch_widget.constrain_done.connect(self._on_constrain_done)
|
||||
self._sketch_widget.sketch_updated.connect(self._on_sketch_updated)
|
||||
self._sketch_widget.solver_warning.connect(self._on_solver_warning)
|
||||
|
||||
self._sketch_list.currentItemChanged.connect(self._on_sketch_selected)
|
||||
self._body_list.currentItemChanged.connect(self._on_body_list_changed)
|
||||
@@ -1108,6 +1261,37 @@ class MainWindow(QMainWindow):
|
||||
self._btn_con_vert.setChecked(True)
|
||||
|
||||
def _on_construct_change(self, checked):
|
||||
"""Handle the "Cstrct" toolbar button.
|
||||
|
||||
The button is checkable, so a click both toggles the construct
|
||||
mode for *new* geometry and (when the user has hovered an
|
||||
existing line) promotes that line to a construction line. The
|
||||
line-conversion path lets a user "select" a line by hovering
|
||||
it and then press the construction button to convert it — the
|
||||
button stays in the "on" state so subsequent new geometry is
|
||||
also created as construction.
|
||||
|
||||
If no line is hovered the click is the original pure
|
||||
construct-mode toggle for new geometry.
|
||||
"""
|
||||
# 1) Try to convert the hovered line first. This mirrors the
|
||||
# existing C-key shortcut (``_toggle_hovered_line_construction``
|
||||
# on the sketch widget) but is exposed publicly so the
|
||||
# toolbar button can drive the same action.
|
||||
converted = self._sketch_widget.convert_hovered_line_to_construction()
|
||||
if converted:
|
||||
# A line was promoted — ensure the button reflects the
|
||||
# "construction mode is on" state regardless of what the
|
||||
# user just clicked. Without this, clicking the button
|
||||
# to turn it OFF while a line was hovered would silently
|
||||
# re-promote that line AND drop construct mode for new
|
||||
# geometry, which is confusing. Forcing the button on
|
||||
# makes the action unambiguous: "make this construction".
|
||||
self._btn_construct.setChecked(True)
|
||||
self._sketch_widget.set_construct_mode(True)
|
||||
return
|
||||
# 2) No line hovered — fall back to the original behaviour:
|
||||
# toggle construct mode for *new* geometry.
|
||||
self._sketch_widget.set_construct_mode(checked)
|
||||
|
||||
def _on_constrain_done(self):
|
||||
@@ -1130,12 +1314,64 @@ class MainWindow(QMainWindow):
|
||||
btn.setChecked(False)
|
||||
self._sketch_widget.set_mode(None)
|
||||
|
||||
def _on_solver_warning(self, message: str) -> None:
|
||||
"""Show solver-failure messages in the status bar.
|
||||
|
||||
Connected to ``Sketch2DWidget.solver_warning``, which fires when
|
||||
the SolveSpace solver returns a non-OKAY result (INCONSISTENT,
|
||||
DIDNT_CONVERGE, …). Without this hook the geometry would just
|
||||
silently stay put and the user would think the constraint had
|
||||
no effect; with the status-bar message the failure is obvious
|
||||
and includes a one-line hint (e.g. "the new constraint
|
||||
conflicts with existing constraints").
|
||||
|
||||
The message stays visible for 8 seconds — long enough to read
|
||||
but short enough not to be annoying if the user fixes the
|
||||
issue and continues editing.
|
||||
"""
|
||||
self.statusBar().showMessage(f"⚠ {message}", 8000)
|
||||
|
||||
def _on_sketch_updated(self):
|
||||
"""Mark bodies as needing update when their source sketch changes."""
|
||||
"""Mark bodies as needing update when their source sketch changes.
|
||||
|
||||
A body is marked when ANY feature in its history references the
|
||||
edited sketch (the flat ``source_sketch`` only mirrors the last
|
||||
feature). The marking then propagates transitively: sketches
|
||||
hosted on faces of a marked body follow its geometry, so bodies
|
||||
built from THOSE sketches are marked too, and so on down the
|
||||
dependency chain.
|
||||
"""
|
||||
if not self._current_component or not self._current_sketch:
|
||||
return
|
||||
sketch = self._current_sketch
|
||||
|
||||
def _body_sketches(b: Body) -> List[Sketch]:
|
||||
out = [f.sketch for f in b.features if f.sketch is not None]
|
||||
if b.source_sketch is not None:
|
||||
out.append(b.source_sketch)
|
||||
return out
|
||||
|
||||
affected: set = set()
|
||||
for body in self._current_component.bodies.values():
|
||||
if body.source_sketch is self._current_sketch and body.extrude_length is not None:
|
||||
if sketch in _body_sketches(body):
|
||||
affected.add(body.id)
|
||||
|
||||
# Transitive closure: a sketch sitting on a face of an affected
|
||||
# body moves with it → bodies using that sketch are affected too.
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for body in self._current_component.bodies.values():
|
||||
if body.id in affected:
|
||||
continue
|
||||
for s in _body_sketches(body):
|
||||
if getattr(s, "_source_body_id", None) in affected:
|
||||
affected.add(body.id)
|
||||
changed = True
|
||||
break
|
||||
|
||||
for body in self._current_component.bodies.values():
|
||||
if body.id in affected:
|
||||
body.needs_update = True
|
||||
self._refresh_lists()
|
||||
# Update undo/redo menu actions
|
||||
@@ -1249,15 +1485,26 @@ class MainWindow(QMainWindow):
|
||||
self._body_list.addItem(item)
|
||||
|
||||
def _update_and_redraw(self):
|
||||
"""Full pipeline: re-extrude from sketch, redraw, propagate to assembly.
|
||||
"""Full pipeline: rebuild bodies, redraw, propagate to assembly.
|
||||
|
||||
Connected to the 'Update Body' button. Rebuilds body geometry
|
||||
from source sketches, refreshes the component view, propagates
|
||||
changes to all assembly instances, and recalculates connectors.
|
||||
Connected to the 'Update Body' button. Bodies are rebuilt by
|
||||
replaying their feature history; sketches hosted on body faces
|
||||
are then re-projected and solved. Because a face-sketch's
|
||||
geometry depends on the body it sits on, the bodies→sketches
|
||||
cycle repeats until nothing moves anymore (bounded), so changes
|
||||
to sketches early in the design propagate all the way down the
|
||||
dependent chain. Then the view, assembly instances and
|
||||
connectors are refreshed.
|
||||
"""
|
||||
self._update_bodies_from_sketch()
|
||||
self._update_sketches_from_bodies()
|
||||
self._update_bodies_from_sketch() # re-extrude bodies whose sketches just moved
|
||||
MAX_PASSES = 5
|
||||
for _ in range(MAX_PASSES):
|
||||
self._update_bodies_from_sketch()
|
||||
if self._update_sketches_from_bodies() == 0:
|
||||
break
|
||||
else:
|
||||
# Still converging after the cap — one last body pass so the
|
||||
# final geometry is built from the freshest sketch state.
|
||||
self._update_bodies_from_sketch()
|
||||
self._redraw_bodies()
|
||||
self._propagate_to_assembly()
|
||||
self._recalculate_connectors()
|
||||
@@ -1265,155 +1512,51 @@ class MainWindow(QMainWindow):
|
||||
self._update_component_thumbnail(self._get_active_component_index())
|
||||
|
||||
def _update_bodies_from_sketch(self):
|
||||
"""Re-extrude bodies whose source sketch has been edited.
|
||||
"""Rebuild bodies by replaying their parametric feature history.
|
||||
|
||||
For every body in the current component that has a ``source_sketch``
|
||||
and stored extrude parameters, re-solve the sketch and rebuild the
|
||||
body geometry. Cut / union operations are re-applied against the
|
||||
current target body.
|
||||
Every body with a feature list is rebuilt FROM SCRATCH: the base
|
||||
feature (extrude / revolve / snapshot) is recomputed from its
|
||||
source sketch, then each cut / union re-applies in order against
|
||||
the freshly built geometry. Because the base is rebuilt clean,
|
||||
a moved sketch entity REPLACES its previous effect instead of
|
||||
piling on top of it (e.g. a moved circle re-cuts one hole at the
|
||||
new position — the old hole is gone).
|
||||
|
||||
Legacy bodies without a feature list are migrated lazily (see
|
||||
:meth:`_ensure_feature_history`).
|
||||
"""
|
||||
if not self._current_component:
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for body_id, body in list(self._current_component.bodies.items()):
|
||||
if body.source_sketch is None or body.extrude_length is None:
|
||||
continue # not a re-extrudable body
|
||||
features = _ensure_feature_history(body)
|
||||
if not features:
|
||||
continue # imported / baked body — nothing parametric
|
||||
|
||||
sketch = body.source_sketch
|
||||
if not sketch.occ_sketch:
|
||||
logger.debug(f"Body '{body.name}': source sketch has no OCC entity, skipping")
|
||||
continue
|
||||
|
||||
# Re-solve the sketch so geometry reflects any edits.
|
||||
sketch.apply_workplane()
|
||||
sketch.solve()
|
||||
|
||||
# Resolve the face geometry.
|
||||
face_geom = None
|
||||
if body.extrude_face_index is not None:
|
||||
faces = sketch.occ_sketch.detect_faces()
|
||||
if 0 <= body.extrude_face_index < len(faces):
|
||||
face_geom = sketch.occ_sketch.build_face_geometry(
|
||||
faces[body.extrude_face_index]
|
||||
)
|
||||
else:
|
||||
# Face index out of range (topology changed) — fallback
|
||||
# to first face if available.
|
||||
if faces:
|
||||
face_geom = sketch.occ_sketch.build_face_geometry(faces[0])
|
||||
logger.info(
|
||||
f"Body '{body.name}': face index {body.extrude_face_index} "
|
||||
f"invalid, fell back to face 0"
|
||||
)
|
||||
if face_geom is None:
|
||||
face_geom = sketch.occ_sketch.get_geometry()
|
||||
if face_geom is None:
|
||||
logger.warning(f"Body '{body.name}': no geometry from sketch, skipping")
|
||||
continue
|
||||
|
||||
# Resolve the target body for cut / union.
|
||||
target = None
|
||||
if body.extrude_cut or body.extrude_union:
|
||||
if (
|
||||
body.extrude_target_body_id
|
||||
and body.extrude_target_body_id in self._current_component.bodies
|
||||
):
|
||||
target = self._current_component.bodies[body.extrude_target_body_id]
|
||||
else:
|
||||
# Fallback: first body that isn't this one.
|
||||
for bid, b in self._current_component.bodies.items():
|
||||
if bid != body_id and b.geometry is not None:
|
||||
target = b
|
||||
break
|
||||
|
||||
# Handle cut_all_bodies: apply the cut to every body in the
|
||||
# component, not just the target.
|
||||
if body.extrude_cut and body.extrude_cut_all_bodies:
|
||||
try:
|
||||
if body.extrude_through_all and target is not None:
|
||||
cut_length = self._through_all_length(target, sketch)
|
||||
cut_symmetric = True
|
||||
cut_invert = False
|
||||
else:
|
||||
cut_length = body.extrude_length or 10.0
|
||||
cut_symmetric = body.extrude_symmetric
|
||||
cut_invert = body.extrude_invert
|
||||
tool_geom = self._kernel.extrude(
|
||||
face_geom,
|
||||
-cut_length if cut_invert else cut_length,
|
||||
symmetric=cut_symmetric,
|
||||
)
|
||||
if tool_geom is None:
|
||||
logger.warning(f"Body '{body.name}': cut-all tool geometry is empty")
|
||||
continue
|
||||
cut_count = 0
|
||||
for other_id, other in list(self._current_component.bodies.items()):
|
||||
if other.geometry is None:
|
||||
continue
|
||||
try:
|
||||
other.geometry = self._kernel.boolean_difference(
|
||||
other.geometry, tool_geom
|
||||
)
|
||||
other.needs_update = False
|
||||
other.modified_at = datetime.now()
|
||||
cut_count += 1
|
||||
except Exception:
|
||||
pass # body doesn't intersect tool, skip
|
||||
body.needs_update = False
|
||||
body.modified_at = datetime.now()
|
||||
updated += 1
|
||||
logger.info(
|
||||
f"Re-extruded cut-all body '{body.name}': cut {cut_count} body(ies)"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(f"Body '{body.name}': re-extrude cut-all failed: {exc}")
|
||||
continue # skip the single-target path below
|
||||
|
||||
# Compute the new result.
|
||||
try:
|
||||
if body.extrude_through_all and target is not None:
|
||||
length = self._through_all_length(target, sketch)
|
||||
result = self._compute_extrude_result(
|
||||
sketch,
|
||||
face_geom,
|
||||
length,
|
||||
symmetric=True,
|
||||
invert=False,
|
||||
cut=body.extrude_cut,
|
||||
union=body.extrude_union,
|
||||
through_all=True,
|
||||
)
|
||||
else:
|
||||
length = body.extrude_length
|
||||
result = self._compute_extrude_result(
|
||||
sketch,
|
||||
face_geom,
|
||||
length,
|
||||
symmetric=body.extrude_symmetric,
|
||||
invert=body.extrude_invert,
|
||||
cut=body.extrude_cut,
|
||||
union=body.extrude_union,
|
||||
through_all=False,
|
||||
)
|
||||
|
||||
if result is None or result["result_geom"] is None:
|
||||
logger.warning(f"Body '{body.name}': re-extrude produced no geometry")
|
||||
continue
|
||||
|
||||
body.geometry = result["result_geom"]
|
||||
body.needs_update = False
|
||||
body.modified_at = datetime.now()
|
||||
updated += 1
|
||||
logger.info(f"Re-extruded body: {body.name}")
|
||||
|
||||
new_geom = _replay_body_features(
|
||||
self._kernel, body, features, self._through_all_length_for_geometry
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(f"Body '{body.name}': re-extrude failed: {exc}")
|
||||
logger.exception(f"Body '{body.name}': feature replay failed: {exc}")
|
||||
continue
|
||||
|
||||
if new_geom is None:
|
||||
# Replay aborted — keep the previous geometry and the
|
||||
# needs_update flag so the ⚠ marker stays visible.
|
||||
continue
|
||||
|
||||
body.geometry = new_geom
|
||||
body.needs_update = False
|
||||
body.modified_at = datetime.now()
|
||||
updated += 1
|
||||
logger.info(f"Rebuilt body from features: {body.name}")
|
||||
|
||||
if updated > 0:
|
||||
logger.info(f"Updated {updated} body(ies) from sketch")
|
||||
|
||||
def _update_sketches_from_bodies(self) -> None:
|
||||
def _update_sketches_from_bodies(self) -> int:
|
||||
"""Re-project underlay construction lines from updated 3D bodies.
|
||||
|
||||
For every sketch in the current component that carries a
|
||||
@@ -1423,9 +1566,13 @@ class MainWindow(QMainWindow):
|
||||
external entities *in place* (preserving entity ids so existing
|
||||
constraints survive). The solver is re-run so any user geometry
|
||||
anchored to the underlay follows the body.
|
||||
|
||||
Returns the number of sketches whose underlay actually moved —
|
||||
the caller (``_update_and_redraw``) uses this to decide whether
|
||||
another body-rebuild pass is needed.
|
||||
"""
|
||||
if not self._current_component:
|
||||
return
|
||||
return 0
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
|
||||
kernel = OCGeometryKernel()
|
||||
@@ -1493,6 +1640,7 @@ class MainWindow(QMainWindow):
|
||||
self._sketch_widget.update()
|
||||
if updated > 0:
|
||||
logger.info("Re-projected underlays for %d sketch(es)", updated)
|
||||
return updated
|
||||
|
||||
def _propagate_to_assembly(self):
|
||||
"""Refresh all assembly instances that reference the current component.
|
||||
@@ -3777,6 +3925,45 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
|
||||
|
||||
def _record_feature(
|
||||
self,
|
||||
body: Body,
|
||||
operation: str,
|
||||
sketch: Optional[Sketch],
|
||||
length: Optional[float] = None,
|
||||
symmetric: bool = False,
|
||||
invert: bool = False,
|
||||
through_all: bool = False,
|
||||
cut_all_bodies: bool = False,
|
||||
face_index: Optional[int] = None,
|
||||
angle: float = 360.0,
|
||||
) -> None:
|
||||
"""Append *operation* to *body*'s parametric feature history.
|
||||
|
||||
If the body has no features yet but already holds geometry (a
|
||||
legacy body created before feature history existed), a frozen
|
||||
"base" snapshot of the CURRENT geometry is inserted first so
|
||||
replays start from a known state. Call this BEFORE assigning
|
||||
the new geometry onto ``body.geometry`` — the snapshot must
|
||||
capture the pre-operation state.
|
||||
"""
|
||||
if not body.features and body.geometry is not None and operation in ("cut", "union"):
|
||||
body.features.append(Feature(operation="base", geometry=body.geometry))
|
||||
logger.info(f"Body '{body.name}': snapshotted legacy geometry as feature base")
|
||||
body.features.append(
|
||||
Feature(
|
||||
operation=operation,
|
||||
sketch=sketch,
|
||||
length=length,
|
||||
symmetric=symmetric,
|
||||
invert=invert,
|
||||
through_all=through_all,
|
||||
cut_all_bodies=cut_all_bodies,
|
||||
face_index=face_index,
|
||||
angle=angle,
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_extrude_target(
|
||||
self, sketch: Sketch, exclude_body: Optional[Body] = None
|
||||
) -> Optional[Body]:
|
||||
@@ -3804,22 +3991,45 @@ class MainWindow(QMainWindow):
|
||||
return body
|
||||
return None
|
||||
|
||||
def _find_extrude_body_for_sketch(self, sketch: Sketch) -> Optional[Body]:
|
||||
"""Return the existing body that was created from *sketch* via a
|
||||
plain extrude, or *None* if no such body exists.
|
||||
|
||||
Used by the plain-extrude path of :meth:`_extrude_sketch` to decide
|
||||
between "update the existing body in place" and "create a new
|
||||
body". Without this, clicking Extrude a second time on the same
|
||||
sketch stacks a fresh 44mm solid on top of the original 44mm
|
||||
solid and the user perceives the apparent depth as 88mm.
|
||||
"""
|
||||
if self._current_component is None or sketch is None:
|
||||
return None
|
||||
for body in self._current_component.bodies.values():
|
||||
if body.source_sketch is sketch and body.source_operation == "extrude":
|
||||
return body
|
||||
return None
|
||||
|
||||
def _through_all_length(self, target: Body, sketch: Sketch) -> float:
|
||||
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
|
||||
*through* the target body.
|
||||
*through* the target body. See :meth:`_through_all_length_for_geometry`.
|
||||
"""
|
||||
return self._through_all_length_for_geometry(target.geometry, sketch)
|
||||
|
||||
Computes the target body's bounding-box extent along the sketch's
|
||||
workplane normal direction ("extent" = how far the body reaches on
|
||||
def _through_all_length_for_geometry(self, geometry: Any, sketch: Sketch) -> float:
|
||||
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
|
||||
*through* a body geometry.
|
||||
|
||||
Computes the geometry's bounding-box extent along the sketch's
|
||||
workplane normal direction ("extent" = how far the solid reaches on
|
||||
either side of the face). With ``symmetric=True`` the kernel
|
||||
extrudes ``± height/2``, so to clear the full ``extent`` on each
|
||||
side we need ``height = 2 × (extent + buffer)``. The 5 mm buffer
|
||||
on each side guarantees the tool pokes out past the body so the
|
||||
on each side guarantees the tool pokes out past the solid so the
|
||||
boolean reliably removes the through volume.
|
||||
"""
|
||||
import numpy as _np
|
||||
|
||||
try:
|
||||
p_min, p_max = self._kernel.get_bounding_box(target.geometry)
|
||||
p_min, p_max = self._kernel.get_bounding_box(geometry)
|
||||
except Exception:
|
||||
logger.debug("through-all bbox failed", exc_info=True)
|
||||
return 2000.0 # generous fallback if bbox fails for any reason
|
||||
@@ -4087,10 +4297,23 @@ class MainWindow(QMainWindow):
|
||||
for body in all_targets:
|
||||
try:
|
||||
new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
|
||||
body.geometry = new_geom
|
||||
except Exception as exc:
|
||||
logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
|
||||
continue
|
||||
# Record the feature BEFORE committing the geometry so
|
||||
# a legacy body snapshots its pre-cut state as base.
|
||||
self._record_feature(
|
||||
body,
|
||||
"cut",
|
||||
sketch,
|
||||
length=length,
|
||||
symmetric=symmetric,
|
||||
invert=invert,
|
||||
through_all=bool(through_all),
|
||||
cut_all_bodies=True,
|
||||
face_index=face_index,
|
||||
)
|
||||
body.geometry = new_geom
|
||||
body.extrude_length = length
|
||||
body.extrude_symmetric = symmetric
|
||||
body.extrude_invert = invert
|
||||
@@ -4111,7 +4334,19 @@ class MainWindow(QMainWindow):
|
||||
body_name = f"{updated_count} body(ies)"
|
||||
elif target is not None:
|
||||
# Single-body cut / union: commit the result onto the *target*
|
||||
# body in place.
|
||||
# body in place. Record the feature BEFORE committing so a
|
||||
# legacy body snapshots its pre-op geometry as base.
|
||||
self._record_feature(
|
||||
target,
|
||||
"cut" if cut else "union",
|
||||
sketch,
|
||||
length=length,
|
||||
symmetric=symmetric,
|
||||
invert=invert,
|
||||
through_all=bool(through_all),
|
||||
cut_all_bodies=False,
|
||||
face_index=face_index,
|
||||
)
|
||||
target.geometry = result["result_geom"]
|
||||
# Store extrude params so the body can be rebuilt later.
|
||||
target.extrude_length = length
|
||||
@@ -4133,30 +4368,81 @@ class MainWindow(QMainWindow):
|
||||
logger.info(f"{op.capitalize()} applied: {target.name} now holds the result")
|
||||
body_name = target.name
|
||||
else:
|
||||
# Plain extrude: create a new body for the extrusion.
|
||||
body = self._current_component.add_body(
|
||||
Body(
|
||||
name=f"Extrusion_{len(self._current_component.bodies) + 1}",
|
||||
geometry=result["result_geom"],
|
||||
source_sketch=sketch,
|
||||
source_operation="extrude",
|
||||
extrude_length=length,
|
||||
extrude_symmetric=symmetric,
|
||||
extrude_invert=invert,
|
||||
extrude_cut=False,
|
||||
extrude_union=False,
|
||||
extrude_through_all=bool(through_all),
|
||||
extrude_cut_all_bodies=False,
|
||||
extrude_face_index=face_index,
|
||||
# Plain extrude. If this sketch already produced an
|
||||
# existing extrude body, UPDATE that body in place rather
|
||||
# than stacking a fresh 44mm solid on top of the old one
|
||||
# — which the user perceives as "44mm looks like 88mm"
|
||||
# because the two coincident solids visually sum.
|
||||
existing = self._find_extrude_body_for_sketch(sketch)
|
||||
if existing is not None:
|
||||
body = existing
|
||||
# Record the new feature BEFORE replacing geometry so
|
||||
# the parametric history reflects the latest op.
|
||||
self._record_feature(
|
||||
body,
|
||||
"extrude",
|
||||
sketch,
|
||||
length=length,
|
||||
symmetric=symmetric,
|
||||
invert=invert,
|
||||
through_all=bool(through_all),
|
||||
face_index=face_index,
|
||||
)
|
||||
)
|
||||
self._mark_dirty()
|
||||
logger.info(f"Created body: {body.name}")
|
||||
logger.debug("Adding shape to OCC viewer")
|
||||
shape = self._kernel._get_shape(body.geometry)
|
||||
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||||
logger.info(f"Render object: {body.render_object}")
|
||||
body_name = body.name
|
||||
body.geometry = result["result_geom"]
|
||||
body.extrude_length = length
|
||||
body.extrude_symmetric = symmetric
|
||||
body.extrude_invert = invert
|
||||
body.extrude_cut = False
|
||||
body.extrude_union = False
|
||||
body.extrude_through_all = bool(through_all)
|
||||
body.extrude_cut_all_bodies = False
|
||||
body.extrude_face_index = face_index
|
||||
body.source_sketch = sketch
|
||||
body.source_operation = "extrude"
|
||||
body.extrude_target_body_id = None
|
||||
self._mark_dirty()
|
||||
logger.info(f"Updated existing body in place: {body.name}")
|
||||
if body.render_object is not None:
|
||||
self._viewer_3d.remove_mesh(body.render_object)
|
||||
shape = self._kernel._get_shape(body.geometry)
|
||||
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||||
body_name = body.name
|
||||
else:
|
||||
# Plain extrude: create a new body for the extrusion.
|
||||
body = self._current_component.add_body(
|
||||
Body(
|
||||
name=f"Extrusion_{len(self._current_component.bodies) + 1}",
|
||||
geometry=result["result_geom"],
|
||||
source_sketch=sketch,
|
||||
source_operation="extrude",
|
||||
extrude_length=length,
|
||||
extrude_symmetric=symmetric,
|
||||
extrude_invert=invert,
|
||||
extrude_cut=False,
|
||||
extrude_union=False,
|
||||
extrude_through_all=bool(through_all),
|
||||
extrude_cut_all_bodies=False,
|
||||
extrude_face_index=face_index,
|
||||
features=[
|
||||
Feature(
|
||||
operation="extrude",
|
||||
sketch=sketch,
|
||||
length=length,
|
||||
symmetric=symmetric,
|
||||
invert=invert,
|
||||
through_all=bool(through_all),
|
||||
face_index=face_index,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
self._mark_dirty()
|
||||
logger.info(f"Created body: {body.name}")
|
||||
logger.debug("Adding shape to OCC viewer")
|
||||
shape = self._kernel._get_shape(body.geometry)
|
||||
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||||
logger.info(f"Render object: {body.render_object}")
|
||||
body_name = body.name
|
||||
|
||||
self._refresh_lists()
|
||||
self._update_component_thumbnail(self._get_active_component_index())
|
||||
@@ -4210,6 +4496,14 @@ class MainWindow(QMainWindow):
|
||||
geometry=body_geometry,
|
||||
source_sketch=sketch,
|
||||
source_operation="revolve",
|
||||
features=[
|
||||
Feature(
|
||||
operation="revolve",
|
||||
sketch=sketch,
|
||||
angle=angle,
|
||||
face_index=self._sketch_widget.get_selected_face_index(),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
self._mark_dirty()
|
||||
|
||||
Reference in New Issue
Block a user