- added renderer
This commit is contained in:
Generated
+66
-38
@@ -4,11 +4,18 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<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$/.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/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/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/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" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
@@ -46,42 +53,45 @@
|
|||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent">{
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"Python.2dtest.executor": "Run",
|
"Python.2dtest.executor": "Run",
|
||||||
"Python.3d_windows.executor": "Run",
|
"Python.3d_windows.executor": "Run",
|
||||||
"Python.Unnamed.executor": "Run",
|
"Python.Unnamed.executor": "Run",
|
||||||
"Python.base.executor": "Run",
|
"Python.base.executor": "Run",
|
||||||
"Python.data_model.executor": "Run",
|
"Python.data_model.executor": "Run",
|
||||||
"Python.draw_widget2d.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.draw_widget_solve.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.fluency.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
"Python.fluencyb.executor": "Run",
|
"Python.fluencyb.executor": "Run",
|
||||||
"Python.gl_widget.executor": "Run",
|
"Python.gl_widget.executor": "Run",
|
||||||
"Python.kernel.executor": "Run",
|
"Python.gui_ui.executor": "Run",
|
||||||
"Python.main.executor": "Run",
|
"Python.kernel.executor": "Run",
|
||||||
"Python.meshtest.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.occ_renderer.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"Python.side_fluency.executor": "Run",
|
"Python.occ_renderer.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.occ_to_mesh.executor": "Run",
|
||||||
"Python.sketch.executor": "Run",
|
"Python.render_backend.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.side_fluency.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"Python.sketch.executor": "Run",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"Python.vtk_widget.executor": "Run",
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"Python.vulkan.executor": "Run",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"git-widget-placeholder": "feature/occ-migration",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"node.js.detected.package.eslint": "true",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/rendering",
|
||||||
"nodejs_package_manager_path": "npm",
|
"node.js.detected.package.eslint": "true",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
"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">
|
<component name="QodanaReportsService">
|
||||||
<option name="descriptions">
|
<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" />
|
<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>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="CopyFile.RECENT_KEYS">
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
|
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
||||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||||
@@ -378,7 +389,23 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1783777171864</updated>
|
<updated>1783777171864</updated>
|
||||||
</task>
|
</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 />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -423,6 +450,7 @@
|
|||||||
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
|
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
|
||||||
<MESSAGE value="- assembly draft" />
|
<MESSAGE value="- assembly draft" />
|
||||||
<MESSAGE value="- Added save file foramt - Split main.py refactor" />
|
<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>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -406,6 +406,11 @@ class SketchInterface(ABC):
|
|||||||
"""Constrain two circles to have equal radius."""
|
"""Constrain two circles to have equal radius."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
|
||||||
|
"""Set the diameter of a circle."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||||||
"""Fix an entity in place."""
|
"""Fix an entity in place."""
|
||||||
|
|||||||
@@ -626,6 +626,17 @@ class OCCSketch(SketchInterface):
|
|||||||
elif ctype == "equal_radius":
|
elif ctype == "equal_radius":
|
||||||
# tracked only (no solver entity)
|
# tracked only (no solver entity)
|
||||||
pass
|
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:
|
else:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -788,6 +799,21 @@ class OCCSketch(SketchInterface):
|
|||||||
self._record_constraint("equal_radius", (circle1.id, circle2.id))
|
self._record_constraint("equal_radius", (circle1.id, circle2.id))
|
||||||
return True
|
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:
|
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||||||
"""Fix an entity in place via dragged constraint."""
|
"""Fix an entity in place via dragged constraint."""
|
||||||
ent = self._entities.get(entity.id)
|
ent = self._entities.get(entity.id)
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
|
|||||||
"name": body.name,
|
"name": body.name,
|
||||||
"source_sketch_id": body.source_sketch.id if body.source_sketch else None,
|
"source_sketch_id": body.source_sketch.id if body.source_sketch else None,
|
||||||
"source_operation": body.source_operation,
|
"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),
|
"position": _coerce_listlike(body.position),
|
||||||
"rotation": _coerce_listlike(body.rotation),
|
"rotation": _coerce_listlike(body.rotation),
|
||||||
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
|
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
|
||||||
@@ -198,6 +206,14 @@ def _body_from_dict(
|
|||||||
geometry=geometry,
|
geometry=geometry,
|
||||||
source_sketch=source_sketch,
|
source_sketch=source_sketch,
|
||||||
source_operation=data.get("source_operation", "extrude"),
|
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")),
|
position=_to_3vec(data.get("position")),
|
||||||
rotation=_to_mat3(data.get("rotation")),
|
rotation=_to_mat3(data.get("rotation")),
|
||||||
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
|
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_ac_id": conn.partner_ac_id,
|
||||||
"partner_connector_id": conn.partner_connector_id,
|
"partner_connector_id": conn.partner_connector_id,
|
||||||
"is_grounded": bool(conn.is_grounded),
|
"is_grounded": bool(conn.is_grounded),
|
||||||
|
"is_invalid": bool(conn.is_invalid),
|
||||||
"created_at": conn.created_at.isoformat() if conn.created_at else None,
|
"created_at": conn.created_at.isoformat() if conn.created_at else None,
|
||||||
"modified_at": conn.modified_at.isoformat() if conn.modified_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_ac_id = data.get("partner_ac_id")
|
||||||
conn.partner_connector_id = data.get("partner_connector_id")
|
conn.partner_connector_id = data.get("partner_connector_id")
|
||||||
conn.is_grounded = bool(data.get("is_grounded", False))
|
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.created_at = _parse_iso(data.get("created_at"))
|
||||||
conn.modified_at = _parse_iso(data.get("modified_at"))
|
conn.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
return conn
|
return conn
|
||||||
|
|||||||
@@ -222,6 +222,20 @@ class Body:
|
|||||||
source_sketch: Optional[Sketch] = None
|
source_sketch: Optional[Sketch] = None
|
||||||
source_operation: str = "extrude"
|
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]))
|
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))
|
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.
|
# True on the first-picked (grounded) connector of a mated pair.
|
||||||
is_grounded: bool = False
|
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)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_at: datetime = field(default_factory=datetime.now)
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Rendering module."""
|
"""Rendering module."""
|
||||||
|
|
||||||
|
# OCC/OpenGL viewport renderers
|
||||||
from fluency.rendering.base import (
|
from fluency.rendering.base import (
|
||||||
Renderer,
|
Renderer,
|
||||||
RenderObject,
|
RenderObject,
|
||||||
@@ -8,10 +9,22 @@ from fluency.rendering.base import (
|
|||||||
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
|
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
|
||||||
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
|
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
|
||||||
|
|
||||||
|
# Photorealistic render backends
|
||||||
|
from fluency.rendering.render_backend import (
|
||||||
|
RenderBackend,
|
||||||
|
RenderMaterial,
|
||||||
|
RenderCamera,
|
||||||
|
RenderSettings,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Renderer",
|
"Renderer",
|
||||||
"RenderObject",
|
"RenderObject",
|
||||||
"RenderColor",
|
"RenderColor",
|
||||||
"PygfxRenderer",
|
"PygfxRenderer",
|
||||||
"PygfxRenderObject",
|
"PygfxRenderObject",
|
||||||
|
"RenderBackend",
|
||||||
|
"RenderMaterial",
|
||||||
|
"RenderCamera",
|
||||||
|
"RenderSettings",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -577,6 +577,126 @@ class OCCRenderer(Renderer):
|
|||||||
|
|
||||||
return (_xyz(eye), _xyz(at), _xyz(up))
|
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:
|
def fit_camera(self, padding: float = 0.05) -> None:
|
||||||
"""Fit camera to show all displayed objects.
|
"""Fit camera to show all displayed objects.
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._assembly_view_active: bool = False
|
self._assembly_view_active: bool = False
|
||||||
self._selected_assembly_component_id: Optional[str] = None
|
self._selected_assembly_component_id: Optional[str] = None
|
||||||
|
|
||||||
|
# Render window reference (prevents GC)
|
||||||
|
self._render_window = None
|
||||||
|
|
||||||
# Connector two-click state
|
# Connector two-click state
|
||||||
self._connector_first_pick: Optional[Dict[str, Any]] = None
|
self._connector_first_pick: Optional[Dict[str, Any]] = None
|
||||||
self._connector_second_ac_id: Optional[str] = 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)
|
self._action_load_last.toggled.connect(self._toggle_load_last_project)
|
||||||
file_menu.addAction(self._action_load_last)
|
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 (runtime-only, not in the .ui) ──
|
||||||
view_menu = self.menuBar().addMenu("&View")
|
view_menu = self.menuBar().addMenu("&View")
|
||||||
view_menu.addAction("Fit All", self._fit_view)
|
view_menu.addAction("Fit All", self._fit_view)
|
||||||
@@ -360,6 +377,12 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
view_menu.addAction(action)
|
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 (runtime-only, not in the .ui) ──
|
||||||
help_menu = self.menuBar().addMenu("&Help")
|
help_menu = self.menuBar().addMenu("&Help")
|
||||||
help_menu.addAction("About", self._show_about)
|
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_vert = ui.pb_con_vert
|
||||||
self._btn_con_dist = ui.pb_con_dist
|
self._btn_con_dist = ui.pb_con_dist
|
||||||
self._btn_con_sym = ui.pb_con_sym
|
self._btn_con_sym = ui.pb_con_sym
|
||||||
|
self._btn_con_diameter = ui.pb_con_diameter
|
||||||
# ── Snaps ──
|
# ── Snaps ──
|
||||||
self._btn_snap_point = ui.pushButton_8
|
self._btn_snap_point = ui.pushButton_8
|
||||||
self._btn_snap_mid = ui.pb_snap_midp
|
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 = ui.pb_add_connector
|
||||||
self._btn_add_connector.setCheckable(True)
|
self._btn_add_connector.setCheckable(True)
|
||||||
self._btn_del_connector = ui.pb_remove_connector
|
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 ──
|
# ── Code tab ──
|
||||||
self._btn_apply_code = ui.pb_apply_code
|
self._btn_apply_code = ui.pb_apply_code
|
||||||
self._btn_load_code = ui.pushButton_5
|
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_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_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_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(
|
self._btn_snap_point.clicked.connect(
|
||||||
lambda c: self._sketch_widget.set_snap_mode("point", c)
|
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_asm_move.toggled.connect(self._on_assembly_move_toggled)
|
||||||
self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
|
self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
|
||||||
self._btn_del_connector.clicked.connect(self._on_delete_connector)
|
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.connectorPicked.connect(self._on_connector_picked)
|
||||||
self._viewer_3d.connectorHover.connect(self._on_connector_hover)
|
self._viewer_3d.connectorHover.connect(self._on_connector_hover)
|
||||||
self._viewer_3d.connectorPickCancelled.connect(
|
self._viewer_3d.connectorPickCancelled.connect(
|
||||||
@@ -623,7 +652,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._on_assembly_move_finished
|
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_del_body.clicked.connect(self._delete_body)
|
||||||
|
|
||||||
self._btn_export_stl.clicked.connect(self._export_stl)
|
self._btn_export_stl.clicked.connect(self._export_stl)
|
||||||
@@ -686,6 +715,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_con_perp,
|
self._btn_con_perp,
|
||||||
self._btn_con_dist,
|
self._btn_con_dist,
|
||||||
self._btn_con_sym,
|
self._btn_con_sym,
|
||||||
|
self._btn_con_diameter,
|
||||||
]:
|
]:
|
||||||
btn.setChecked(False)
|
btn.setChecked(False)
|
||||||
|
|
||||||
@@ -726,12 +756,21 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_con_perp,
|
self._btn_con_perp,
|
||||||
self._btn_con_dist,
|
self._btn_con_dist,
|
||||||
self._btn_con_sym,
|
self._btn_con_sym,
|
||||||
|
self._btn_con_diameter,
|
||||||
]:
|
]:
|
||||||
btn.setChecked(False)
|
btn.setChecked(False)
|
||||||
self._sketch_widget.set_mode(None)
|
self._sketch_widget.set_mode(None)
|
||||||
|
|
||||||
def _on_sketch_updated(self):
|
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:
|
def _get_active_component_index(self) -> int:
|
||||||
for i, btn in enumerate(self._component_buttons):
|
for i, btn in enumerate(self._component_buttons):
|
||||||
@@ -792,6 +831,7 @@ class MainWindow(QMainWindow):
|
|||||||
def _refresh_lists(self):
|
def _refresh_lists(self):
|
||||||
self._sketch_list.clear()
|
self._sketch_list.clear()
|
||||||
self._body_list.clear()
|
self._body_list.clear()
|
||||||
|
self._refresh_connection_list()
|
||||||
|
|
||||||
if self._current_component:
|
if self._current_component:
|
||||||
for sketch_id, sketch in self._current_component.sketches.items():
|
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
|
# each body's visibility in the 3D viewer. The item's
|
||||||
# data role stores the body id so the toggle handler can
|
# data role stores the body id so the toggle handler can
|
||||||
# look up the right body without relying on display text.
|
# 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)
|
item.setData(Qt.UserRole, body_id)
|
||||||
# Qt.Checked = visible, Qt.Unchecked = hidden. Default
|
# Qt.Checked = visible, Qt.Unchecked = hidden. Default
|
||||||
# is whatever the body model says.
|
# 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.
|
# Greying out a hidden body's name is a nice UX touch.
|
||||||
if not body.visible:
|
if not body.visible:
|
||||||
item.setForeground(QColor("#6c7086"))
|
item.setForeground(QColor("#6c7086"))
|
||||||
|
elif body.needs_update:
|
||||||
|
item.setForeground(QColor("#f5a623")) # Orange warning
|
||||||
self._body_list.addItem(item)
|
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):
|
def _redraw_bodies(self):
|
||||||
self._viewer_3d.clear_scene()
|
self._viewer_3d.clear_scene()
|
||||||
|
|
||||||
@@ -1078,11 +1343,12 @@ class MainWindow(QMainWindow):
|
|||||||
try:
|
try:
|
||||||
local_pos = np.array(conn.position, dtype=float)
|
local_pos = np.array(conn.position, dtype=float)
|
||||||
world_pos = ac.position + ac.rotation @ local_pos
|
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:
|
if sphere_shape is not None:
|
||||||
self._viewer_3d.show_shape(
|
self._viewer_3d.show_shape(
|
||||||
sphere_shape,
|
sphere_shape,
|
||||||
color=(1.0, 0.3, 0.0), # Orange
|
color=conn_color,
|
||||||
name=f"conn_{ac_id}_{conn_id}",
|
name=f"conn_{ac_id}_{conn_id}",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1151,11 +1417,12 @@ class MainWindow(QMainWindow):
|
|||||||
try:
|
try:
|
||||||
local_pos = np.array(conn.position, dtype=float)
|
local_pos = np.array(conn.position, dtype=float)
|
||||||
world_pos = ac.position + ac.rotation @ local_pos
|
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:
|
if sphere_shape is not None:
|
||||||
self._viewer_3d.show_shape(
|
self._viewer_3d.show_shape(
|
||||||
sphere_shape,
|
sphere_shape,
|
||||||
color=(1.0, 0.3, 0.0), # Orange
|
color=conn_color,
|
||||||
name=f"conn_{ac_id}_{conn_id}",
|
name=f"conn_{ac_id}_{conn_id}",
|
||||||
)
|
)
|
||||||
new_ids.append(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_first_pick = None
|
||||||
self._connector_second_ac_id = None
|
self._connector_second_ac_id = None
|
||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
|
self._refresh_connection_list()
|
||||||
self._show_assembly_in_viewer(fit=True)
|
self._show_assembly_in_viewer(fit=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1962,6 +2230,92 @@ class MainWindow(QMainWindow):
|
|||||||
ac.remove_connector(conn_id)
|
ac.remove_connector(conn_id)
|
||||||
logger.info(f"Removed connector {conn_id}")
|
logger.info(f"Removed connector {conn_id}")
|
||||||
self._show_assembly_in_viewer(fit=True)
|
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):
|
def _new_workplane(self):
|
||||||
"""Open the orientation dialog and create a new independent workplane.
|
"""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}"
|
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:
|
try:
|
||||||
result = self._compute_extrude_result(
|
result = self._compute_extrude_result(
|
||||||
sketch, face_geom,
|
sketch, face_geom,
|
||||||
@@ -3065,6 +3423,17 @@ class MainWindow(QMainWindow):
|
|||||||
# "added without cut" bug once the spurious body was
|
# "added without cut" bug once the spurious body was
|
||||||
# deleted).
|
# deleted).
|
||||||
target.geometry = result["result_geom"]
|
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:
|
if target.render_object is not None:
|
||||||
self._viewer_3d.remove_mesh(target.render_object)
|
self._viewer_3d.remove_mesh(target.render_object)
|
||||||
shape = self._kernel._get_shape(target.geometry)
|
shape = self._kernel._get_shape(target.geometry)
|
||||||
@@ -3084,6 +3453,13 @@ class MainWindow(QMainWindow):
|
|||||||
geometry=result["result_geom"],
|
geometry=result["result_geom"],
|
||||||
source_sketch=sketch,
|
source_sketch=sketch,
|
||||||
source_operation="extrude",
|
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()
|
self._mark_dirty()
|
||||||
@@ -3204,6 +3580,8 @@ class MainWindow(QMainWindow):
|
|||||||
name=f"Cut_{len(self._current_component.bodies) + 1}",
|
name=f"Cut_{len(self._current_component.bodies) + 1}",
|
||||||
geometry=result_geom,
|
geometry=result_geom,
|
||||||
source_operation="boolean_cut",
|
source_operation="boolean_cut",
|
||||||
|
extrude_cut=True,
|
||||||
|
extrude_target_body_id=base_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
@@ -3241,6 +3619,7 @@ class MainWindow(QMainWindow):
|
|||||||
name=f"Union_{len(self._current_component.bodies) + 1}",
|
name=f"Union_{len(self._current_component.bodies) + 1}",
|
||||||
geometry=result_geom,
|
geometry=result_geom,
|
||||||
source_operation="boolean_union",
|
source_operation="boolean_union",
|
||||||
|
extrude_union=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
@@ -3792,6 +4171,115 @@ class MainWindow(QMainWindow):
|
|||||||
def _reset_view(self):
|
def _reset_view(self):
|
||||||
self._viewer_3d.set_camera_position((100, 100, 100), (0, 0, 0))
|
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):
|
def _show_about(self):
|
||||||
QMessageBox.about(
|
QMessageBox.about(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+215
-48
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
|
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
|
||||||
|
from fluency.sketch_undo import SketchUndoManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -178,6 +179,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._offset_preview_points: List[Tuple[float, float]] = []
|
self._offset_preview_points: List[Tuple[float, float]] = []
|
||||||
self._offset_preview_active: bool = False
|
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.setFocusPolicy(Qt.StrongFocus)
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
|
||||||
@@ -197,6 +201,11 @@ class Sketch2DWidget(QWidget):
|
|||||||
# them if the new sketch is on a face too.)
|
# them if the new sketch is on a face too.)
|
||||||
if self._sketch is not None and self._sketch is not sketch:
|
if self._sketch is not None and self._sketch is not sketch:
|
||||||
self._sketch.remove_external_entities()
|
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()
|
self.update()
|
||||||
|
|
||||||
def clear_source_face(self) -> None:
|
def clear_source_face(self) -> None:
|
||||||
@@ -412,6 +421,16 @@ class Sketch2DWidget(QWidget):
|
|||||||
return self._sketch.build_face_geometry(self._selected_face)
|
return self._sketch.build_face_geometry(self._selected_face)
|
||||||
return None
|
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):
|
def clear_selected_face(self):
|
||||||
self._selected_face = None
|
self._selected_face = None
|
||||||
self._hovered_face = None
|
self._hovered_face = None
|
||||||
@@ -499,12 +518,18 @@ class Sketch2DWidget(QWidget):
|
|||||||
def get_sketch(self) -> Optional[OCCSketch]:
|
def get_sketch(self) -> Optional[OCCSketch]:
|
||||||
return self._sketch
|
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:
|
def create_sketch(self) -> OCCSketch:
|
||||||
self._sketch = OCCSketch()
|
self._sketch = OCCSketch()
|
||||||
self._sketch.add_centerlines()
|
self._sketch.add_centerlines()
|
||||||
# Sync widget tracking lists from sketch so centerlines (and any
|
# Sync widget tracking lists from sketch so centerlines (and any
|
||||||
# future auto-created entities) are immediately pickable.
|
# future auto-created entities) are immediately pickable.
|
||||||
self._rebuild_from_sketch()
|
self._rebuild_from_sketch()
|
||||||
|
# Initialize undo manager for the new sketch
|
||||||
|
self._undo_manager = SketchUndoManager(self._sketch)
|
||||||
self.update()
|
self.update()
|
||||||
return self._sketch
|
return self._sketch
|
||||||
|
|
||||||
@@ -577,6 +602,8 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._selected_entities = []
|
self._selected_entities = []
|
||||||
self._hovered_constraint_idx = -1
|
self._hovered_constraint_idx = -1
|
||||||
self._hovered_face = None
|
self._hovered_face = None
|
||||||
|
self._hovered_circle = None
|
||||||
|
self._hovered_circle_entity = None
|
||||||
self._arc_accum_sweep = 0.0
|
self._arc_accum_sweep = 0.0
|
||||||
self._arc_prev_angle = None
|
self._arc_prev_angle = None
|
||||||
self.clear_offset_preview()
|
self.clear_offset_preview()
|
||||||
@@ -1267,7 +1294,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if event.button() == Qt.LeftButton:
|
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:
|
if self._mode in ("select", None) and self._sketch:
|
||||||
# ① Tight point check (4 px) — a deliberate grab on a point.
|
# ① Tight point check (4 px) — a deliberate grab on a point.
|
||||||
tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4)
|
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 = tight_ent
|
||||||
self._move_anchor_orig = QPoint(int(round(x)), int(round(y)))
|
self._move_anchor_orig = QPoint(int(round(x)), int(round(y)))
|
||||||
self._move_orig_positions = orig
|
self._move_orig_positions = orig
|
||||||
|
# Save state before starting move
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
self._move_active = True
|
self._move_active = True
|
||||||
self.setCursor(Qt.ClosedHandCursor)
|
self.setCursor(Qt.ClosedHandCursor)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ② Face region — click inside a closed face to select it.
|
# ② Wider element-move check (lines, circles, arcs).
|
||||||
# Use float-precision world coords so small shapes (sub-integer
|
# Lines/circles/arcs have small hit targets — check them
|
||||||
# at the current zoom) are still pickable.
|
# BEFORE face so they aren't blocked by the large interior
|
||||||
fwx, fwy = self._screen_to_world_f(event.pos())
|
# selection zone. External (underlay) entities are fixed
|
||||||
face = self._sketch.find_face_at(fwx, fwy)
|
# references and can't be dragged.
|
||||||
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.
|
|
||||||
target = self._find_move_target(event.pos())
|
target = self._find_move_target(event.pos())
|
||||||
if (target is not None
|
if (target is not None
|
||||||
and not self._is_external(target[0])
|
and not self._is_external(target[0])
|
||||||
@@ -1322,10 +1337,30 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._move_anchor = anchor_ent
|
self._move_anchor = anchor_ent
|
||||||
self._move_anchor_orig = anchor_world
|
self._move_anchor_orig = anchor_world
|
||||||
self._move_orig_positions = orig
|
self._move_orig_positions = orig
|
||||||
|
# Save state before starting move
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
self._move_active = True
|
self._move_active = True
|
||||||
self.setCursor(Qt.ClosedHandCursor)
|
self.setCursor(Qt.ClosedHandCursor)
|
||||||
return
|
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(
|
snapped_pos = self._apply_all_snaps(
|
||||||
event.pos(), self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None
|
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)
|
self._handle_constraint_ptline(world_snapped)
|
||||||
elif self._mode == "constrain_symmetric":
|
elif self._mode == "constrain_symmetric":
|
||||||
self._handle_constraint_symmetric(world_snapped)
|
self._handle_constraint_symmetric(world_snapped)
|
||||||
|
elif self._mode == "constrain_diameter":
|
||||||
|
self._handle_constraint_diameter(world_snapped)
|
||||||
|
|
||||||
def mouseMoveEvent(self, event):
|
def mouseMoveEvent(self, event):
|
||||||
if self._panning and self._pan_start:
|
if self._panning and self._pan_start:
|
||||||
@@ -1455,10 +1492,10 @@ class Sketch2DWidget(QWidget):
|
|||||||
if self._hovered_constraint_idx != -1:
|
if self._hovered_constraint_idx != -1:
|
||||||
self._hovered_constraint_idx = -1
|
self._hovered_constraint_idx = -1
|
||||||
|
|
||||||
# Priority for select/move mode: point > face > line > circle.
|
# Priority for select/move mode: point > line > circle > face.
|
||||||
# Face is checked before line so the user can see and select the
|
# Lines/circles have small hit targets — check them before face so
|
||||||
# wall region between two concentric boundaries (e.g. after offset).
|
# the large interior face zone doesn't block picking. In drawing
|
||||||
# In drawing modes, lines take priority so the user can snap to them.
|
# modes, lines take priority so the user can snap to them.
|
||||||
hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor
|
hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor
|
||||||
|
|
||||||
# ── ① Point hover (tightest, always first) ──
|
# ── ① Point hover (tightest, always first) ──
|
||||||
@@ -1474,25 +1511,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._hovered_point = None
|
self._hovered_point = None
|
||||||
self._hovered_point_entity = None
|
self._hovered_point_entity = None
|
||||||
|
|
||||||
# ── ② Face hover (checked before line in select mode) ──
|
# ── ② Line hover ──
|
||||||
# In select/move mode the face is the primary interaction target
|
# Lines have small hit targets — check them BEFORE face so
|
||||||
# (select to extrude etc.). Lines remain selectable via click
|
# the large interior face zone doesn't block line picking.
|
||||||
# (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)
|
line_hit = self._get_line_entity_at(world_pos)
|
||||||
if line_hit:
|
if line_hit:
|
||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
@@ -1502,15 +1523,18 @@ class Sketch2DWidget(QWidget):
|
|||||||
QPoint(int(round(p2_ent.geometry[0])), int(round(p2_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_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
|
self._hovered_face = None
|
||||||
self.setCursor(hover_cursor)
|
self.setCursor(hover_cursor)
|
||||||
else:
|
else:
|
||||||
self._hovered_line = None
|
self._hovered_line = None
|
||||||
self._hovered_line_entity = None
|
self._hovered_line_entity = None
|
||||||
|
self._hovered_face = None
|
||||||
self.setCursor(Qt.ArrowCursor)
|
self.setCursor(Qt.ArrowCursor)
|
||||||
else:
|
else:
|
||||||
self._hovered_line = None
|
self._hovered_line = None
|
||||||
self._hovered_line_entity = None
|
self._hovered_line_entity = None
|
||||||
# ── ④ Circle hover ──
|
|
||||||
|
# ── ③ Circle / arc hover ──
|
||||||
if self._mode in ("select", None):
|
if self._mode in ("select", None):
|
||||||
over_circle = False
|
over_circle = False
|
||||||
for c_ent, r in self._circles:
|
for c_ent, r in self._circles:
|
||||||
@@ -1520,9 +1544,31 @@ class Sketch2DWidget(QWidget):
|
|||||||
if abs(d - r) < 8:
|
if abs(d - r) < 8:
|
||||||
over_circle = True
|
over_circle = True
|
||||||
break
|
break
|
||||||
self.setCursor(Qt.OpenHandCursor if over_circle else Qt.ArrowCursor)
|
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:
|
else:
|
||||||
|
self._hovered_circle = None
|
||||||
|
self._hovered_circle_entity = None
|
||||||
self.setCursor(Qt.ArrowCursor)
|
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()
|
self.update()
|
||||||
|
|
||||||
@@ -1572,6 +1618,31 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def keyPressEvent(self, event):
|
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
|
# Delete / Backspace removes the entity currently under the cursor
|
||||||
# and recomputes the surviving constraints. Priority: constraint tag >
|
# and recomputes the surviving constraints. Priority: constraint tag >
|
||||||
# line > point. Works in Move mode or when no tool is selected; ignored
|
# 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
|
idx = self._hovered_constraint_idx
|
||||||
if idx < 0 or self._sketch is None:
|
if idx < 0 or self._sketch is None:
|
||||||
return
|
return
|
||||||
|
# Save state before deleting
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
ok = self._sketch.remove_constraint_at(idx)
|
ok = self._sketch.remove_constraint_at(idx)
|
||||||
logger.info(f"Deleted constraint #{idx}; recompute solved={ok}")
|
logger.info(f"Deleted constraint #{idx}; recompute solved={ok}")
|
||||||
self._hovered_constraint_idx = -1
|
self._hovered_constraint_idx = -1
|
||||||
@@ -1630,6 +1704,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
if self._is_centerline(line_ent):
|
if self._is_centerline(line_ent):
|
||||||
logger.debug("Refusing to delete centerline")
|
logger.debug("Refusing to delete centerline")
|
||||||
return
|
return
|
||||||
|
# Save state before deleting
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
ok = self._sketch.delete_line(line_ent)
|
ok = self._sketch.delete_line(line_ent)
|
||||||
logger.info(f"Deleted line {line_ent.id}; recompute solved={ok}")
|
logger.info(f"Deleted line {line_ent.id}; recompute solved={ok}")
|
||||||
# Refresh widget tracking from the pruned sketch and sync solved positions.
|
# Refresh widget tracking from the pruned sketch and sync solved positions.
|
||||||
@@ -1660,6 +1737,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
if self._is_centerline(point_ent):
|
if self._is_centerline(point_ent):
|
||||||
logger.debug("Refusing to delete centerline point")
|
logger.debug("Refusing to delete centerline point")
|
||||||
return
|
return
|
||||||
|
# Save state before deleting
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
ok = self._sketch.delete_point(point_ent)
|
ok = self._sketch.delete_point(point_ent)
|
||||||
logger.info(f"Deleted point {point_ent.id}; recompute solved={ok}")
|
logger.info(f"Deleted point {point_ent.id}; recompute solved={ok}")
|
||||||
self._rebuild_from_sketch()
|
self._rebuild_from_sketch()
|
||||||
@@ -1693,6 +1773,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
if self._is_centerline(line_ent):
|
if self._is_centerline(line_ent):
|
||||||
logger.debug("Refusing to toggle centerline")
|
logger.debug("Refusing to toggle centerline")
|
||||||
return
|
return
|
||||||
|
# Save state before toggling
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
# Flip the construction flag on the line entity itself.
|
# Flip the construction flag on the line entity itself.
|
||||||
new_val = not line_ent.is_construction
|
new_val = not line_ent.is_construction
|
||||||
line_ent.is_construction = new_val
|
line_ent.is_construction = new_val
|
||||||
@@ -1711,6 +1794,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._ensure_sketch_with_centerlines()
|
self._ensure_sketch_with_centerlines()
|
||||||
|
|
||||||
if not self._draw_buffer:
|
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 = self._sketch.add_point(pos.x(), pos.y())
|
||||||
point.is_construction = self._is_construct
|
point.is_construction = self._is_construct
|
||||||
self._points.append(point)
|
self._points.append(point)
|
||||||
@@ -1756,6 +1842,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._ensure_sketch_with_centerlines()
|
self._ensure_sketch_with_centerlines()
|
||||||
|
|
||||||
if not self._draw_buffer:
|
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._draw_buffer.append(pos)
|
||||||
self._rect_first_snap_target = self._snap_point_target
|
self._rect_first_snap_target = self._snap_point_target
|
||||||
else:
|
else:
|
||||||
@@ -1818,6 +1907,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._ensure_sketch_with_centerlines()
|
self._ensure_sketch_with_centerlines()
|
||||||
|
|
||||||
if not self._draw_buffer:
|
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 = self._sketch.add_point(pos.x(), pos.y())
|
||||||
center.is_construction = self._is_construct
|
center.is_construction = self._is_construct
|
||||||
self._points.append(center)
|
self._points.append(center)
|
||||||
@@ -1846,6 +1938,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._ensure_sketch_with_centerlines()
|
self._ensure_sketch_with_centerlines()
|
||||||
|
|
||||||
if not self._draw_buffer:
|
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
|
# Click 1: place center point
|
||||||
center = self._sketch.add_point(pos.x(), pos.y())
|
center = self._sketch.add_point(pos.x(), pos.y())
|
||||||
center.is_construction = self._is_construct
|
center.is_construction = self._is_construct
|
||||||
@@ -1938,6 +2033,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._ensure_sketch_with_centerlines()
|
self._ensure_sketch_with_centerlines()
|
||||||
|
|
||||||
if not self._draw_buffer:
|
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.
|
# Click 1: place first arc center C1.
|
||||||
c1 = self._sketch.add_point(pos.x(), pos.y())
|
c1 = self._sketch.add_point(pos.x(), pos.y())
|
||||||
c1.is_construction = self._is_construct
|
c1.is_construction = self._is_construct
|
||||||
@@ -2146,6 +2244,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._selected_entities.append(ent)
|
self._selected_entities.append(ent)
|
||||||
if len(self._selected_entities) >= 2:
|
if len(self._selected_entities) >= 2:
|
||||||
e1, e2 = 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:
|
if self._sketch:
|
||||||
self._sketch.constrain_coincident(e1, e2)
|
self._sketch.constrain_coincident(e1, e2)
|
||||||
ok = self._solve_and_sync()
|
ok = self._solve_and_sync()
|
||||||
@@ -2166,6 +2267,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
if line_ent is None:
|
if line_ent is None:
|
||||||
return
|
return
|
||||||
|
# Save state before adding constraint
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
if self._sketch:
|
if self._sketch:
|
||||||
self._sketch.constrain_horizontal(line_ent)
|
self._sketch.constrain_horizontal(line_ent)
|
||||||
# Tag endpoints so paintEvent renders the "> hrz <" label.
|
# 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)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
if line_ent is None:
|
if line_ent is None:
|
||||||
return
|
return
|
||||||
|
# Save state before adding constraint
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
if self._sketch:
|
if self._sketch:
|
||||||
self._sketch.constrain_vertical(line_ent)
|
self._sketch.constrain_vertical(line_ent)
|
||||||
for ent in (p1_ent, p2_ent):
|
for ent in (p1_ent, p2_ent):
|
||||||
@@ -2218,6 +2325,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
|
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
|
||||||
self._constraint_distance_value, 0, 10000, 2)
|
self._constraint_distance_value, 0, 10000, 2)
|
||||||
if ok and self._sketch:
|
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._sketch.constrain_distance(p1_ent, p2_ent, dist)
|
||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
logger.info(f"Line distance {dist:.2f}mm")
|
logger.info(f"Line distance {dist:.2f}mm")
|
||||||
@@ -2232,6 +2342,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
|
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
|
||||||
self._constraint_distance_value, 0, 10000, 2)
|
self._constraint_distance_value, 0, 10000, 2)
|
||||||
if ok and self._sketch:
|
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._sketch.constrain_distance(e1, e2, dist)
|
||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
logger.info(f"Distance {dist:.2f}mm")
|
logger.info(f"Distance {dist:.2f}mm")
|
||||||
@@ -2250,6 +2363,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
if line_ent is not None and self._sketch and self._selected_entities:
|
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)
|
self._sketch.constrain_midpoint(self._selected_entities[0], line_ent)
|
||||||
for ent in (p1_ent, p2_ent):
|
for ent in (p1_ent, p2_ent):
|
||||||
if "mid" not in ent.constraints:
|
if "mid" not in ent.constraints:
|
||||||
@@ -2276,6 +2392,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
else:
|
else:
|
||||||
# Second click: apply perpendicular constraint between two LINE entities
|
# Second click: apply perpendicular constraint between two LINE entities
|
||||||
prev_ent = self._selected_entities[0]
|
prev_ent = self._selected_entities[0]
|
||||||
|
# Save state before adding constraint
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
if self._sketch:
|
if self._sketch:
|
||||||
self._sketch.constrain_perpendicular(prev_ent, target_ent)
|
self._sketch.constrain_perpendicular(prev_ent, target_ent)
|
||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
@@ -2298,6 +2417,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._selected_entities.append(target_ent)
|
self._selected_entities.append(target_ent)
|
||||||
else:
|
else:
|
||||||
prev_ent = self._selected_entities[0]
|
prev_ent = self._selected_entities[0]
|
||||||
|
# Save state before adding constraint
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
if self._sketch:
|
if self._sketch:
|
||||||
self._sketch.constrain_parallel(prev_ent, target_ent)
|
self._sketch.constrain_parallel(prev_ent, target_ent)
|
||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
@@ -2318,6 +2440,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
if line_ent is not None and self._sketch and self._selected_entities:
|
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
|
# coincident(point, line) = point-on-line; needs the line handle
|
||||||
self._sketch.constrain_coincident(self._selected_entities[0], line_ent)
|
self._sketch.constrain_coincident(self._selected_entities[0], line_ent)
|
||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
@@ -2336,6 +2461,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
|
mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
if mirror_line is not None and self._sketch:
|
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._sketch.constrain_symmetric(
|
||||||
self._selected_entities[0], self._selected_entities[1], mirror_line
|
self._selected_entities[0], self._selected_entities[1], mirror_line
|
||||||
)
|
)
|
||||||
@@ -2360,6 +2488,46 @@ class Sketch2DWidget(QWidget):
|
|||||||
logger.info("Click on the mirror line")
|
logger.info("Click on the mirror line")
|
||||||
self.update()
|
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 ─────────────────────────────────────────────────────────
|
# ─── Painting ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _calculate_midpoint(self, p1: QPoint, p2: QPoint) -> QPointF:
|
def _calculate_midpoint(self, p1: QPoint, p2: QPoint) -> QPointF:
|
||||||
@@ -2780,14 +2948,13 @@ class Sketch2DWidget(QWidget):
|
|||||||
painter.setBrush(Qt.NoBrush)
|
painter.setBrush(Qt.NoBrush)
|
||||||
painter.drawEllipse(screen_pos, 10, 10)
|
painter.drawEllipse(screen_pos, 10, 10)
|
||||||
|
|
||||||
# ── Hovered line distance measurement ──
|
# ── Hovered line highlight ──
|
||||||
if self._hovered_line and not self._hovered_point:
|
if self._hovered_line and not self._hovered_point:
|
||||||
p1, p2 = self._hovered_line
|
p1, p2 = self._hovered_line
|
||||||
sp1 = self._world_to_screen(p1)
|
sp1 = self._world_to_screen(p1)
|
||||||
sp2 = self._world_to_screen(p2)
|
sp2 = self._world_to_screen(p2)
|
||||||
painter.setPen(QPen(QColor("#a6e3a1"), 2))
|
painter.setPen(QPen(QColor("#a6e3a1"), 2))
|
||||||
painter.drawLine(sp1, sp2)
|
painter.drawLine(sp1, sp2)
|
||||||
self._draw_distance_measurement(painter, p1, p2)
|
|
||||||
|
|
||||||
# ── Moved-element highlight ──
|
# ── Moved-element highlight ──
|
||||||
if self._move_active and self._moving_points:
|
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),
|
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) ────────────────────────────────
|
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
|
||||||
|
|
||||||
def set_pick_face_mode(self, enabled: bool) -> None:
|
def set_pick_face_mode(self, enabled: bool) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user