- added renderer

This commit is contained in:
bklronin
2026-07-12 22:21:20 +02:00
parent b8516fff91
commit 210e3cfb5d
12 changed files with 2628 additions and 1580 deletions
+66 -38
View File
@@ -4,11 +4,18 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Added save file foramt&#10;- Split main.py refactor">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Working assembly multi :)">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry/base.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry/base.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/__init__.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
@@ -46,42 +53,45 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.base.executor&quot;: &quot;Run&quot;,
&quot;Python.data_model.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.kernel.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Python.2dtest.executor": "Run",
"Python.3d_windows.executor": "Run",
"Python.Unnamed.executor": "Run",
"Python.base.executor": "Run",
"Python.data_model.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
"Python.fluencyb.executor": "Run",
"Python.gl_widget.executor": "Run",
"Python.gui_ui.executor": "Run",
"Python.kernel.executor": "Run",
"Python.main.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.occ_renderer.executor": "Run",
"Python.occ_to_mesh.executor": "Run",
"Python.render_backend.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.sketch.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feature/occ-migration",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/rendering",
"node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
}
}</component>
}]]></component>
<component name="QodanaReportsService">
<option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -89,6 +99,7 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/drawing_modules" />
@@ -378,7 +389,23 @@
<option name="project" value="LOCAL" />
<updated>1783777171864</updated>
</task>
<option name="localTasksCounter" value="34" />
<task id="LOCAL-00034" summary="- Working assembly multi :)">
<option name="closed" value="true" />
<created>1783798201133</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1783798201133</updated>
</task>
<task id="LOCAL-00035" summary="- Working assembly multi :)">
<option name="closed" value="true" />
<created>1783798929531</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1783798929531</updated>
</task>
<option name="localTasksCounter" value="36" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -423,6 +450,7 @@
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
<MESSAGE value="- assembly draft" />
<MESSAGE value="- Added save file foramt&#10;- Split main.py refactor" />
<option name="LAST_COMMIT_MESSAGE" value="- Added save file foramt&#10;- Split main.py refactor" />
<MESSAGE value="- Working assembly multi :)" />
<option name="LAST_COMMIT_MESSAGE" value="- Working assembly multi :)" />
</component>
</project>
+1031 -930
View File
File diff suppressed because it is too large Load Diff
+588 -534
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -406,6 +406,11 @@ class SketchInterface(ABC):
"""Constrain two circles to have equal radius."""
pass
@abstractmethod
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
"""Set the diameter of a circle."""
pass
@abstractmethod
def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place."""
+26
View File
@@ -626,6 +626,17 @@ class OCCSketch(SketchInterface):
elif ctype == "equal_radius":
# tracked only (no solver entity)
pass
elif ctype == "diameter":
# Update circle radius in sketch data
circle_id = ids[0]
if circle_id in self._circles:
center_id, _ = self._circles[circle_id]
radius = params[0] / 2.0
self._circles[circle_id] = (center_id, radius)
ent = self._entities.get(circle_id)
if ent is not None and ent.geometry is not None:
cx, cy = ent.geometry[0] if isinstance(ent.geometry[0], tuple) else ent.geometry
ent.geometry = ((cx, cy), radius)
else:
return False
return True
@@ -788,6 +799,21 @@ class OCCSketch(SketchInterface):
self._record_constraint("equal_radius", (circle1.id, circle2.id))
return True
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
"""Set the diameter of a circle."""
radius = diameter / 2.0
# Update the circle's radius in the sketch
if circle.id in self._circles:
center_id, _ = self._circles[circle.id]
self._circles[circle.id] = (center_id, radius)
# Update the entity geometry
ent = self._entities.get(circle.id)
if ent is not None:
cx, cy = ent.geometry
ent.geometry = ((cx, cy), radius)
self._record_constraint("diameter", (circle.id,), (diameter,))
return True
def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place via dragged constraint."""
ent = self._entities.get(entity.id)
+18
View File
@@ -171,6 +171,14 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
"name": body.name,
"source_sketch_id": body.source_sketch.id if body.source_sketch else None,
"source_operation": body.source_operation,
"extrude_length": body.extrude_length,
"extrude_symmetric": body.extrude_symmetric,
"extrude_invert": body.extrude_invert,
"extrude_cut": body.extrude_cut,
"extrude_union": body.extrude_union,
"extrude_through_all": body.extrude_through_all,
"extrude_face_index": body.extrude_face_index,
"extrude_target_body_id": body.extrude_target_body_id,
"position": _coerce_listlike(body.position),
"rotation": _coerce_listlike(body.rotation),
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
@@ -198,6 +206,14 @@ def _body_from_dict(
geometry=geometry,
source_sketch=source_sketch,
source_operation=data.get("source_operation", "extrude"),
extrude_length=data.get("extrude_length"),
extrude_symmetric=bool(data.get("extrude_symmetric", False)),
extrude_invert=bool(data.get("extrude_invert", False)),
extrude_cut=bool(data.get("extrude_cut", False)),
extrude_union=bool(data.get("extrude_union", False)),
extrude_through_all=bool(data.get("extrude_through_all", False)),
extrude_face_index=data.get("extrude_face_index"),
extrude_target_body_id=data.get("extrude_target_body_id"),
position=_to_3vec(data.get("position")),
rotation=_to_mat3(data.get("rotation")),
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
@@ -338,6 +354,7 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
"partner_ac_id": conn.partner_ac_id,
"partner_connector_id": conn.partner_connector_id,
"is_grounded": bool(conn.is_grounded),
"is_invalid": bool(conn.is_invalid),
"created_at": conn.created_at.isoformat() if conn.created_at else None,
"modified_at": conn.modified_at.isoformat() if conn.modified_at else None,
}
@@ -358,6 +375,7 @@ def _connector_from_dict(data: Dict[str, Any]) -> Connector:
conn.partner_ac_id = data.get("partner_ac_id")
conn.partner_connector_id = data.get("partner_connector_id")
conn.is_grounded = bool(data.get("is_grounded", False))
conn.is_invalid = bool(data.get("is_invalid", False))
conn.created_at = _parse_iso(data.get("created_at"))
conn.modified_at = _parse_iso(data.get("modified_at"))
return conn
+18
View File
@@ -222,6 +222,20 @@ class Body:
source_sketch: Optional[Sketch] = None
source_operation: str = "extrude"
# Re-extrusion parameters — stored so the body can be rebuilt from
# its source sketch when the sketch is edited. None means the body
# was not created by an extrude-type operation and cannot be auto-
# rebuilt.
extrude_length: Optional[float] = None
extrude_symmetric: bool = False
extrude_invert: bool = False
extrude_cut: bool = False
extrude_union: bool = False
extrude_through_all: bool = False
extrude_face_index: Optional[int] = None # which sketch face was selected
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
needs_update: bool = False # True when source sketch changed since last extrude
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
@@ -382,6 +396,10 @@ class Connector:
# True on the first-picked (grounded) connector of a mated pair.
is_grounded: bool = False
# True when the source geometry this connector was placed on has
# changed or disappeared — the connector needs re-placement.
is_invalid: bool = False
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
+13
View File
@@ -1,5 +1,6 @@
"""Rendering module."""
# OCC/OpenGL viewport renderers
from fluency.rendering.base import (
Renderer,
RenderObject,
@@ -8,10 +9,22 @@ from fluency.rendering.base import (
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
# Photorealistic render backends
from fluency.rendering.render_backend import (
RenderBackend,
RenderMaterial,
RenderCamera,
RenderSettings,
)
__all__ = [
"Renderer",
"RenderObject",
"RenderColor",
"PygfxRenderer",
"PygfxRenderObject",
"RenderBackend",
"RenderMaterial",
"RenderCamera",
"RenderSettings",
]
+120
View File
@@ -577,6 +577,126 @@ class OCCRenderer(Renderer):
return (_xyz(eye), _xyz(at), _xyz(up))
def get_camera_fov(self) -> float:
"""Return the current vertical FOV in degrees from the OCC camera."""
if self._view is None:
return 45.0
cam = self._view.Camera()
fov_y = cam.FOVy()
# Clamp to a reasonable range for Mitsuba (10120 deg)
return max(10.0, min(120.0, fov_y))
def get_render_camera(self) -> "RenderCamera":
"""Compute a :class:`RenderCamera` from the current viewport state.
Handles both perspective and orthographic projection modes:
* **Perspective** uses the actual eye/target/up/fov directly.
* **Orthographic** translates the view's scale factor (which encodes
mouse-wheel zoom) into a camera distance so the render framing
matches what the user sees in the viewport. In orthographic mode,
zooming changes ``view.Scale()`` rather than moving the eye position,
so simply reading ``Eye()`` produces incorrect framing.
Returns *None* if the view is not initialised.
"""
from .render_backend import RenderCamera
if self._view is None:
return None
cam = self._view.Camera()
eye_obj = self._view.Eye()
at_obj = self._view.At()
up_obj = self._view.Up()
def _xyz(v):
if isinstance(v, (tuple, list)):
return np.array([float(v[0]), float(v[1]), float(v[2])])
return np.array([v.X(), v.Y(), v.Z()])
eye = _xyz(eye_obj)
at = _xyz(at_obj)
up = _xyz(up_obj)
fov_y = max(10.0, min(120.0, cam.FOVy()))
# Check projection type.
from OCP.Graphic3d import Graphic3d_Camera
proj_type = cam.ProjectionType()
is_orthographic = (
proj_type == Graphic3d_Camera.Projection_Orthographic
)
if not is_orthographic:
# Perspective mode: use the actual eye position directly.
return RenderCamera(
origin=tuple(float(v) for v in eye),
target=tuple(float(v) for v in at),
up=tuple(float(v) for v in up),
fov=fov_y,
)
# ── Orthographic mode: translate scale → camera distance ───────
# In OCC orthographic projection, mouse-wheel zoom changes
# ``view.Scale()`` rather than moving the eye. A smaller scale
# means "zoomed in" (more world units per pixel), so the render
# camera should move closer to match.
view_scale = self._view.Scale()
# Compute scene bounding box diagonal from displayed objects.
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box()
try:
for robj in self._objects.values():
if robj.ais_shape is not None and robj.ais_type != "workplane":
shape = getattr(robj.ais_shape, "Shape", lambda: None)()
if shape is not None:
BRepBndLib.Add_s(shape, bbox)
except Exception:
pass
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
diag = float(
np.sqrt(
(xmax - xmin) ** 2
+ (ymax - ymin) ** 2
+ (zmax - zmin) ** 2
)
)
# Fallback: if bbox is empty (no objects or all shapes failed),
# use the eye-to-at distance as a reasonable estimate.
if diag < 1e-9:
diag = float(np.linalg.norm(eye - at))
# Base distance: how far the camera must be for the bbox diagonal
# to fill the frame at the given vertical FOV.
import math
base_distance = diag / (
2.0 * math.tan(math.radians(fov_y / 2.0))
)
# Scale factor maps directly: smaller scale (zoomed in) → closer camera.
adjusted_distance = base_distance * view_scale
# Direction from target toward the original eye position.
direction = eye - at
norm = np.linalg.norm(direction)
if norm < 1e-9:
direction = np.array([0.0, 0.0, 1.0])
else:
direction /= norm
new_eye = at + direction * adjusted_distance
return RenderCamera(
origin=tuple(float(v) for v in new_eye),
target=tuple(float(v) for v in at),
up=tuple(float(v) for v in up),
fov=fov_y,
)
def fit_camera(self, padding: float = 0.05) -> None:
"""Fit camera to show all displayed objects.
+495 -7
View File
@@ -256,6 +256,9 @@ class MainWindow(QMainWindow):
self._assembly_view_active: bool = False
self._selected_assembly_component_id: Optional[str] = None
# Render window reference (prevents GC)
self._render_window = None
# Connector two-click state
self._connector_first_pick: Optional[Dict[str, Any]] = None
self._connector_second_ac_id: Optional[str] = None
@@ -347,6 +350,20 @@ class MainWindow(QMainWindow):
self._action_load_last.toggled.connect(self._toggle_load_last_project)
file_menu.addAction(self._action_load_last)
# ── Edit menu (runtime-only, not in the .ui) ──
edit_menu = self.menuBar().addMenu("&Edit")
self._action_undo = QAction("Undo", self)
self._action_undo.setShortcut(QKeySequence("Ctrl+Z"))
self._action_undo.triggered.connect(self._undo_sketch)
self._action_undo.setEnabled(False)
edit_menu.addAction(self._action_undo)
self._action_redo = QAction("Redo", self)
self._action_redo.setShortcut(QKeySequence("Ctrl+Shift+Z"))
self._action_redo.triggered.connect(self._redo_sketch)
self._action_redo.setEnabled(False)
edit_menu.addAction(self._action_redo)
# ── View menu (runtime-only, not in the .ui) ──
view_menu = self.menuBar().addMenu("&View")
view_menu.addAction("Fit All", self._fit_view)
@@ -360,6 +377,12 @@ class MainWindow(QMainWindow):
)
view_menu.addAction(action)
view_menu.addSeparator()
self._action_render = QAction("Render...", self)
self._action_render.setShortcut(QKeySequence("Ctrl+R"))
self._action_render.triggered.connect(self._open_render_window)
view_menu.addAction(self._action_render)
# ── Help menu (runtime-only, not in the .ui) ──
help_menu = self.menuBar().addMenu("&Help")
help_menu.addAction("About", self._show_about)
@@ -463,6 +486,7 @@ class MainWindow(QMainWindow):
self._btn_con_vert = ui.pb_con_vert
self._btn_con_dist = ui.pb_con_dist
self._btn_con_sym = ui.pb_con_sym
self._btn_con_diameter = ui.pb_con_diameter
# ── Snaps ──
self._btn_snap_point = ui.pushButton_8
self._btn_snap_mid = ui.pb_snap_midp
@@ -500,6 +524,9 @@ class MainWindow(QMainWindow):
self._btn_add_connector = ui.pb_add_connector
self._btn_add_connector.setCheckable(True)
self._btn_del_connector = ui.pb_remove_connector
# ── Connection list ──
self._connection_list = ui.connection_list
self._btn_del_connection = ui.pb_del_connection
# ── Code tab ──
self._btn_apply_code = ui.pb_apply_code
self._btn_load_code = ui.pushButton_5
@@ -569,6 +596,7 @@ class MainWindow(QMainWindow):
self._btn_con_perp.clicked.connect(lambda: self._set_sketch_mode("constrain_perpendicular"))
self._btn_con_dist.clicked.connect(lambda: self._set_sketch_mode("constrain_distance"))
self._btn_con_sym.clicked.connect(lambda: self._set_sketch_mode("constrain_symmetric"))
self._btn_con_diameter.clicked.connect(lambda: self._set_sketch_mode("constrain_diameter"))
self._btn_snap_point.clicked.connect(
lambda c: self._sketch_widget.set_snap_mode("point", c)
@@ -608,6 +636,7 @@ class MainWindow(QMainWindow):
self._btn_asm_move.toggled.connect(self._on_assembly_move_toggled)
self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
self._btn_del_connector.clicked.connect(self._on_delete_connector)
self._btn_del_connection.clicked.connect(self._on_delete_connection_from_list)
self._viewer_3d.connectorPicked.connect(self._on_connector_picked)
self._viewer_3d.connectorHover.connect(self._on_connector_hover)
self._viewer_3d.connectorPickCancelled.connect(
@@ -623,7 +652,7 @@ class MainWindow(QMainWindow):
self._on_assembly_move_finished
)
self._btn_update_body.clicked.connect(self._redraw_bodies)
self._btn_update_body.clicked.connect(self._update_and_redraw)
self._btn_del_body.clicked.connect(self._delete_body)
self._btn_export_stl.clicked.connect(self._export_stl)
@@ -686,6 +715,7 @@ class MainWindow(QMainWindow):
self._btn_con_perp,
self._btn_con_dist,
self._btn_con_sym,
self._btn_con_diameter,
]:
btn.setChecked(False)
@@ -726,12 +756,21 @@ class MainWindow(QMainWindow):
self._btn_con_perp,
self._btn_con_dist,
self._btn_con_sym,
self._btn_con_diameter,
]:
btn.setChecked(False)
self._sketch_widget.set_mode(None)
def _on_sketch_updated(self):
pass
"""Mark bodies as needing update when their source sketch changes."""
if not self._current_component or not self._current_sketch:
return
for body in self._current_component.bodies.values():
if body.source_sketch is self._current_sketch and body.extrude_length is not None:
body.needs_update = True
self._refresh_lists()
# Update undo/redo menu actions
self._update_undo_redo_actions()
def _get_active_component_index(self) -> int:
for i, btn in enumerate(self._component_buttons):
@@ -792,6 +831,7 @@ class MainWindow(QMainWindow):
def _refresh_lists(self):
self._sketch_list.clear()
self._body_list.clear()
self._refresh_connection_list()
if self._current_component:
for sketch_id, sketch in self._current_component.sketches.items():
@@ -802,7 +842,10 @@ class MainWindow(QMainWindow):
# each body's visibility in the 3D viewer. The item's
# data role stores the body id so the toggle handler can
# look up the right body without relying on display text.
item = QListWidgetItem(body.name)
display_name = body.name
if body.needs_update:
display_name = f"{body.name}"
item = QListWidgetItem(display_name)
item.setData(Qt.UserRole, body_id)
# Qt.Checked = visible, Qt.Unchecked = hidden. Default
# is whatever the body model says.
@@ -812,8 +855,230 @@ class MainWindow(QMainWindow):
# Greying out a hidden body's name is a nice UX touch.
if not body.visible:
item.setForeground(QColor("#6c7086"))
elif body.needs_update:
item.setForeground(QColor("#f5a623")) # Orange warning
self._body_list.addItem(item)
def _update_and_redraw(self):
"""Full pipeline: re-extrude from sketch, 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.
"""
self._update_bodies_from_sketch()
self._redraw_bodies()
self._propagate_to_assembly()
self._recalculate_connectors()
self._refresh_lists()
def _update_bodies_from_sketch(self):
"""Re-extrude bodies whose source sketch has been edited.
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.
"""
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
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
# 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}")
except Exception as exc:
logger.exception(f"Body '{body.name}': re-extrude failed: {exc}")
if updated > 0:
logger.info(f"Updated {updated} body(ies) from sketch")
def _propagate_to_assembly(self):
"""Refresh all assembly instances that reference the current component.
Called after ``_update_bodies_from_sketch`` so the assembly view
shows the updated bodies without a full scene rebuild.
"""
if not self._current_component:
return
comp_id = self._current_component.id
for assembly in self._project.assemblies.values():
for ac_id, ac in assembly.components.items():
if ac.component_id == comp_id:
self._update_assembly_component_in_viewer(ac_id)
def _recalculate_connectors(self):
"""Smart recalculation of connector positions after body update.
For each assembly instance of the current component, probe the
updated geometry near each connector's world position. If a
matching snap candidate is found within tolerance, update the
connector's local position and normal. Otherwise mark it invalid.
"""
import numpy as np
if not self._current_component:
return
comp_id = self._current_component.id
TOLERANCE = 5.0 # mm — max distance to consider a match
updated = 0
invalidated = 0
for assembly in self._project.assemblies.values():
for ac_id, ac in assembly.components.items():
if ac.component_id != comp_id:
continue
for conn_id, conn in list(ac.connectors.items()):
# Compute the connector's current world position.
local_pos = np.array(conn.position, dtype=float)
world_pos = ac.position + ac.rotation @ local_pos
# Project to screen and probe nearby geometry.
screen = self._viewer_3d._renderer._project_to_screen(
tuple(world_pos)
)
if screen is None:
continue
try:
candidates = self._viewer_3d._renderer.probe_snap_candidates(
screen[0], screen[1], radius=20,
)
except Exception:
continue
if not candidates:
# No geometry nearby — connector may be orphaned.
if not conn.is_invalid:
conn.is_invalid = True
invalidated += 1
logger.info(
f"Connector '{conn.name}' on {ac.name}: "
f"no geometry nearby, marked invalid"
)
continue
# Find the nearest candidate of matching type.
best = None
best_dist = float("inf")
for cand in candidates:
cand_pos = np.array(cand["position"], dtype=float)
dist = float(np.linalg.norm(cand_pos - world_pos))
if dist < best_dist:
best_dist = dist
best = cand
if best is not None and best_dist <= TOLERANCE:
# Update connector to new local coords.
new_world = np.array(best["position"], dtype=float)
new_local = ac.rotation.T @ (new_world - ac.position)
conn.position = tuple(float(v) for v in new_local)
if best.get("normal") is not None:
new_normal = ac.rotation.T @ np.array(best["normal"], dtype=float)
conn.normal = tuple(float(v) for v in new_normal)
if best.get("x_dir") is not None:
new_xdir = ac.rotation.T @ np.array(best["x_dir"], dtype=float)
conn.x_dir = tuple(float(v) for v in new_xdir)
conn.is_invalid = False
conn.modified_at = datetime.now()
updated += 1
logger.info(
f"Connector '{conn.name}' on {ac.name}: "
f"updated ({best_dist:.1f}mm shift)"
)
elif not conn.is_invalid:
conn.is_invalid = True
invalidated += 1
logger.info(
f"Connector '{conn.name}' on {ac.name}: "
f"no close match ({best_dist:.1f}mm), marked invalid"
)
if updated or invalidated:
logger.info(
f"Connector recalc: {updated} updated, "
f"{invalidated} invalidated"
)
def _redraw_bodies(self):
self._viewer_3d.clear_scene()
@@ -1078,11 +1343,12 @@ class MainWindow(QMainWindow):
try:
local_pos = np.array(conn.position, dtype=float)
world_pos = ac.position + ac.rotation @ local_pos
sphere_shape = self._make_connector_marker(tuple(world_pos))
conn_color = (1.0, 0.1, 0.1) if conn.is_invalid else (1.0, 0.3, 0.0)
sphere_shape = self._make_connector_marker(tuple(world_pos), color=conn_color)
if sphere_shape is not None:
self._viewer_3d.show_shape(
sphere_shape,
color=(1.0, 0.3, 0.0), # Orange
color=conn_color,
name=f"conn_{ac_id}_{conn_id}",
)
except Exception as exc:
@@ -1151,11 +1417,12 @@ class MainWindow(QMainWindow):
try:
local_pos = np.array(conn.position, dtype=float)
world_pos = ac.position + ac.rotation @ local_pos
sphere_shape = self._make_connector_marker(tuple(world_pos))
conn_color = (1.0, 0.1, 0.1) if conn.is_invalid else (1.0, 0.3, 0.0)
sphere_shape = self._make_connector_marker(tuple(world_pos), color=conn_color)
if sphere_shape is not None:
self._viewer_3d.show_shape(
sphere_shape,
color=(1.0, 0.3, 0.0), # Orange
color=conn_color,
name=f"conn_{ac_id}_{conn_id}",
)
new_ids.append(f"conn_{ac_id}_{conn_id}")
@@ -1553,6 +1820,7 @@ class MainWindow(QMainWindow):
self._connector_first_pick = None
self._connector_second_ac_id = None
self._mark_dirty()
self._refresh_connection_list()
self._show_assembly_in_viewer(fit=True)
@staticmethod
@@ -1962,6 +2230,92 @@ class MainWindow(QMainWindow):
ac.remove_connector(conn_id)
logger.info(f"Removed connector {conn_id}")
self._show_assembly_in_viewer(fit=True)
self._refresh_connection_list()
def _refresh_connection_list(self):
"""Refresh the connection list widget with all assembly connections.
Each entry shows the connected component names and connector info.
The list is populated from the active assembly's connections.
"""
self._connection_list.clear()
assembly = self._get_assembly()
if assembly is None or not assembly.connections:
return
for conn in assembly.connections:
first_ac = assembly.components.get(conn.first_ac_id)
second_ac = assembly.components.get(conn.second_ac_id)
if first_ac is None or second_ac is None:
continue
# Get component names from the project.
first_comp = self._project.get_component_by_id(first_ac.component_id)
second_comp = self._project.get_component_by_id(second_ac.component_id)
first_name = first_comp.name if first_comp else first_ac.name
second_name = second_comp.name if second_comp else second_ac.name
# Build display text.
display = f"{first_name}{second_name}"
item = QListWidgetItem(display)
item.setData(Qt.UserRole, conn.id)
self._connection_list.addItem(item)
def _on_delete_connection_from_list(self):
"""Delete the selected connection from the connection list.
Also removes the mated connectors from both assembly components
and cleans up the connection graph.
"""
selected = self._connection_list.currentItem()
if selected is None:
QMessageBox.information(self, "No Selection",
"Select a connection to delete")
return
conn_id = selected.data(Qt.UserRole)
assembly = self._get_assembly()
if assembly is None:
return
# Find the connection.
target_conn = None
for conn in assembly.connections:
if conn.id == conn_id:
target_conn = conn
break
if target_conn is None:
return
# Get the assembly components involved.
first_ac = assembly.components.get(target_conn.first_ac_id)
second_ac = assembly.components.get(target_conn.second_ac_id)
# Remove the mated connectors from both sides.
if first_ac is not None:
for conn in list(first_ac.connectors.values()):
if conn.partner_ac_id == target_conn.second_ac_id:
first_ac.remove_connector(conn.id)
break
if second_ac is not None:
for conn in list(second_ac.connectors.values()):
if conn.partner_ac_id == target_conn.first_ac_id:
second_ac.remove_connector(conn.id)
break
# Remove the connection from the assembly.
assembly.connections = [c for c in assembly.connections if c.id != conn_id]
self._mark_dirty()
self._refresh_connection_list()
self._show_assembly_in_viewer(fit=True)
logger.info(f"Deleted connection {conn_id}")
def _new_workplane(self):
"""Open the orientation dialog and create a new independent workplane.
@@ -3046,6 +3400,10 @@ class MainWindow(QMainWindow):
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}"
)
# Capture the face index before the dialog closes (the selected
# face may be cleared during preview cleanup).
face_index = self._sketch_widget.get_selected_face_index()
try:
result = self._compute_extrude_result(
sketch, face_geom,
@@ -3065,6 +3423,17 @@ class MainWindow(QMainWindow):
# "added without cut" bug once the spurious body was
# deleted).
target.geometry = result["result_geom"]
# Store extrude params so the body can be rebuilt later.
target.extrude_length = length
target.extrude_symmetric = symmetric
target.extrude_invert = invert
target.extrude_cut = bool(cut)
target.extrude_union = bool(union)
target.extrude_through_all = bool(through_all)
target.extrude_face_index = face_index
target.source_sketch = sketch
target.source_operation = "cut" if cut else "union"
target.extrude_target_body_id = target.id
if target.render_object is not None:
self._viewer_3d.remove_mesh(target.render_object)
shape = self._kernel._get_shape(target.geometry)
@@ -3084,6 +3453,13 @@ class MainWindow(QMainWindow):
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_face_index=face_index,
)
)
self._mark_dirty()
@@ -3204,6 +3580,8 @@ class MainWindow(QMainWindow):
name=f"Cut_{len(self._current_component.bodies) + 1}",
geometry=result_geom,
source_operation="boolean_cut",
extrude_cut=True,
extrude_target_body_id=base_id,
)
)
self._mark_dirty()
@@ -3241,6 +3619,7 @@ class MainWindow(QMainWindow):
name=f"Union_{len(self._current_component.bodies) + 1}",
geometry=result_geom,
source_operation="boolean_union",
extrude_union=True,
)
)
self._mark_dirty()
@@ -3792,6 +4171,115 @@ class MainWindow(QMainWindow):
def _reset_view(self):
self._viewer_3d.set_camera_position((100, 100, 100), (0, 0, 0))
def _open_render_window(self):
"""Open the render window for the selected body or assembly."""
from fluency.ui.render_window import RenderWindow
shape = None
# Try selected body first
if self._selected_body and self._selected_body.geometry:
try:
shape = self._kernel._get_shape(self._selected_body.geometry)
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
# Try selected assembly component
if shape is None and self._selected_assembly_component_id:
ac_id = self._selected_assembly_component_id
for assembly in self._project.assemblies.values():
ac = assembly.components.get(ac_id)
if ac and ac.component_id:
src_comp = self._project.components.get(ac.component_id)
if src_comp:
for body in src_comp.bodies.values():
if body.geometry:
try:
shape = self._kernel._get_shape(body.geometry)
break
except Exception:
pass
break
if shape is None:
QMessageBox.information(
self,
"Render",
"Select a body or assembly component to render.",
)
return
# Capture the current 3D viewport camera so the render matches what the user sees.
# get_render_camera() handles both perspective and orthographic modes — in
# orthographic mode it translates view.Scale() (mouse-wheel zoom) into a
# proper perspective camera distance, so the render framing matches the viewport.
try:
renderer = self._viewer_3d.get_renderer()
if hasattr(renderer, "get_render_camera"):
viewport_camera = renderer.get_render_camera()
else:
# Fallback for pygfx or other renderers without the method.
from fluency.rendering.render_backend import RenderCamera
eye, at_, up = self._viewer_3d.get_camera_position()
fov = self._viewer_3d.get_camera_fov()
viewport_camera = RenderCamera(
origin=tuple(float(v) for v in eye),
target=tuple(float(v) for v in at_),
up=tuple(float(v) for v in up),
fov=fov,
)
except Exception:
viewport_camera = None
win = RenderWindow(shape=shape, camera=viewport_camera, parent=self)
win.show()
self._render_window = win # prevent GC
# ─── Sketch Undo/Redo ─────────────────────────────────────────────────
def _get_sketch_widget(self):
"""Return the active sketch widget, or None if not in sketch mode."""
# The sketch widget is in the sketch tab
if hasattr(self, '_sketch_widget'):
return self._sketch_widget
return None
def _undo_sketch(self):
"""Undo the last sketch operation."""
sketch_widget = self._get_sketch_widget()
if sketch_widget and sketch_widget.get_undo_manager():
undo_mgr = sketch_widget.get_undo_manager()
if undo_mgr.can_undo:
undo_mgr.undo()
sketch_widget._rebuild_from_sketch()
sketch_widget._solve_and_sync()
sketch_widget.sketch_updated.emit()
sketch_widget.update()
self._update_undo_redo_actions()
def _redo_sketch(self):
"""Redo the last undone sketch operation."""
sketch_widget = self._get_sketch_widget()
if sketch_widget and sketch_widget.get_undo_manager():
undo_mgr = sketch_widget.get_undo_manager()
if undo_mgr.can_redo:
undo_mgr.redo()
sketch_widget._rebuild_from_sketch()
sketch_widget._solve_and_sync()
sketch_widget.sketch_updated.emit()
sketch_widget.update()
self._update_undo_redo_actions()
def _update_undo_redo_actions(self):
"""Update the enabled state of Undo/Redo menu actions."""
sketch_widget = self._get_sketch_widget()
if sketch_widget and sketch_widget.get_undo_manager():
undo_mgr = sketch_widget.get_undo_manager()
self._action_undo.setEnabled(undo_mgr.can_undo)
self._action_redo.setEnabled(undo_mgr.can_redo)
else:
self._action_undo.setEnabled(False)
self._action_redo.setEnabled(False)
def _show_about(self):
QMessageBox.about(
self,
+238 -71
View File
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
)
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
from fluency.sketch_undo import SketchUndoManager
logger = logging.getLogger(__name__)
@@ -178,6 +179,9 @@ class Sketch2DWidget(QWidget):
self._offset_preview_points: List[Tuple[float, float]] = []
self._offset_preview_active: bool = False
# Undo/redo manager (created when a sketch is set)
self._undo_manager: Optional[SketchUndoManager] = None
self.setFocusPolicy(Qt.StrongFocus)
self._setup_ui()
@@ -197,6 +201,11 @@ class Sketch2DWidget(QWidget):
# them if the new sketch is on a face too.)
if self._sketch is not None and self._sketch is not sketch:
self._sketch.remove_external_entities()
# Reset undo manager for the new sketch
if self._sketch is not None:
self._undo_manager = SketchUndoManager(self._sketch)
else:
self._undo_manager = None
self.update()
def clear_source_face(self) -> None:
@@ -412,6 +421,16 @@ class Sketch2DWidget(QWidget):
return self._sketch.build_face_geometry(self._selected_face)
return None
def get_selected_face_index(self) -> Optional[int]:
"""Return the index of the selected face in detect_faces(), or None."""
if self._selected_face is None or self._sketch is None:
return None
faces = self._sketch.detect_faces()
for i, face in enumerate(faces):
if self._faces_match(face, self._selected_face):
return i
return None
def clear_selected_face(self):
self._selected_face = None
self._hovered_face = None
@@ -499,12 +518,18 @@ class Sketch2DWidget(QWidget):
def get_sketch(self) -> Optional[OCCSketch]:
return self._sketch
def get_undo_manager(self) -> Optional[SketchUndoManager]:
"""Return the undo manager for this sketch widget."""
return self._undo_manager
def create_sketch(self) -> OCCSketch:
self._sketch = OCCSketch()
self._sketch.add_centerlines()
# Sync widget tracking lists from sketch so centerlines (and any
# future auto-created entities) are immediately pickable.
self._rebuild_from_sketch()
# Initialize undo manager for the new sketch
self._undo_manager = SketchUndoManager(self._sketch)
self.update()
return self._sketch
@@ -577,6 +602,8 @@ class Sketch2DWidget(QWidget):
self._selected_entities = []
self._hovered_constraint_idx = -1
self._hovered_face = None
self._hovered_circle = None
self._hovered_circle_entity = None
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
self.clear_offset_preview()
@@ -1267,7 +1294,7 @@ class Sketch2DWidget(QWidget):
return
if event.button() == Qt.LeftButton:
# Priority order: ① tight point-grab → ② face selection → ③ element move
# Priority order: ① tight point-grab → ② element move → ③ face selection
if self._mode in ("select", None) and self._sketch:
# ① Tight point check (4 px) — a deliberate grab on a point.
tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4)
@@ -1284,30 +1311,18 @@ class Sketch2DWidget(QWidget):
self._move_anchor = tight_ent
self._move_anchor_orig = QPoint(int(round(x)), int(round(y)))
self._move_orig_positions = orig
# Save state before starting move
if self._undo_manager:
self._undo_manager.save_state()
self._move_active = True
self.setCursor(Qt.ClosedHandCursor)
return
# ② Face region — click inside a closed face to select it.
# Use float-precision world coords so small shapes (sub-integer
# at the current zoom) are still pickable.
fwx, fwy = self._screen_to_world_f(event.pos())
face = self._sketch.find_face_at(fwx, fwy)
if face is not None:
if self._faces_match(face, self._selected_face):
# Clicking the same face again toggles it off.
self._selected_face = None
else:
self._selected_face = face
self._hovered_face = None
self.update()
return
# ③ Wider element-move check (lines and circles). External
# (underlay) entities are fixed references and can't be
# dragged — fall through to the constraint / draw handlers
# so a click on an underlay edge is treated as a constraint
# pick (the desired behavior) rather than a no-op.
# ② Wider element-move check (lines, circles, arcs).
# Lines/circles/arcs have small hit targets — check them
# BEFORE face so they aren't blocked by the large interior
# selection zone. External (underlay) entities are fixed
# references and can't be dragged.
target = self._find_move_target(event.pos())
if (target is not None
and not self._is_external(target[0])
@@ -1322,10 +1337,30 @@ class Sketch2DWidget(QWidget):
self._move_anchor = anchor_ent
self._move_anchor_orig = anchor_world
self._move_orig_positions = orig
# Save state before starting move
if self._undo_manager:
self._undo_manager.save_state()
self._move_active = True
self.setCursor(Qt.ClosedHandCursor)
return
# ③ Face region — click inside a closed face to select it.
# Checked LAST because faces have huge selection zones that
# would otherwise block picking lines/points/arcs/circles.
# Use float-precision world coords so small shapes (sub-integer
# at the current zoom) are still pickable.
fwx, fwy = self._screen_to_world_f(event.pos())
face = self._sketch.find_face_at(fwx, fwy)
if face is not None:
if self._faces_match(face, self._selected_face):
# Clicking the same face again toggles it off.
self._selected_face = None
else:
self._selected_face = face
self._hovered_face = None
self.update()
return
snapped_pos = self._apply_all_snaps(
event.pos(), self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None
)
@@ -1363,6 +1398,8 @@ class Sketch2DWidget(QWidget):
self._handle_constraint_ptline(world_snapped)
elif self._mode == "constrain_symmetric":
self._handle_constraint_symmetric(world_snapped)
elif self._mode == "constrain_diameter":
self._handle_constraint_diameter(world_snapped)
def mouseMoveEvent(self, event):
if self._panning and self._pan_start:
@@ -1455,10 +1492,10 @@ class Sketch2DWidget(QWidget):
if self._hovered_constraint_idx != -1:
self._hovered_constraint_idx = -1
# Priority for select/move mode: point > face > line > circle.
# Face is checked before line so the user can see and select the
# wall region between two concentric boundaries (e.g. after offset).
# In drawing modes, lines take priority so the user can snap to them.
# Priority for select/move mode: point > line > circle > face.
# Lines/circles have small hit targets — check them before face so
# the large interior face zone doesn't block picking. In drawing
# modes, lines take priority so the user can snap to them.
hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor
# ── ① Point hover (tightest, always first) ──
@@ -1474,55 +1511,64 @@ class Sketch2DWidget(QWidget):
self._hovered_point = None
self._hovered_point_entity = None
# ── ② Face hover (checked before line in select mode) ──
# In select/move mode the face is the primary interaction target
# (select to extrude etc.). Lines remain selectable via click
# (element move) but the visual highlight shows the face region.
face_found = False
if self._mode in ("select", None) and self._sketch is not None:
fwx, fwy = self._screen_to_world_f(event.pos())
face = self._sketch.find_face_at(fwx, fwy)
if face is not None:
self._hovered_face = face
self._hovered_line = None
self._hovered_line_entity = None
self.setCursor(Qt.PointingHandCursor)
face_found = True
if not face_found:
self._hovered_face = None
# ── ③ Line hover (when no face under cursor) ──
line_hit = self._get_line_entity_at(world_pos)
if line_hit:
p1_ent, p2_ent = line_hit
if p1_ent.geometry and p2_ent.geometry:
self._hovered_line = (
QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))),
QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))),
)
self._hovered_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent)
self.setCursor(hover_cursor)
else:
self._hovered_line = None
self._hovered_line_entity = None
self.setCursor(Qt.ArrowCursor)
# ── ② Line hover ──
# Lines have small hit targets — check them BEFORE face so
# the large interior face zone doesn't block line picking.
line_hit = self._get_line_entity_at(world_pos)
if line_hit:
p1_ent, p2_ent = line_hit
if p1_ent.geometry and p2_ent.geometry:
self._hovered_line = (
QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))),
QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))),
)
self._hovered_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent)
self._hovered_face = None
self.setCursor(hover_cursor)
else:
self._hovered_line = None
self._hovered_line_entity = None
# ── ④ Circle hover ──
if self._mode in ("select", None):
over_circle = False
for c_ent, r in self._circles:
if c_ent.geometry and r > 0:
cx, cy = c_ent.geometry
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
if abs(d - r) < 8:
over_circle = True
break
self.setCursor(Qt.OpenHandCursor if over_circle else Qt.ArrowCursor)
self._hovered_face = None
self.setCursor(Qt.ArrowCursor)
else:
self._hovered_line = None
self._hovered_line_entity = None
# ── ③ Circle / arc hover ──
if self._mode in ("select", None):
over_circle = False
for c_ent, r in self._circles:
if c_ent.geometry and r > 0:
cx, cy = c_ent.geometry
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
if abs(d - r) < 8:
over_circle = True
break
self._hovered_face = None
if over_circle:
self._hovered_circle = (QPoint(int(round(cx)), int(round(cy))), r)
self._hovered_circle_entity = c_ent
self.setCursor(Qt.OpenHandCursor)
else:
self._hovered_circle = None
self._hovered_circle_entity = None
self.setCursor(Qt.ArrowCursor)
else:
self._hovered_circle = None
self._hovered_circle_entity = None
self._hovered_face = None
self.setCursor(Qt.ArrowCursor)
# ── ④ Face hover (checked LAST — huge interior zones) ──
# Face is the lowest priority so it doesn't block picking
# lines, circles, arcs, or points. Only highlighted when
# nothing else is under the cursor.
if self._mode in ("select", None) and self._sketch is not None:
fwx, fwy = self._screen_to_world_f(event.pos())
face = self._sketch.find_face_at(fwx, fwy)
if face is not None:
self._hovered_face = face
self.setCursor(Qt.PointingHandCursor)
self.update()
@@ -1572,6 +1618,31 @@ class Sketch2DWidget(QWidget):
self.update()
def keyPressEvent(self, event):
# Undo: Ctrl+Z
if event.key() == Qt.Key_Z and event.modifiers() & Qt.ControlModifier:
if not (event.modifiers() & Qt.ShiftModifier):
# Ctrl+Z: Undo
if self._undo_manager and self._undo_manager.can_undo:
self._undo_manager.undo()
self._rebuild_from_sketch()
self._solve_and_sync()
self.sketch_updated.emit()
self.update()
event.accept()
return
# Redo: Ctrl+Shift+Z or Ctrl+Y
if (event.key() == Qt.Key_Z and event.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier)) or \
(event.key() == Qt.Key_Y and event.modifiers() & Qt.ControlModifier):
if self._undo_manager and self._undo_manager.can_redo:
self._undo_manager.redo()
self._rebuild_from_sketch()
self._solve_and_sync()
self.sketch_updated.emit()
self.update()
event.accept()
return
# Delete / Backspace removes the entity currently under the cursor
# and recomputes the surviving constraints. Priority: constraint tag >
# line > point. Works in Move mode or when no tool is selected; ignored
@@ -1606,6 +1677,9 @@ class Sketch2DWidget(QWidget):
idx = self._hovered_constraint_idx
if idx < 0 or self._sketch is None:
return
# Save state before deleting
if self._undo_manager:
self._undo_manager.save_state()
ok = self._sketch.remove_constraint_at(idx)
logger.info(f"Deleted constraint #{idx}; recompute solved={ok}")
self._hovered_constraint_idx = -1
@@ -1630,6 +1704,9 @@ class Sketch2DWidget(QWidget):
if self._is_centerline(line_ent):
logger.debug("Refusing to delete centerline")
return
# Save state before deleting
if self._undo_manager:
self._undo_manager.save_state()
ok = self._sketch.delete_line(line_ent)
logger.info(f"Deleted line {line_ent.id}; recompute solved={ok}")
# Refresh widget tracking from the pruned sketch and sync solved positions.
@@ -1660,6 +1737,9 @@ class Sketch2DWidget(QWidget):
if self._is_centerline(point_ent):
logger.debug("Refusing to delete centerline point")
return
# Save state before deleting
if self._undo_manager:
self._undo_manager.save_state()
ok = self._sketch.delete_point(point_ent)
logger.info(f"Deleted point {point_ent.id}; recompute solved={ok}")
self._rebuild_from_sketch()
@@ -1693,6 +1773,9 @@ class Sketch2DWidget(QWidget):
if self._is_centerline(line_ent):
logger.debug("Refusing to toggle centerline")
return
# Save state before toggling
if self._undo_manager:
self._undo_manager.save_state()
# Flip the construction flag on the line entity itself.
new_val = not line_ent.is_construction
line_ent.is_construction = new_val
@@ -1711,6 +1794,9 @@ class Sketch2DWidget(QWidget):
self._ensure_sketch_with_centerlines()
if not self._draw_buffer:
# Save state before starting a new line
if self._undo_manager:
self._undo_manager.save_state()
point = self._sketch.add_point(pos.x(), pos.y())
point.is_construction = self._is_construct
self._points.append(point)
@@ -1756,6 +1842,9 @@ class Sketch2DWidget(QWidget):
self._ensure_sketch_with_centerlines()
if not self._draw_buffer:
# Save state before starting a new rectangle
if self._undo_manager:
self._undo_manager.save_state()
self._draw_buffer.append(pos)
self._rect_first_snap_target = self._snap_point_target
else:
@@ -1818,6 +1907,9 @@ class Sketch2DWidget(QWidget):
self._ensure_sketch_with_centerlines()
if not self._draw_buffer:
# Save state before starting a new circle
if self._undo_manager:
self._undo_manager.save_state()
center = self._sketch.add_point(pos.x(), pos.y())
center.is_construction = self._is_construct
self._points.append(center)
@@ -1846,6 +1938,9 @@ class Sketch2DWidget(QWidget):
self._ensure_sketch_with_centerlines()
if not self._draw_buffer:
# Save state before starting a new arc
if self._undo_manager:
self._undo_manager.save_state()
# Click 1: place center point
center = self._sketch.add_point(pos.x(), pos.y())
center.is_construction = self._is_construct
@@ -1938,6 +2033,9 @@ class Sketch2DWidget(QWidget):
self._ensure_sketch_with_centerlines()
if not self._draw_buffer:
# Save state before starting a new slot
if self._undo_manager:
self._undo_manager.save_state()
# Click 1: place first arc center C1.
c1 = self._sketch.add_point(pos.x(), pos.y())
c1.is_construction = self._is_construct
@@ -2146,6 +2244,9 @@ class Sketch2DWidget(QWidget):
self._selected_entities.append(ent)
if len(self._selected_entities) >= 2:
e1, e2 = self._selected_entities[:2]
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
if self._sketch:
self._sketch.constrain_coincident(e1, e2)
ok = self._solve_and_sync()
@@ -2166,6 +2267,9 @@ class Sketch2DWidget(QWidget):
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
if line_ent is None:
return
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
if self._sketch:
self._sketch.constrain_horizontal(line_ent)
# Tag endpoints so paintEvent renders the "> hrz <" label.
@@ -2189,6 +2293,9 @@ class Sketch2DWidget(QWidget):
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
if line_ent is None:
return
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
if self._sketch:
self._sketch.constrain_vertical(line_ent)
for ent in (p1_ent, p2_ent):
@@ -2218,6 +2325,9 @@ class Sketch2DWidget(QWidget):
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
self._constraint_distance_value, 0, 10000, 2)
if ok and self._sketch:
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
self._sketch.constrain_distance(p1_ent, p2_ent, dist)
self._solve_and_sync()
logger.info(f"Line distance {dist:.2f}mm")
@@ -2232,6 +2342,9 @@ class Sketch2DWidget(QWidget):
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
self._constraint_distance_value, 0, 10000, 2)
if ok and self._sketch:
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
self._sketch.constrain_distance(e1, e2, dist)
self._solve_and_sync()
logger.info(f"Distance {dist:.2f}mm")
@@ -2250,6 +2363,9 @@ class Sketch2DWidget(QWidget):
p1_ent, p2_ent = line_hit
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
if line_ent is not None and self._sketch and self._selected_entities:
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
self._sketch.constrain_midpoint(self._selected_entities[0], line_ent)
for ent in (p1_ent, p2_ent):
if "mid" not in ent.constraints:
@@ -2276,6 +2392,9 @@ class Sketch2DWidget(QWidget):
else:
# Second click: apply perpendicular constraint between two LINE entities
prev_ent = self._selected_entities[0]
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
if self._sketch:
self._sketch.constrain_perpendicular(prev_ent, target_ent)
self._solve_and_sync()
@@ -2298,6 +2417,9 @@ class Sketch2DWidget(QWidget):
self._selected_entities.append(target_ent)
else:
prev_ent = self._selected_entities[0]
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
if self._sketch:
self._sketch.constrain_parallel(prev_ent, target_ent)
self._solve_and_sync()
@@ -2318,6 +2440,9 @@ class Sketch2DWidget(QWidget):
p1_ent, p2_ent = line_hit
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
if line_ent is not None and self._sketch and self._selected_entities:
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
# coincident(point, line) = point-on-line; needs the line handle
self._sketch.constrain_coincident(self._selected_entities[0], line_ent)
self._solve_and_sync()
@@ -2336,6 +2461,9 @@ class Sketch2DWidget(QWidget):
p1_ent, p2_ent = line_hit
mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
if mirror_line is not None and self._sketch:
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
self._sketch.constrain_symmetric(
self._selected_entities[0], self._selected_entities[1], mirror_line
)
@@ -2360,6 +2488,46 @@ class Sketch2DWidget(QWidget):
logger.info("Click on the mirror line")
self.update()
def _handle_constraint_diameter(self, world_pos: QPoint):
"""Diameter constraint: click on a circle to set its diameter."""
# Check if we clicked on a circle
for c_ent, r in self._circles:
if c_ent.geometry and r > 0:
cx, cy = c_ent.geometry
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
if abs(d - r) < 8: # Clicked on circle circumference
# Prompt for diameter value
current_diameter = r * 2.0
diameter, ok = QInputDialog.getDouble(
self, "Diameter", "Diameter (mm):",
current_diameter, 0, 10000, 2
)
if ok and self._sketch:
# Save state before adding constraint
if self._undo_manager:
self._undo_manager.save_state()
new_radius = diameter / 2.0
# Update the circle's radius in the sketch
self._sketch._circles[c_ent.id] = (c_ent.id, new_radius)
# Update the local cache
for i, (ent, rad) in enumerate(self._circles):
if ent.id == c_ent.id:
self._circles[i] = (ent, new_radius)
break
# Record constraint for undo/redo
self._sketch._record_constraint(
"diameter", (c_ent.id,), (diameter,)
)
self._solve_and_sync()
logger.info(f"Diameter constraint: {diameter:.2f}mm")
self._selected_entities = []
self._mode = None
self.constrain_done.emit()
self.update()
return
# Clicked somewhere else - do nothing
self.update()
# ─── Painting ─────────────────────────────────────────────────────────
def _calculate_midpoint(self, p1: QPoint, p2: QPoint) -> QPointF:
@@ -2780,14 +2948,13 @@ class Sketch2DWidget(QWidget):
painter.setBrush(Qt.NoBrush)
painter.drawEllipse(screen_pos, 10, 10)
# ── Hovered line distance measurement ──
# ── Hovered line highlight ──
if self._hovered_line and not self._hovered_point:
p1, p2 = self._hovered_line
sp1 = self._world_to_screen(p1)
sp2 = self._world_to_screen(p2)
painter.setPen(QPen(QColor("#a6e3a1"), 2))
painter.drawLine(sp1, sp2)
self._draw_distance_measurement(painter, p1, p2)
# ── Moved-element highlight ──
if self._move_active and self._moving_points:
+10
View File
@@ -395,6 +395,16 @@ class Viewer3DWidget(QWidget):
np.array([0.0, 0.0, 1.0], dtype=float),
)
def get_camera_fov(self) -> float:
"""Return the current vertical FOV in degrees from the renderer.
Falls back to 45.0 if the renderer doesn't support it.
"""
self._ensure_initialized()
if hasattr(self._renderer, "get_camera_fov"):
return self._renderer.get_camera_fov()
return 45.0
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
def set_pick_face_mode(self, enabled: bool) -> None: