- added renderer
This commit is contained in:
Generated
+66
-38
@@ -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 - 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">{
|
||||
"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.kernel.executor": "Run",
|
||||
"Python.main.executor": "Run",
|
||||
"Python.meshtest.executor": "Run",
|
||||
"Python.occ_renderer.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",
|
||||
"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 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 - Split main.py refactor" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="- Added save file foramt - Split main.py refactor" />
|
||||
<MESSAGE value="- Working assembly multi :)" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="- Working assembly multi :)" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 (10–120 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.
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user