- added "measurement lines"

This commit is contained in:
bklronin
2026-08-02 20:58:43 +02:00
parent 0daa6152ee
commit baa2fd5d47
8 changed files with 1842 additions and 410 deletions
+471 -177
View File
@@ -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()