- Added save file foramt
- Split main.py refactor
This commit is contained in:
Generated
+9
-4
@@ -6,9 +6,6 @@
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- assembly draft">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.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/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -329,7 +326,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783239410744</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="28" />
|
||||
<task id="LOCAL-00028" summary="- assembly draft">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783272988957</created>
|
||||
<option name="number" value="00028" />
|
||||
<option name="presentableId" value="LOCAL-00028" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783272988957</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="29" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
|
||||
@@ -1190,9 +1190,17 @@
|
||||
<string>File</string>
|
||||
</property>
|
||||
<addaction name="actionNew_Project"/>
|
||||
<addaction name="actionLoad_Project"/>
|
||||
<addaction name="actionRecent"/>
|
||||
<addaction name="actionOpen_Project"/>
|
||||
<addaction name="actionSave_Project"/>
|
||||
<addaction name="actionSave_Project_As"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionImport_File"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionExport_Step"/>
|
||||
<addaction name="actionExport_Iges"/>
|
||||
<addaction name="actionExport_Stl"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionExit"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuSettings">
|
||||
<property name="title">
|
||||
@@ -1205,17 +1213,62 @@
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<action name="actionNew_Project">
|
||||
<property name="text">
|
||||
<string>New</string>
|
||||
<string>New Project</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+N</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionLoad_Project">
|
||||
<action name="actionOpen_Project">
|
||||
<property name="text">
|
||||
<string>Load</string>
|
||||
<string>Open Project...</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+O</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionRecent">
|
||||
<action name="actionSave_Project">
|
||||
<property name="text">
|
||||
<string>Recent</string>
|
||||
<string>Save Project</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+S</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave_Project_As">
|
||||
<property name="text">
|
||||
<string>Save Project As...</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Shift+S</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionImport_File">
|
||||
<property name="text">
|
||||
<string>Import STEP/IGES...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionExport_Step">
|
||||
<property name="text">
|
||||
<string>Export STEP...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionExport_Iges">
|
||||
<property name="text">
|
||||
<string>Export IGES...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionExport_Stl">
|
||||
<property name="text">
|
||||
<string>Export STL...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionExit">
|
||||
<property name="text">
|
||||
<string>Exit</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Q</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
|
||||
@@ -29,10 +29,22 @@ class Ui_fluencyCAD(object):
|
||||
fluencyCAD.resize(1941, 1155)
|
||||
self.actionNew_Project = QAction(fluencyCAD)
|
||||
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||
self.actionLoad_Project = QAction(fluencyCAD)
|
||||
self.actionLoad_Project.setObjectName(u"actionLoad_Project")
|
||||
self.actionRecent = QAction(fluencyCAD)
|
||||
self.actionRecent.setObjectName(u"actionRecent")
|
||||
self.actionOpen_Project = QAction(fluencyCAD)
|
||||
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
|
||||
self.actionSave_Project = QAction(fluencyCAD)
|
||||
self.actionSave_Project.setObjectName(u"actionSave_Project")
|
||||
self.actionSave_Project_As = QAction(fluencyCAD)
|
||||
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
|
||||
self.actionImport_File = QAction(fluencyCAD)
|
||||
self.actionImport_File.setObjectName(u"actionImport_File")
|
||||
self.actionExport_Step = QAction(fluencyCAD)
|
||||
self.actionExport_Step.setObjectName(u"actionExport_Step")
|
||||
self.actionExport_Iges = QAction(fluencyCAD)
|
||||
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
|
||||
self.actionExport_Stl = QAction(fluencyCAD)
|
||||
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
|
||||
self.actionExit = QAction(fluencyCAD)
|
||||
self.actionExit.setObjectName(u"actionExit")
|
||||
self.centralwidget = QWidget(fluencyCAD)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.gridLayout = QGridLayout(self.centralwidget)
|
||||
@@ -649,9 +661,17 @@ class Ui_fluencyCAD(object):
|
||||
self.menubar.addAction(self.menuFile.menuAction())
|
||||
self.menubar.addAction(self.menuSettings.menuAction())
|
||||
self.menuFile.addAction(self.actionNew_Project)
|
||||
self.menuFile.addAction(self.actionLoad_Project)
|
||||
self.menuFile.addAction(self.actionRecent)
|
||||
self.menuFile.addAction(self.actionOpen_Project)
|
||||
self.menuFile.addAction(self.actionSave_Project)
|
||||
self.menuFile.addAction(self.actionSave_Project_As)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionImport_File)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionExport_Step)
|
||||
self.menuFile.addAction(self.actionExport_Iges)
|
||||
self.menuFile.addAction(self.actionExport_Stl)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionExit)
|
||||
|
||||
self.retranslateUi(fluencyCAD)
|
||||
|
||||
@@ -663,9 +683,30 @@ class Ui_fluencyCAD(object):
|
||||
|
||||
def retranslateUi(self, fluencyCAD):
|
||||
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
|
||||
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||
self.actionLoad_Project.setText(QCoreApplication.translate("fluencyCAD", u"Load", None))
|
||||
self.actionRecent.setText(QCoreApplication.translate("fluencyCAD", u"Recent", None))
|
||||
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
|
||||
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
|
||||
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
|
||||
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
|
||||
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
|
||||
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
|
||||
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
|
||||
@@ -1673,3 +1673,205 @@ class OCCSketch(SketchInterface):
|
||||
return self._solver.dof() == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ─── Serialization (used by fluency.io.project_io) ─────────────────────
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize the sketch to a plain-dict for JSON storage.
|
||||
|
||||
Captures: the workplane, every entity (with its current geometry and
|
||||
flags), the constraint log, and the entity counter. Live solver
|
||||
handles are intentionally NOT saved — the consumer must call
|
||||
:meth:`from_dict` (or :meth:`rebuild_from_dict`) to rebuild the
|
||||
SolveSpace system before solving again.
|
||||
"""
|
||||
# Sort entities by id so replay order is deterministic and matches
|
||||
# creation order (ids are assigned monotonically by ``_next_id``).
|
||||
entities_payload: List[Dict[str, Any]] = []
|
||||
for eid in sorted(self._entities.keys()):
|
||||
ent = self._entities[eid]
|
||||
entities_payload.append(
|
||||
{
|
||||
"id": eid,
|
||||
"type": ent.entity_type,
|
||||
# geometry shape varies: point→(x,y), line→((x1,y1),(x2,y2)),
|
||||
# circle→((cx,cy),r), arc→dict. All JSON-friendly.
|
||||
"geometry": ent.geometry,
|
||||
"is_construction": bool(ent.is_construction),
|
||||
"is_external": bool(ent.is_external),
|
||||
"constraints": list(ent.constraints),
|
||||
}
|
||||
)
|
||||
|
||||
# Sets become sorted lists for JSON. ``labels`` inside constraint_log
|
||||
# is a set on the wire; convert to sorted list for JSON round-trip.
|
||||
constraint_log_payload: List[Dict[str, Any]] = []
|
||||
for entry in self._constraint_log:
|
||||
constraint_log_payload.append(
|
||||
{
|
||||
"type": entry["type"],
|
||||
"ids": list(entry["ids"]),
|
||||
"params": list(entry["params"]),
|
||||
"labels": sorted(entry["labels"]),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"wp_origin": list(self._wp_origin),
|
||||
"wp_normal": list(self._wp_normal),
|
||||
"wp_x_dir": list(self._wp_x_dir),
|
||||
"wp_y_dir": list(self._wp_y_dir),
|
||||
"entity_counter": self._entity_counter,
|
||||
"first_point_id": self._first_point_id,
|
||||
"external_entity_ids": sorted(self._external_entity_ids),
|
||||
"centerline_ids": sorted(self._centerline_ids),
|
||||
"constraint_count": self._constraint_count,
|
||||
"entities": entities_payload,
|
||||
"constraint_log": constraint_log_payload,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "OCCSketch":
|
||||
"""Build a fresh OCCSketch that reproduces the saved state.
|
||||
|
||||
Replays the construction sequence (points → lines → circles → arcs,
|
||||
respecting external/centerline flags) and re-applies every constraint
|
||||
in the saved log. The SolveSpace solver is deterministic for a given
|
||||
input, so the post-solve state matches the saved one.
|
||||
"""
|
||||
sk = cls()
|
||||
sk.rebuild_from_dict(data)
|
||||
return sk
|
||||
|
||||
def rebuild_from_dict(self, data: Dict[str, Any]) -> None:
|
||||
"""In-place restore from a dict produced by :meth:`to_dict`.
|
||||
|
||||
Wipes the current solver/state and re-creates every entity in id
|
||||
order so :attr:`_first_point_id` is anchored correctly. Existing
|
||||
callers (notably the solver-rebuild path on entity delete) don't use
|
||||
this; only the project load path does.
|
||||
"""
|
||||
# Wipe solver + trackers (don't lose the workplane yet — we set it
|
||||
# explicitly below).
|
||||
self.clear()
|
||||
self._external_entity_ids.clear()
|
||||
self._centerline_ids.clear()
|
||||
|
||||
# 1. Workplane.
|
||||
self.set_workplane(
|
||||
tuple(data["wp_origin"]),
|
||||
tuple(data["wp_normal"]),
|
||||
tuple(data["wp_x_dir"]),
|
||||
)
|
||||
|
||||
# 2. Force the entity counter so the replay assigns the same ids as
|
||||
# the saved sketch — the constraint log references those ids.
|
||||
self._entity_counter = int(data.get("entity_counter", 0))
|
||||
|
||||
# 3. Replay entities in id order. We need the OCCSketchEntity
|
||||
# objects back (for arc center/start/end lookups), so we
|
||||
# reconstruct by id and let ``_next_id`` advance the counter.
|
||||
entities_by_id: Dict[int, OCCSketchEntity] = {}
|
||||
for entry in data.get("entities", []):
|
||||
eid = int(entry["id"])
|
||||
# Ensure the next _next_id() call returns eid.
|
||||
self._entity_counter = eid - 1
|
||||
etype = entry["type"]
|
||||
geom = entry.get("geometry")
|
||||
is_external = bool(entry.get("is_external", False))
|
||||
|
||||
if etype == "point":
|
||||
x, y = float(geom[0]), float(geom[1])
|
||||
if is_external:
|
||||
ent = self.add_external_point(x, y)
|
||||
else:
|
||||
ent = self.add_point(x, y)
|
||||
elif etype == "line":
|
||||
# line geometry is ((x1,y1),(x2,y2)); we already know the
|
||||
# endpoints exist as point entities. Look them up by saved
|
||||
# position via _points (which was just populated above).
|
||||
(x1, y1), (x2, y2) = geom
|
||||
s_id = self._find_point_at(x1, y1)
|
||||
e_id = self._find_point_at(x2, y2)
|
||||
if s_id is None or e_id is None:
|
||||
logger.warning(
|
||||
"Skipping line %s during load: endpoints not found", eid
|
||||
)
|
||||
continue
|
||||
if is_external:
|
||||
ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
|
||||
else:
|
||||
ent = self.add_line(entities_by_id[s_id], entities_by_id[e_id])
|
||||
elif etype == "circle":
|
||||
(cx, cy), radius = geom
|
||||
c_id = self._find_point_at(cx, cy)
|
||||
if c_id is None:
|
||||
logger.warning(
|
||||
"Skipping circle %s during load: center not found", eid
|
||||
)
|
||||
continue
|
||||
ent = self.add_circle(entities_by_id[c_id], float(radius))
|
||||
elif etype == "arc":
|
||||
center_pos = tuple(geom["center"])
|
||||
start_pos = tuple(geom["start"])
|
||||
end_pos = tuple(geom["end"])
|
||||
radius = float(geom["radius"])
|
||||
sweep = float(geom.get("sweep", 0.0))
|
||||
c_id = self._find_point_at(*center_pos)
|
||||
s_id = self._find_point_at(*start_pos)
|
||||
e_id = self._find_point_at(*end_pos)
|
||||
if c_id is None or s_id is None or e_id is None:
|
||||
logger.warning(
|
||||
"Skipping arc %s during load: endpoints not found", eid
|
||||
)
|
||||
continue
|
||||
ent = self.add_arc(
|
||||
entities_by_id[c_id],
|
||||
radius,
|
||||
entities_by_id[s_id],
|
||||
entities_by_id[e_id],
|
||||
sweep=sweep,
|
||||
)
|
||||
else:
|
||||
logger.warning("Unknown sketch entity type %r; skipping", etype)
|
||||
continue
|
||||
|
||||
# Restore the per-entity UI flags / labels that aren't carried
|
||||
# by the add_* methods themselves.
|
||||
ent.is_construction = bool(entry.get("is_construction", False))
|
||||
ent.constraints = list(entry.get("constraints", []))
|
||||
entities_by_id[eid] = ent
|
||||
|
||||
# 4. Replay constraint log. ``_apply_constraint_log`` re-issues the
|
||||
# solver call and pushes back into the entity tracker via
|
||||
# ``entity.constraints``. We don't double-record into the log
|
||||
# itself (the log was already cleared by ``clear()`` and we
|
||||
# re-populate it here, so the live ``_constraint_count`` will
|
||||
# reflect the saved state at the end).
|
||||
for entry in data.get("constraint_log", []):
|
||||
self._record_constraint(
|
||||
entry["type"],
|
||||
tuple(entry["ids"]),
|
||||
tuple(entry.get("params", ())),
|
||||
tuple(entry.get("labels", ())),
|
||||
)
|
||||
# Re-apply the live solver calls AFTER the log is restored, so that
|
||||
# the constraint tracker matches the solver state on a fresh solve.
|
||||
for entry in self._constraint_log:
|
||||
self._apply_constraint_log(entry)
|
||||
|
||||
# 5. Restore external / centerline id sets. ``add_external_*`` adds
|
||||
# to the set internally; if the entity's id was a regular entity
|
||||
# for some reason (legacy / hand-edited file), fold it in too so
|
||||
# the saved flag is authoritative.
|
||||
for eid in data.get("external_entity_ids", []):
|
||||
self._external_entity_ids.add(int(eid))
|
||||
for eid in data.get("centerline_ids", []):
|
||||
self._centerline_ids.add(int(eid))
|
||||
|
||||
def _find_point_at(self, x: float, y: float, tol: float = 1e-6) -> Optional[int]:
|
||||
"""Return the entity id of a point sitting at UV ``(x, y)`` (within tol)."""
|
||||
for pid, pos in self._points.items():
|
||||
if abs(pos[0] - x) < tol and abs(pos[1] - y) < tol:
|
||||
return pid
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""I/O module: project save/load."""
|
||||
|
||||
from fluency.io.project_io import save_project, load_project, project_zip_path
|
||||
|
||||
__all__ = ["save_project", "load_project", "project_zip_path"]
|
||||
@@ -0,0 +1,716 @@
|
||||
"""Project save/load — ``.fluency`` ZIP files.
|
||||
|
||||
The on-disk format is a single ZIP archive:
|
||||
|
||||
project.json # project tree: components, sketches, bodies,
|
||||
# workplanes, assemblies, connectors, view state
|
||||
bodies/<id>.step # one STEP file per Body (BRep geometry)
|
||||
sketches/<id>/meta.json # sketch entities + constraints (kept separately
|
||||
# so a single huge sketch doesn't bloat the
|
||||
# main project.json)
|
||||
sketches/<id>/solved.step # the sketch's solved face geometry
|
||||
|
||||
Sketch constraint solving and 3D body geometry are both preserved by using
|
||||
OpenCASCADE's native STEP exporter (which is lossless for BRep). Everything
|
||||
else is JSON.
|
||||
|
||||
The :func:`save_project` function is the entry point used by the File menu.
|
||||
The :func:`load_project` function returns a fully populated
|
||||
:class:`fluency.models.Project` (with a fresh ``OCGeometryKernel``) and an
|
||||
optional view-state dict that the main window can hand back to the renderer
|
||||
to restore the camera.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from fluency.models.data_model import (
|
||||
Assembly,
|
||||
AssemblyComponent,
|
||||
AssemblyConnection,
|
||||
Body,
|
||||
Component,
|
||||
Connector,
|
||||
Project,
|
||||
Sketch,
|
||||
Workplane,
|
||||
)
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── JSON-friendly type coercion ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
"""Default JSON encoder for numpy / dataclass / datetime values."""
|
||||
if isinstance(obj, np.ndarray):
|
||||
return obj.tolist()
|
||||
if isinstance(obj, (datetime,)):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, (set, frozenset)):
|
||||
return sorted(obj)
|
||||
if isinstance(obj, tuple):
|
||||
return list(obj)
|
||||
if is_dataclass(obj):
|
||||
return asdict(obj)
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
def _to_json(data: Any) -> str:
|
||||
return json.dumps(data, default=_json_default, indent=2, sort_keys=False)
|
||||
|
||||
|
||||
def _coerce_listlike(value: Any) -> List[Any]:
|
||||
"""Cast arrays / tuples / numpy arrays to plain lists for JSON friendliness."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, np.ndarray):
|
||||
return value.tolist()
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [v for v in value]
|
||||
return list(value)
|
||||
|
||||
|
||||
def _to_3tuple(value: Any) -> Tuple[float, float, float]:
|
||||
"""Coerce a saved 3-vector to a tuple of floats (for OCC)."""
|
||||
if value is None:
|
||||
return (0.0, 0.0, 0.0)
|
||||
if isinstance(value, np.ndarray):
|
||||
seq = value.tolist()
|
||||
else:
|
||||
seq = list(value)
|
||||
if len(seq) < 3:
|
||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||
return (float(seq[0]), float(seq[1]), float(seq[2]))
|
||||
|
||||
|
||||
def _to_3vec(value: Any) -> np.ndarray:
|
||||
"""Coerce a saved 3-vector to a 3-element numpy array."""
|
||||
if isinstance(value, np.ndarray):
|
||||
return value.astype(float).reshape(3)
|
||||
if value is None:
|
||||
return np.zeros(3, dtype=float)
|
||||
seq = list(value)
|
||||
if len(seq) < 3:
|
||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
|
||||
|
||||
|
||||
def _to_mat3(value: Any) -> np.ndarray:
|
||||
"""Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
|
||||
if isinstance(value, np.ndarray):
|
||||
arr = value.astype(float)
|
||||
return arr.reshape(3, 3)
|
||||
if value is None:
|
||||
return np.eye(3, dtype=float)
|
||||
flat = list(np.asarray(value, dtype=float).flatten())
|
||||
if len(flat) < 9:
|
||||
flat = flat + [0.0] * (9 - len(flat))
|
||||
return np.array(flat[:9], dtype=float).reshape(3, 3)
|
||||
|
||||
|
||||
def _parse_iso(value: Optional[str]) -> datetime:
|
||||
"""Parse an ISO-8601 timestamp, falling back to ``now`` on failure."""
|
||||
if not value:
|
||||
return datetime.now()
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError):
|
||||
return datetime.now()
|
||||
|
||||
|
||||
# ── Model serialization (to_dict) ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": wp.id,
|
||||
"name": wp.name,
|
||||
"origin": list(wp.origin),
|
||||
"normal": list(wp.normal),
|
||||
"x_dir": list(wp.x_dir),
|
||||
"visible": bool(wp.visible),
|
||||
"created_at": wp.created_at.isoformat() if wp.created_at else None,
|
||||
"modified_at": wp.modified_at.isoformat() if wp.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
|
||||
wp = Workplane(
|
||||
id=data.get("id") or None, # Workplane generates uuid if None
|
||||
name=data.get("name", "Untitled Workplane"),
|
||||
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
|
||||
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
|
||||
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
|
||||
visible=bool(data.get("visible", True)),
|
||||
)
|
||||
wp.created_at = _parse_iso(data.get("created_at"))
|
||||
wp.modified_at = _parse_iso(data.get("modified_at"))
|
||||
return wp
|
||||
|
||||
|
||||
def _body_to_dict(body: Body) -> Dict[str, Any]:
|
||||
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer
|
||||
once the STEP file is written."""
|
||||
return {
|
||||
"id": body.id,
|
||||
"name": body.name,
|
||||
"source_sketch_id": body.source_sketch.id if body.source_sketch else None,
|
||||
"source_operation": body.source_operation,
|
||||
"position": _coerce_listlike(body.position),
|
||||
"rotation": _coerce_listlike(body.rotation),
|
||||
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
|
||||
"opacity": float(body.opacity),
|
||||
"visible": bool(body.visible),
|
||||
"has_geometry": body.geometry is not None,
|
||||
"geometry_ref": None, # filled in by save_project
|
||||
"created_at": body.created_at.isoformat() if body.created_at else None,
|
||||
"modified_at": body.modified_at.isoformat() if body.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _body_from_dict(
|
||||
data: Dict[str, Any],
|
||||
geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
source_sketch: Optional[Sketch] = None,
|
||||
) -> Body:
|
||||
geometry: Optional[OCCGeometryObject] = None
|
||||
if geometry_loader is not None and data.get("geometry_ref"):
|
||||
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||
|
||||
body = Body(
|
||||
id=data.get("id") or None,
|
||||
name=data.get("name", "Untitled Body"),
|
||||
geometry=geometry,
|
||||
source_sketch=source_sketch,
|
||||
source_operation=data.get("source_operation", "extrude"),
|
||||
position=_to_3vec(data.get("position")),
|
||||
rotation=_to_mat3(data.get("rotation")),
|
||||
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
|
||||
opacity=float(data.get("opacity", 1.0)),
|
||||
visible=bool(data.get("visible", True)),
|
||||
)
|
||||
body.created_at = _parse_iso(data.get("created_at"))
|
||||
body.modified_at = _parse_iso(data.get("modified_at"))
|
||||
return body
|
||||
|
||||
|
||||
def _sketch_to_dict(sketch: Sketch) -> Dict[str, Any]:
|
||||
occ_dict: Optional[Dict[str, Any]] = None
|
||||
if sketch.occ_sketch is not None and isinstance(sketch.occ_sketch, OCCSketch):
|
||||
try:
|
||||
occ_dict = sketch.occ_sketch.to_dict()
|
||||
except Exception as exc:
|
||||
logger.warning("Sketch %s occ_sketch.to_dict() failed: %s", sketch.id, exc)
|
||||
|
||||
return {
|
||||
"id": sketch.id,
|
||||
"name": sketch.name,
|
||||
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
|
||||
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
|
||||
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
|
||||
"is_solved": bool(sketch.is_solved),
|
||||
"is_fully_constrained": bool(sketch.is_fully_constrained),
|
||||
"occ_sketch": occ_dict,
|
||||
"has_geometry": sketch.geometry is not None,
|
||||
"geometry_ref": None, # filled in by save_project
|
||||
"created_at": sketch.created_at.isoformat() if sketch.created_at else None,
|
||||
"modified_at": sketch.modified_at.isoformat() if sketch.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _sketch_from_dict(
|
||||
data: Dict[str, Any],
|
||||
geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
) -> Sketch:
|
||||
occ_dict = data.get("occ_sketch")
|
||||
occ_sketch: Optional[OCCSketch] = None
|
||||
if occ_dict is not None:
|
||||
try:
|
||||
occ_sketch = OCCSketch.from_dict(occ_dict)
|
||||
except Exception as exc:
|
||||
logger.warning("Sketch %s OCCSketch.from_dict() failed: %s", data.get("id"), exc)
|
||||
occ_sketch = OCCSketch()
|
||||
else:
|
||||
occ_sketch = OCCSketch()
|
||||
|
||||
# Re-apply the workplane (from_dict already does this internally, but be
|
||||
# defensive in case the saved dict didn't carry the workplane fields).
|
||||
occ_sketch.set_workplane(
|
||||
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
|
||||
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
|
||||
tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
|
||||
)
|
||||
|
||||
geometry: Optional[OCCGeometryObject] = None
|
||||
if geometry_loader is not None and data.get("geometry_ref"):
|
||||
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||
|
||||
sk = Sketch(
|
||||
id=data.get("id") or None,
|
||||
name=data.get("name", "Untitled Sketch"),
|
||||
occ_sketch=occ_sketch,
|
||||
geometry=geometry,
|
||||
is_solved=bool(data.get("is_solved", False)),
|
||||
is_fully_constrained=bool(data.get("is_fully_constrained", False)),
|
||||
)
|
||||
sk.workplane_origin = _to_3vec(data.get("workplane_origin"))
|
||||
sk.workplane_normal = _to_3vec(data.get("workplane_normal"))
|
||||
sk.workplane_x_dir = _to_3vec(data.get("workplane_x_dir"))
|
||||
sk.apply_workplane()
|
||||
sk.created_at = _parse_iso(data.get("created_at"))
|
||||
sk.modified_at = _parse_iso(data.get("modified_at"))
|
||||
return sk
|
||||
|
||||
|
||||
def _component_to_dict(comp: Component) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": comp.id,
|
||||
"name": comp.name,
|
||||
"description": comp.description,
|
||||
"active_sketch": comp.active_sketch,
|
||||
"active_workplane": comp.active_workplane,
|
||||
"sketches": {sid: _sketch_to_dict(s) for sid, s in comp.sketches.items()},
|
||||
"bodies": {bid: _body_to_dict(b) for bid, b in comp.bodies.items()},
|
||||
"workplanes": {wid: _workplane_to_dict(w) for wid, w in comp.workplanes.items()},
|
||||
"created_at": comp.created_at.isoformat() if comp.created_at else None,
|
||||
"modified_at": comp.modified_at.isoformat() if comp.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _component_from_dict(
|
||||
data: Dict[str, Any],
|
||||
body_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
) -> Component:
|
||||
comp = Component(
|
||||
id=data.get("id") or None,
|
||||
name=data.get("name", "Untitled Component"),
|
||||
description=data.get("description", ""),
|
||||
active_sketch=data.get("active_sketch"),
|
||||
active_workplane=data.get("active_workplane"),
|
||||
)
|
||||
comp.created_at = _parse_iso(data.get("created_at"))
|
||||
comp.modified_at = _parse_iso(data.get("modified_at"))
|
||||
|
||||
for wid, wp_data in (data.get("workplanes") or {}).items():
|
||||
comp.workplanes[wid] = _workplane_from_dict(wp_data)
|
||||
|
||||
# Sketches first so bodies can reference them.
|
||||
for sid, sk_data in (data.get("sketches") or {}).items():
|
||||
comp.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
|
||||
|
||||
for bid, body_data in (data.get("bodies") or {}).items():
|
||||
src_sketch = None
|
||||
src_id = body_data.get("source_sketch_id")
|
||||
if src_id and src_id in comp.sketches:
|
||||
src_sketch = comp.sketches[src_id]
|
||||
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch)
|
||||
|
||||
return comp
|
||||
|
||||
|
||||
def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": conn.id,
|
||||
"name": conn.name,
|
||||
"position": list(conn.position),
|
||||
"normal": list(conn.normal),
|
||||
"x_dir": list(conn.x_dir),
|
||||
"axis_rotation": float(conn.axis_rotation),
|
||||
"offset": float(conn.offset),
|
||||
"assembly_component_id": conn.assembly_component_id,
|
||||
"source_obj_id": conn.source_obj_id,
|
||||
"partner_ac_id": conn.partner_ac_id,
|
||||
"partner_connector_id": conn.partner_connector_id,
|
||||
"is_grounded": bool(conn.is_grounded),
|
||||
"created_at": conn.created_at.isoformat() if conn.created_at else None,
|
||||
"modified_at": conn.modified_at.isoformat() if conn.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _connector_from_dict(data: Dict[str, Any]) -> Connector:
|
||||
conn = Connector(
|
||||
id=data.get("id") or None,
|
||||
name=data.get("name", "Untitled Connector"),
|
||||
position=_to_3tuple(data.get("position")),
|
||||
normal=_to_3tuple(data.get("normal")),
|
||||
x_dir=_to_3tuple(data.get("x_dir")),
|
||||
axis_rotation=float(data.get("axis_rotation", 0.0)),
|
||||
offset=float(data.get("offset", 0.0)),
|
||||
assembly_component_id=data.get("assembly_component_id", ""),
|
||||
source_obj_id=data.get("source_obj_id", ""),
|
||||
)
|
||||
conn.partner_ac_id = data.get("partner_ac_id")
|
||||
conn.partner_connector_id = data.get("partner_connector_id")
|
||||
conn.is_grounded = bool(data.get("is_grounded", False))
|
||||
conn.created_at = _parse_iso(data.get("created_at"))
|
||||
conn.modified_at = _parse_iso(data.get("modified_at"))
|
||||
return conn
|
||||
|
||||
|
||||
def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": ac.id,
|
||||
"component_id": ac.component_id,
|
||||
"name": ac.name,
|
||||
"position": _coerce_listlike(ac.position),
|
||||
"rotation": _coerce_listlike(ac.rotation),
|
||||
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
|
||||
"created_at": ac.created_at.isoformat() if ac.created_at else None,
|
||||
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
||||
ac = AssemblyComponent(
|
||||
id=data.get("id") or None,
|
||||
component_id=data.get("component_id", ""),
|
||||
name=data.get("name", "Untitled Instance"),
|
||||
position=_to_3vec(data.get("position")),
|
||||
rotation=_to_mat3(data.get("rotation")),
|
||||
)
|
||||
ac.created_at = _parse_iso(data.get("created_at"))
|
||||
ac.modified_at = _parse_iso(data.get("modified_at"))
|
||||
for cid, c_data in (data.get("connectors") or {}).items():
|
||||
ac.connectors[cid] = _connector_from_dict(c_data)
|
||||
return ac
|
||||
|
||||
|
||||
def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": c.id,
|
||||
"first_ac_id": c.first_ac_id,
|
||||
"second_ac_id": c.second_ac_id,
|
||||
"first_connector_id": c.first_connector_id,
|
||||
"second_connector_id": c.second_connector_id,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
|
||||
conn = AssemblyConnection(
|
||||
id=data.get("id") or None,
|
||||
first_ac_id=data.get("first_ac_id", ""),
|
||||
second_ac_id=data.get("second_ac_id", ""),
|
||||
first_connector_id=data.get("first_connector_id"),
|
||||
second_connector_id=data.get("second_connector_id"),
|
||||
)
|
||||
conn.created_at = _parse_iso(data.get("created_at"))
|
||||
return conn
|
||||
|
||||
|
||||
def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": asm.id,
|
||||
"name": asm.name,
|
||||
"active_assembly_component": asm.active_assembly_component,
|
||||
"components": {cid: _assembly_component_to_dict(ac) for cid, ac in asm.components.items()},
|
||||
"connections": [_assembly_connection_to_dict(c) for c in asm.connections],
|
||||
"created_at": asm.created_at.isoformat() if asm.created_at else None,
|
||||
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
||||
asm = Assembly(
|
||||
id=data.get("id") or None,
|
||||
name=data.get("name", "Untitled Assembly"),
|
||||
active_assembly_component=data.get("active_assembly_component"),
|
||||
)
|
||||
asm.created_at = _parse_iso(data.get("created_at"))
|
||||
asm.modified_at = _parse_iso(data.get("modified_at"))
|
||||
for cid, ac_data in (data.get("components") or {}).items():
|
||||
asm.components[cid] = _assembly_component_from_dict(ac_data)
|
||||
for c_data in (data.get("connections") or []):
|
||||
asm.connections.append(_assembly_connection_from_dict(c_data))
|
||||
return asm
|
||||
|
||||
|
||||
def _project_to_dict(
|
||||
project: Project,
|
||||
view_state: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"format_version": 1,
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"active_component": project.active_component,
|
||||
"active_assembly": project.active_assembly,
|
||||
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
|
||||
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
|
||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
|
||||
"view_state": view_state or {},
|
||||
}
|
||||
|
||||
|
||||
# ── Geometry (STEP) write/read helpers ─────────────────────────────────────
|
||||
|
||||
|
||||
def _write_step_for_body(
|
||||
kernel: OCGeometryKernel,
|
||||
geometry: OCCGeometryObject,
|
||||
) -> Optional[bytes]:
|
||||
"""Serialize a single body geometry to a STEP byte string.
|
||||
|
||||
Returns *None* if OCC reports the shape is empty (so the ZIP can omit
|
||||
the file and the body is restored as geometry-less). The temporary
|
||||
file is created and immediately deleted; we never touch the user's
|
||||
filesystem outside of ``tempfile``.
|
||||
"""
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".step")
|
||||
os.close(fd)
|
||||
try:
|
||||
ok = kernel.export_step(geometry, tmp_path)
|
||||
if not ok:
|
||||
return None
|
||||
with open(tmp_path, "rb") as f:
|
||||
return f.read()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _read_step_bytes(
|
||||
kernel: OCGeometryKernel,
|
||||
data: bytes,
|
||||
) -> Optional[OCCGeometryObject]:
|
||||
"""Parse a STEP byte string back into an OCCGeometryObject."""
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".step")
|
||||
os.close(fd)
|
||||
try:
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(data)
|
||||
geom = kernel.import_step(tmp_path)
|
||||
return geom
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to read STEP: %s", exc)
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Save / Load entry points ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def project_zip_path(path: str) -> str:
|
||||
"""Return *path* with the ``.fluency`` extension added if missing."""
|
||||
base, ext = os.path.splitext(path)
|
||||
if ext.lower() == ".fluency":
|
||||
return path
|
||||
return base + ".fluency"
|
||||
|
||||
|
||||
def save_project(
|
||||
project: Project,
|
||||
filepath: str,
|
||||
view_state: Optional[Dict[str, Any]] = None,
|
||||
kernel: Optional[OCGeometryKernel] = None,
|
||||
) -> str:
|
||||
"""Save *project* to a ``.fluency`` ZIP at *filepath*.
|
||||
|
||||
*view_state* (optional) is a free-form dict that the main window uses to
|
||||
record camera position, active tab, etc. It is stored verbatim inside
|
||||
``project.json`` under the ``view_state`` key.
|
||||
|
||||
*kernel* is the OCGeometryKernel to use for STEP export. A new one is
|
||||
created if not provided (slightly slower startup, but never holds stale
|
||||
state). Pass the app's kernel to keep one canonical instance.
|
||||
|
||||
Returns the actual file path that was written.
|
||||
"""
|
||||
filepath = project_zip_path(filepath)
|
||||
kernel = kernel or OCGeometryKernel()
|
||||
|
||||
# Build the manifest in two passes:
|
||||
# pass 1: serialize all metadata + collect body/sketches that need
|
||||
# STEP files written alongside. We track the in-zip path of
|
||||
# each STEP file in the body/sketches' ``geometry_ref`` slot.
|
||||
# pass 2: write the ZIP, streaming each body/sketches's STEP data
|
||||
# into its own archive member.
|
||||
manifest = _project_to_dict(project, view_state)
|
||||
|
||||
# Per-body STEP files. Skipped if the body has no geometry.
|
||||
body_files: List[Tuple[str, bytes]] = []
|
||||
for comp_id, comp in project.components.items():
|
||||
for body_id, body in comp.bodies.items():
|
||||
if body.geometry is None:
|
||||
continue
|
||||
step_bytes = _write_step_for_body(kernel, body.geometry)
|
||||
if step_bytes is None:
|
||||
continue
|
||||
arcname = f"bodies/{body_id}.step"
|
||||
body_files.append((arcname, step_bytes))
|
||||
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
|
||||
|
||||
# Per-sketch STEP files (solved face geometry).
|
||||
sketch_files: List[Tuple[str, bytes]] = []
|
||||
sketch_meta_files: List[Tuple[str, bytes]] = []
|
||||
for comp_id, comp in project.components.items():
|
||||
for sketch_id, sketch in comp.sketches.items():
|
||||
# Save the OCCSketch state to its own JSON file so the
|
||||
# main project.json stays compact.
|
||||
occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
|
||||
meta = {
|
||||
"id": sketch.id,
|
||||
"name": sketch.name,
|
||||
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
|
||||
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
|
||||
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
|
||||
"is_solved": bool(sketch.is_solved),
|
||||
"is_fully_constrained": bool(sketch.is_fully_constrained),
|
||||
"occ_sketch": occ,
|
||||
}
|
||||
meta_arc = f"sketches/{sketch_id}/meta.json"
|
||||
sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
|
||||
# Drop the heavy occ_sketch payload from the main manifest so
|
||||
# the file is smaller and edits are localised.
|
||||
manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch"] = None
|
||||
manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch_ref"] = meta_arc
|
||||
|
||||
if sketch.geometry is None:
|
||||
continue
|
||||
step_bytes = _write_step_for_body(kernel, sketch.geometry)
|
||||
if step_bytes is None:
|
||||
continue
|
||||
arcname = f"sketches/{sketch_id}/solved.step"
|
||||
sketch_files.append((arcname, step_bytes))
|
||||
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
|
||||
|
||||
# Write the ZIP. Use a temp file + rename so a partial write can't
|
||||
# clobber an existing good file.
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
|
||||
os.close(tmp_fd)
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("project.json", _to_json(manifest))
|
||||
for arcname, data in body_files + sketch_files + sketch_meta_files:
|
||||
zf.writestr(arcname, data)
|
||||
# Atomic-ish replace.
|
||||
shutil.move(tmp_path, filepath)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
||||
"""Load a project from a ``.fluency`` ZIP.
|
||||
|
||||
Returns ``(project, view_state)``. The caller is responsible for
|
||||
handing *view_state* to the renderer (camera, etc.) and for re-rendering
|
||||
the scene with the freshly-loaded bodies.
|
||||
"""
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(filepath)
|
||||
|
||||
kernel = OCGeometryKernel()
|
||||
body_cache: Dict[str, Optional[OCCGeometryObject]] = {}
|
||||
|
||||
# Body geometry can be reused across bodies if the same STEP appears
|
||||
# under multiple names (rare, but cheap to handle). We cache by zip
|
||||
# member name.
|
||||
def body_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
|
||||
if member_name in body_cache:
|
||||
return body_cache[member_name]
|
||||
try:
|
||||
data = zipf.read(member_name)
|
||||
except KeyError:
|
||||
logger.warning("Body STEP missing in archive: %s", member_name)
|
||||
body_cache[member_name] = None
|
||||
return None
|
||||
geom = _read_step_bytes(kernel, data)
|
||||
body_cache[member_name] = geom
|
||||
return geom
|
||||
|
||||
# Sketch geometry loader shares the same byte path. Sketches that have
|
||||
# solved faces point at sketches/<id>/solved.step.
|
||||
def sketch_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
|
||||
return body_geometry_loader(member_name)
|
||||
|
||||
with zipfile.ZipFile(filepath, "r") as zipf:
|
||||
manifest_raw = zipf.read("project.json")
|
||||
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||
view_state: Dict[str, Any] = manifest.get("view_state") or {}
|
||||
|
||||
# If a sketch's occ_sketch is referenced as a separate file, read
|
||||
# it in now and patch the manifest so _sketch_from_dict sees it.
|
||||
for comp_id, comp_data in (manifest.get("components") or {}).items():
|
||||
for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
|
||||
ref = sk_data.get("occ_sketch_ref")
|
||||
if not ref:
|
||||
continue
|
||||
try:
|
||||
meta_bytes = zipf.read(ref)
|
||||
except KeyError:
|
||||
logger.warning("Sketch meta missing in archive: %s", ref)
|
||||
continue
|
||||
meta = json.loads(meta_bytes.decode("utf-8"))
|
||||
sk_data["occ_sketch"] = meta.get("occ_sketch")
|
||||
# Workplane fields on the sketch-level file override the
|
||||
# embedded ones (source of truth lives in the sidecar).
|
||||
for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
|
||||
"is_solved", "is_fully_constrained"):
|
||||
if k in meta:
|
||||
sk_data[k] = meta[k]
|
||||
|
||||
project = Project(
|
||||
name=manifest.get("name", "Untitled Project"),
|
||||
description=manifest.get("description", ""),
|
||||
active_component=manifest.get("active_component"),
|
||||
active_assembly=manifest.get("active_assembly"),
|
||||
kernel=kernel,
|
||||
)
|
||||
project.file_path = filepath
|
||||
project.created_at = _parse_iso(manifest.get("created_at"))
|
||||
project.modified_at = _parse_iso(manifest.get("modified_at"))
|
||||
|
||||
for cid, c_data in (manifest.get("components") or {}).items():
|
||||
project.components[cid] = _component_from_dict(
|
||||
c_data,
|
||||
body_geometry_loader=body_geometry_loader,
|
||||
sketch_geometry_loader=sketch_geometry_loader,
|
||||
)
|
||||
|
||||
for aid, a_data in (manifest.get("assemblies") or {}).items():
|
||||
project.assemblies[aid] = _assembly_from_dict(a_data)
|
||||
|
||||
# After all components are loaded, re-wire connector partner ids so
|
||||
# they point to the freshly-loaded AssemblyComponents. (The dict
|
||||
# round-trip preserves the raw strings; we just make sure the partner
|
||||
# ids are still present in the project so the assembly-move handler
|
||||
# can follow the rigid-group graph.)
|
||||
for asm in project.assemblies.values():
|
||||
for conn in asm.connections:
|
||||
if conn.first_connector_id and conn.first_ac_id in asm.components:
|
||||
first_ac = asm.components[conn.first_ac_id]
|
||||
if conn.first_connector_id in first_ac.connectors:
|
||||
first_ac.connectors[conn.first_connector_id].is_grounded = True
|
||||
if conn.second_connector_id and conn.second_ac_id in asm.components:
|
||||
pass # already set in the connector itself
|
||||
|
||||
return project, view_state
|
||||
+40
-7351
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
"""Smoke test for project_io save/load round-trip.
|
||||
|
||||
Builds a small project (a Component with a sketch, an extrude body, a
|
||||
workplane, plus an assembly with two instances and a connector) and
|
||||
verifies that saving then loading it preserves the data.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Allow running this file directly: ``python tests/test_project_io.py``.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
|
||||
|
||||
from fluency.io.project_io import save_project, load_project
|
||||
from fluency.models.data_model import (
|
||||
Project,
|
||||
Component,
|
||||
Body,
|
||||
Workplane,
|
||||
Assembly,
|
||||
)
|
||||
|
||||
|
||||
class TestProjectIO(unittest.TestCase):
|
||||
"""Round-trip the same project through save/load and check equivalence."""
|
||||
|
||||
def _build_project(self) -> Project:
|
||||
project = Project(name="Test Project", description="A tiny test")
|
||||
project.file_path = None # simulate untitled
|
||||
|
||||
comp = project.add_component(Component(name="Part1"))
|
||||
|
||||
# Add a workplane.
|
||||
wp = Workplane(
|
||||
name="Top",
|
||||
origin=(0.0, 0.0, 0.0),
|
||||
normal=(0.0, 0.0, 1.0),
|
||||
x_dir=(1.0, 0.0, 0.0),
|
||||
)
|
||||
comp.add_workplane(wp)
|
||||
|
||||
# Add a sketch with a square.
|
||||
sk = comp.add_sketch()
|
||||
sk.occ_sketch.add_rectangle((0.0, 0.0), (10.0, 10.0))
|
||||
sk.solve()
|
||||
# Build a face geometry for the sketch (needed for export / restore).
|
||||
faces = sk.occ_sketch.detect_faces()
|
||||
if faces:
|
||||
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
|
||||
|
||||
# Extrude into a body.
|
||||
if sk.geometry:
|
||||
kernel = project.kernel
|
||||
body_shape = kernel.extrude(sk.geometry, height=20.0)
|
||||
body = comp.add_body(
|
||||
Body(
|
||||
name="Block",
|
||||
geometry=body_shape,
|
||||
source_sketch=sk,
|
||||
source_operation="extrude",
|
||||
)
|
||||
)
|
||||
body.color = (0.4, 0.2, 0.8)
|
||||
|
||||
# Add an assembly with two instances and a mated connector pair.
|
||||
asm = project.add_assembly(Assembly(name="Asm1"))
|
||||
ac1 = asm.add_component_instance(comp.id, name="Inst1")
|
||||
ac2 = asm.add_component_instance(comp.id, name="Inst2")
|
||||
c1 = ac1.add_connector(
|
||||
position=(5.0, 5.0, 0.0),
|
||||
normal=(0.0, 0.0, 1.0),
|
||||
x_dir=(1.0, 0.0, 0.0),
|
||||
)
|
||||
c2 = ac2.add_connector(
|
||||
position=(10.0, 10.0, 5.0),
|
||||
normal=(0.0, 0.0, -1.0),
|
||||
x_dir=(1.0, 0.0, 0.0),
|
||||
)
|
||||
# Record a mated pair (UI normally does this on connector-pick).
|
||||
conn = asm.add_connection(ac1.id, ac2.id)
|
||||
conn.first_connector_id = c1.id
|
||||
conn.second_connector_id = c2.id
|
||||
c1.partner_ac_id = ac2.id
|
||||
c1.partner_connector_id = c2.id
|
||||
c2.partner_ac_id = ac1.id
|
||||
c2.partner_connector_id = c1.id
|
||||
c1.is_grounded = True
|
||||
|
||||
return project
|
||||
|
||||
def test_round_trip(self):
|
||||
original = self._build_project()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "test.fluency")
|
||||
saved_path = save_project(original, path)
|
||||
self.assertTrue(os.path.exists(saved_path))
|
||||
self.assertGreater(os.path.getsize(saved_path), 100)
|
||||
|
||||
loaded, view_state = load_project(saved_path)
|
||||
|
||||
# ── Project metadata ──
|
||||
self.assertEqual(loaded.name, "Test Project")
|
||||
self.assertEqual(loaded.description, "A tiny test")
|
||||
self.assertEqual(len(loaded.components), 1)
|
||||
self.assertEqual(len(loaded.assemblies), 1)
|
||||
|
||||
# ── Component / Workplane / Sketch / Body ──
|
||||
comp = next(iter(loaded.components.values()))
|
||||
self.assertEqual(comp.name, "Part1")
|
||||
self.assertEqual(len(comp.workplanes), 1)
|
||||
self.assertEqual(len(comp.sketches), 1)
|
||||
self.assertEqual(len(comp.bodies), 1)
|
||||
|
||||
sk = next(iter(comp.sketches.values()))
|
||||
self.assertIsNotNone(sk.occ_sketch)
|
||||
# OCCSketch replayed the rectangle: 4 points + 4 lines + 1 implicit
|
||||
# origin anchor (first-point-fix). We only check that the entities
|
||||
# exist.
|
||||
self.assertGreaterEqual(sk.occ_sketch.get_entity_count(), 4)
|
||||
# Solved geometry should round-trip through STEP.
|
||||
self.assertIsNotNone(sk.geometry)
|
||||
|
||||
body = next(iter(comp.bodies.values()))
|
||||
self.assertIsNotNone(body.geometry)
|
||||
self.assertEqual(body.name, "Block")
|
||||
self.assertEqual(tuple(body.color), (0.4, 0.2, 0.8))
|
||||
# BRep topology should still be valid.
|
||||
self.assertGreater(body.get_mesh(loaded.kernel, 0.5)[0].size, 0)
|
||||
|
||||
# ── Assembly / connector / connection ──
|
||||
asm = next(iter(loaded.assemblies.values()))
|
||||
self.assertEqual(len(asm.components), 2)
|
||||
self.assertEqual(len(asm.connections), 1)
|
||||
ac1, ac2 = list(asm.components.values())
|
||||
self.assertEqual(len(ac1.connectors), 1)
|
||||
conn = next(iter(ac1.connectors.values()))
|
||||
self.assertEqual(conn.position, (5.0, 5.0, 0.0))
|
||||
# The grounded-reference flag was re-applied to the first connector.
|
||||
self.assertTrue(conn.is_grounded)
|
||||
# Rigid-group BFS should still link the two instances.
|
||||
self.assertEqual(set(asm.get_rigid_group(ac1.id)), {ac1.id, ac2.id})
|
||||
|
||||
|
||||
class TestProjectIOWithConstraints(unittest.TestCase):
|
||||
"""Round-trip with parametric constraints on the sketch.
|
||||
|
||||
Builds a slightly more interesting sketch (rectangle with horizontal +
|
||||
vertical constraints + a distance) so the constraint-log replay path
|
||||
is exercised, not just the entity-construction path.
|
||||
"""
|
||||
|
||||
def _build_project(self) -> Project:
|
||||
project = Project(name="Constraints Project", description="")
|
||||
|
||||
comp = project.add_component(Component(name="Part1"))
|
||||
|
||||
# A square with a 25.4mm horizontal distance + a vertical distance,
|
||||
# both anchored on a single fixed corner. This is the minimum
|
||||
# number of constraints to fully define a square in 2D.
|
||||
sk = comp.add_sketch()
|
||||
sk.occ_sketch.set_workplane(
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
)
|
||||
p1 = sk.occ_sketch.add_point(0.0, 0.0) # fixed anchor (auto)
|
||||
p2 = sk.occ_sketch.add_point(25.4, 0.0)
|
||||
p3 = sk.occ_sketch.add_point(25.4, 25.4)
|
||||
p4 = sk.occ_sketch.add_point(0.0, 25.4)
|
||||
sk.occ_sketch.add_line(p1, p2)
|
||||
sk.occ_sketch.add_line(p2, p3)
|
||||
sk.occ_sketch.add_line(p3, p4)
|
||||
sk.occ_sketch.add_line(p4, p1)
|
||||
# Constraint the right side to a known length (instead of relying
|
||||
# on the construction positions, which would just be redundant).
|
||||
sk.occ_sketch.constrain_distance(p2, p3, 25.4)
|
||||
sk.solve()
|
||||
sk.is_fully_constrained = sk.occ_sketch.is_fully_constrained()
|
||||
faces = sk.occ_sketch.detect_faces()
|
||||
if faces:
|
||||
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
|
||||
|
||||
# View state to persist.
|
||||
view_state = {
|
||||
"active_tab": 0,
|
||||
"active_component_id": comp.id,
|
||||
"active_sketch_id": sk.id,
|
||||
"camera_eye": [50.0, 50.0, 50.0],
|
||||
"camera_at": [0.0, 0.0, 0.0],
|
||||
"camera_up": [0.0, 0.0, 1.0],
|
||||
"panel_focus": "sketch",
|
||||
"assembly_view_active": False,
|
||||
}
|
||||
self._view_state = view_state
|
||||
return project
|
||||
|
||||
def test_round_trip_with_view_state(self):
|
||||
original = self._build_project()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "constrained.fluency")
|
||||
save_project(original, path, view_state=self._view_state)
|
||||
loaded, view_state = load_project(path)
|
||||
|
||||
# View state must survive (modulo float-to-list round-tripping).
|
||||
self.assertEqual(view_state.get("active_component_id"),
|
||||
self._view_state["active_component_id"])
|
||||
self.assertEqual(view_state.get("active_sketch_id"),
|
||||
self._view_state["active_sketch_id"])
|
||||
self.assertEqual(view_state.get("camera_eye"),
|
||||
self._view_state["camera_eye"])
|
||||
self.assertEqual(view_state.get("panel_focus"),
|
||||
self._view_state["panel_focus"])
|
||||
|
||||
# Sketch must have replayed its constraints and remain solvable.
|
||||
comp = next(iter(loaded.components.values()))
|
||||
sk = next(iter(comp.sketches.values()))
|
||||
# Re-solve on the loaded sketch to confirm the post-replay
|
||||
# configuration is still consistent.
|
||||
self.assertTrue(sk.occ_sketch.solve())
|
||||
# The right edge of the square should still be 25.4mm tall.
|
||||
import math
|
||||
for lid, line in sk.occ_sketch._lines.items():
|
||||
sid, eid = line
|
||||
sx, sy = sk.occ_sketch._points[sid]
|
||||
ex, ey = sk.occ_sketch._points[eid]
|
||||
length = math.hypot(ex - sx, ey - sy)
|
||||
if abs(length) > 1e-3:
|
||||
self.assertAlmostEqual(
|
||||
length, 25.4, places=3,
|
||||
msg=f"Constraint replay broke: line {lid} = {length}",
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Fluency CAD UI package.
|
||||
|
||||
Contains the Qt widgets, dialogs, and the main window:
|
||||
|
||||
ui/
|
||||
dialogs.py – All 4 modal dialogs (Extrude, Revolve, Offset, WorkplaneOrientation)
|
||||
sketch_widget.py – Sketch2DWidget (2D sketcher with constraint solver)
|
||||
viewer_widget.py – Viewer3DWidget (3D viewer / OCC canvas)
|
||||
main_window.py – MainWindow (application shell)
|
||||
|
||||
The public classes are re-exported from `fluency.main` so existing code that
|
||||
does `from fluency.main import MainWindow` continues to work.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Dialogs for Fluency CAD operations: extrude, revolve, offset, workplane orientation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, QPoint, QPointF
|
||||
from PySide6.QtGui import QColor, QFont, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ExtrudeDialog(QDialog):
|
||||
"""Dialog for extrude options.
|
||||
|
||||
Carries an optional ``preview_callback`` that is invoked whenever the
|
||||
user changes any option; the host uses it to render a live transparent
|
||||
preview of the operation result in the 3D view. Passing *False* (or
|
||||
*None*) to the callback tells the host to clear the preview.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Extrude Options")
|
||||
self.setMinimumWidth(320)
|
||||
|
||||
self._preview_callback = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
length_layout = QHBoxLayout()
|
||||
length_layout.addWidget(QLabel("Extrude Length (mm):"))
|
||||
self.length_input = QDoubleSpinBox()
|
||||
self.length_input.setDecimals(2)
|
||||
self.length_input.setRange(-10000, 10000)
|
||||
self.length_input.setValue(10)
|
||||
length_layout.addWidget(self.length_input)
|
||||
layout.addLayout(length_layout)
|
||||
|
||||
self.symmetric_checkbox = QCheckBox("Symmetric Extrude")
|
||||
layout.addWidget(self.symmetric_checkbox)
|
||||
|
||||
self.invert_checkbox = QCheckBox("Invert Extrusion")
|
||||
layout.addWidget(self.invert_checkbox)
|
||||
|
||||
self.cut_checkbox = QCheckBox("Perform Cut")
|
||||
layout.addWidget(self.cut_checkbox)
|
||||
|
||||
self.union_checkbox = QCheckBox("Combine (Union)")
|
||||
layout.addWidget(self.union_checkbox)
|
||||
|
||||
self.through_all_checkbox = QCheckBox("Through All (cut/union target)")
|
||||
self.through_all_checkbox.setToolTip(
|
||||
"Ignore the typed length and extrude far enough to fully pass "
|
||||
"through the cut/union target body. Applies when Perform Cut or "
|
||||
"Combine (Union) is checked."
|
||||
)
|
||||
layout.addWidget(self.through_all_checkbox)
|
||||
|
||||
self.rounded_checkbox = QCheckBox("Round Edges")
|
||||
layout.addWidget(self.rounded_checkbox)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
ok_button = QPushButton("OK")
|
||||
ok_button.clicked.connect(self.accept)
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(ok_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Live preview: recompute on every option change. Use a light-
|
||||
# weight guard so we don't emit before the host has wired up the
|
||||
# callback.
|
||||
for w in (
|
||||
self.length_input,
|
||||
self.symmetric_checkbox,
|
||||
self.invert_checkbox,
|
||||
self.cut_checkbox,
|
||||
self.union_checkbox,
|
||||
self.through_all_checkbox,
|
||||
self.rounded_checkbox,
|
||||
):
|
||||
# The spinbox has valueChanged; the checkboxes have stateChanged.
|
||||
# Each must be wired in its own try/except so that a missing
|
||||
# signal on one widget type doesn't skip the OTHER signal's
|
||||
# connection (the prior single-try version accidentally
|
||||
# left checkboxes un-connected when valueChanged raised first).
|
||||
try:
|
||||
w.valueChanged.connect(self._emit_preview)
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
w.stateChanged.connect(self._emit_preview)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def set_preview_callback(self, callback) -> None:
|
||||
"""Install the live-preview callback (or *None* to disable)."""
|
||||
self._preview_callback = callback
|
||||
# Emit once so the initial state shows a preview right away.
|
||||
self._emit_preview()
|
||||
|
||||
def _emit_preview(self, *args) -> None:
|
||||
if self._preview_callback is None:
|
||||
return
|
||||
try:
|
||||
self._preview_callback(self.get_values())
|
||||
except Exception as exc: # preview must never break the dialog
|
||||
logger.debug("extrude preview callback raised: %s", exc)
|
||||
|
||||
def hideEvent(self, event):
|
||||
# Tell the host to clear the preview when the dialog goes away
|
||||
# (accept, reject, or close). The host is responsible for the
|
||||
# actual viewer cleanup.
|
||||
if self._preview_callback is not None:
|
||||
try:
|
||||
self._preview_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
super().hideEvent(event)
|
||||
|
||||
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
|
||||
return (
|
||||
self.length_input.value(),
|
||||
self.symmetric_checkbox.isChecked(),
|
||||
self.invert_checkbox.isChecked(),
|
||||
self.cut_checkbox.isChecked(),
|
||||
self.union_checkbox.isChecked(),
|
||||
self.through_all_checkbox.isChecked(),
|
||||
self.rounded_checkbox.isChecked(),
|
||||
)
|
||||
|
||||
|
||||
class RevolveDialog(QDialog):
|
||||
"""Dialog for revolve options."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Revolve Options")
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
angle_layout = QHBoxLayout()
|
||||
angle_layout.addWidget(QLabel("Revolve Angle (°):"))
|
||||
self.angle_input = QDoubleSpinBox()
|
||||
self.angle_input.setDecimals(1)
|
||||
self.angle_input.setRange(1, 360)
|
||||
self.angle_input.setValue(360)
|
||||
self.angle_input.setSuffix("°")
|
||||
angle_layout.addWidget(self.angle_input)
|
||||
layout.addLayout(angle_layout)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
ok_button = QPushButton("OK")
|
||||
ok_button.clicked.connect(self.accept)
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(ok_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
|
||||
class OffsetDialog(QDialog):
|
||||
"""Dialog for 2D sketch offset options.
|
||||
|
||||
Shows a number input for the offset distance with a live preview
|
||||
callback so the sketch widget can render the offset result in real
|
||||
time. On accept the caller retrieves ``get_values()`` → distance.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Offset Sketch")
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
self._preview_callback = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
dist_layout = QHBoxLayout()
|
||||
dist_layout.addWidget(QLabel("Offset Distance (mm):"))
|
||||
self.distance_input = QDoubleSpinBox()
|
||||
self.distance_input.setDecimals(2)
|
||||
self.distance_input.setRange(-10000, 10000)
|
||||
self.distance_input.setValue(10.0)
|
||||
self.distance_input.setSingleStep(0.5)
|
||||
dist_layout.addWidget(self.distance_input)
|
||||
layout.addLayout(dist_layout)
|
||||
|
||||
self.inward_checkbox = QCheckBox("Offset Inward (negative)")
|
||||
self.inward_checkbox.setToolTip("Offset is applied inward instead of outward.")
|
||||
layout.addWidget(self.inward_checkbox)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
ok_button = QPushButton("OK")
|
||||
ok_button.clicked.connect(self.accept)
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(ok_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Live preview on every value change.
|
||||
self.distance_input.valueChanged.connect(self._emit_preview)
|
||||
self.inward_checkbox.stateChanged.connect(self._emit_preview)
|
||||
|
||||
def set_preview_callback(self, callback) -> None:
|
||||
"""Install the live-preview callback (or *None* to disable)."""
|
||||
self._preview_callback = callback
|
||||
self._emit_preview()
|
||||
|
||||
def _emit_preview(self, *args) -> None:
|
||||
if self._preview_callback is None:
|
||||
return
|
||||
try:
|
||||
self._preview_callback(self.get_values())
|
||||
except Exception as exc:
|
||||
logger.debug("offset preview callback raised: %s", exc)
|
||||
|
||||
def hideEvent(self, event):
|
||||
if self._preview_callback is not None:
|
||||
try:
|
||||
self._preview_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
super().hideEvent(event)
|
||||
|
||||
def get_values(self) -> Tuple[float, bool]:
|
||||
return (self.distance_input.value(), self.inward_checkbox.isChecked())
|
||||
|
||||
|
||||
class WorkplaneOrientationDialog(QDialog):
|
||||
"""Modal dialog to choose the orientation of a new workplane.
|
||||
|
||||
Offers XY, XZ, YZ, and custom angle presets. On accept, the caller
|
||||
can retrieve the chosen orientation via :meth:`get_orientation`, which
|
||||
returns (normal, x_dir) pair (both as 3-tuples).
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("New Workplane Orientation")
|
||||
self.setMinimumWidth(320)
|
||||
|
||||
self._normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
self._x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||
|
||||
# Optional callback for live 3D preview of the workplane.
|
||||
# The host installs it via ``set_preview_callback``. The callback
|
||||
# receives ``(normal, x_dir)`` or *None* to clear.
|
||||
self._preview_callback = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# ── Orientation presets ──
|
||||
lbl = QLabel("Choose orientation:")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
self._preset_group = QButtonGroup(self)
|
||||
preset_layout = QGridLayout()
|
||||
presets = [
|
||||
("XY (Top)", (0, 0, 1), (1, 0, 0)),
|
||||
("XZ (Front)", (0, 1, 0), (1, 0, 0)),
|
||||
("YZ (Right)", (1, 0, 0), (0, 1, 0)),
|
||||
("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
|
||||
("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
|
||||
("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
|
||||
]
|
||||
for idx, (label, normal, x_dir) in enumerate(presets):
|
||||
btn = QRadioButton(label)
|
||||
btn.setChecked(idx == 0)
|
||||
self._preset_group.addButton(btn, idx)
|
||||
btn.normal = normal
|
||||
btn.x_dir = x_dir
|
||||
preset_layout.addWidget(btn, idx // 2, idx % 2)
|
||||
layout.addLayout(preset_layout)
|
||||
|
||||
# ── Custom angle (offset from XY) ──
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
self._custom_radio = QRadioButton("Custom (angle from XY):")
|
||||
self._custom_radio.setChecked(False)
|
||||
layout.addWidget(self._custom_radio)
|
||||
|
||||
angle_layout = QHBoxLayout()
|
||||
angle_layout.addWidget(QLabel("Angle X (°):"))
|
||||
self._angle_x = QDoubleSpinBox()
|
||||
self._angle_x.setDecimals(1)
|
||||
self._angle_x.setRange(-360, 360)
|
||||
self._angle_x.setValue(0.0)
|
||||
self._angle_x.setSuffix("°")
|
||||
angle_layout.addWidget(self._angle_x)
|
||||
angle_layout.addWidget(QLabel("Angle Y (°):"))
|
||||
self._angle_y = QDoubleSpinBox()
|
||||
self._angle_y.setDecimals(1)
|
||||
self._angle_y.setRange(-360, 360)
|
||||
self._angle_y.setValue(0.0)
|
||||
self._angle_y.setSuffix("°")
|
||||
angle_layout.addWidget(self._angle_y)
|
||||
layout.addLayout(angle_layout)
|
||||
|
||||
self._name_label = QLabel("Workplane Name:")
|
||||
layout.addWidget(self._name_label)
|
||||
|
||||
self._name_input = QLineEdit()
|
||||
self._name_input.setText("Workplane 1")
|
||||
layout.addWidget(self._name_input)
|
||||
|
||||
# ── Buttons ──
|
||||
line2 = QFrame()
|
||||
line2.setFrameShape(QFrame.HLine)
|
||||
line2.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line2)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
ok_button = QPushButton("Create")
|
||||
ok_button.clicked.connect(self._on_ok)
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(ok_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Live preview: update the 3D view whenever the user changes
|
||||
# the preset, custom radio toggle, or angle values.
|
||||
self._preset_group.buttonClicked.connect(self._on_preset_changed)
|
||||
self._custom_radio.toggled.connect(self._emit_preview)
|
||||
self._angle_x.valueChanged.connect(self._emit_preview)
|
||||
self._angle_y.valueChanged.connect(self._emit_preview)
|
||||
|
||||
def set_preview_callback(self, callback) -> None:
|
||||
"""Install a callback for live 3D preview of the workplane orientation.
|
||||
|
||||
*callback* is called with ``(normal, x_dir)`` whenever the user
|
||||
changes the selection, or with *None* when the dialog closes.
|
||||
"""
|
||||
self._preview_callback = callback
|
||||
# Emit once so the initial state shows a preview right away.
|
||||
self._emit_preview()
|
||||
|
||||
def _emit_preview(self, *args) -> None:
|
||||
"""Call the preview callback with the current orientation, if installed."""
|
||||
if self._preview_callback is None:
|
||||
return
|
||||
try:
|
||||
normal, x_dir, _name = self.get_orientation()
|
||||
self._preview_callback((normal, x_dir))
|
||||
except Exception as exc:
|
||||
logger.debug("workplane preview callback raised: %s", exc)
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Clear the live preview when the dialog closes."""
|
||||
if self._preview_callback is not None:
|
||||
try:
|
||||
self._preview_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
super().hideEvent(event)
|
||||
|
||||
def _on_preset_changed(self, btn):
|
||||
"""When a preset is selected, deselect the custom radio and emit preview."""
|
||||
self._custom_radio.setChecked(False)
|
||||
self._emit_preview()
|
||||
|
||||
def _on_ok(self):
|
||||
"""Compute the final orientation and accept."""
|
||||
import numpy as np
|
||||
import math
|
||||
|
||||
if self._custom_radio.isChecked():
|
||||
# Custom: start from XY normal and rotate by the two angles.
|
||||
ax = math.radians(self._angle_x.value())
|
||||
ay = math.radians(self._angle_y.value())
|
||||
# Start from +Z normal, rotate around X then Y
|
||||
n = np.array([0.0, 0.0, 1.0])
|
||||
# Rotate around X
|
||||
rx = np.array([
|
||||
[1, 0, 0],
|
||||
[0, math.cos(ax), -math.sin(ax)],
|
||||
[0, math.sin(ax), math.cos(ax)],
|
||||
])
|
||||
n = rx @ n
|
||||
# Rotate around Y
|
||||
ry = np.array([
|
||||
[math.cos(ay), 0, math.sin(ay)],
|
||||
[0, 1, 0],
|
||||
[-math.sin(ay), 0, math.cos(ay)],
|
||||
])
|
||||
n = ry @ n
|
||||
n = n / np.linalg.norm(n)
|
||||
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
|
||||
world_y = np.array([0.0, 1.0, 0.0])
|
||||
if abs(np.dot(n, world_y)) > 0.99:
|
||||
world_y = np.array([0.0, 0.0, 1.0])
|
||||
x = np.cross(world_y, n)
|
||||
x_norm = np.linalg.norm(x)
|
||||
if x_norm > 1e-9:
|
||||
x = x / x_norm
|
||||
else:
|
||||
x = np.array([1.0, 0.0, 0.0])
|
||||
self._normal = tuple(float(v) for v in n)
|
||||
self._x_dir = tuple(float(v) for v in x)
|
||||
|
||||
else:
|
||||
btn = self._preset_group.checkedButton()
|
||||
if btn is not None:
|
||||
self._normal = btn.normal
|
||||
self._x_dir = btn.x_dir
|
||||
self.accept()
|
||||
|
||||
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
|
||||
"""Return (normal, x_dir, name) for the chosen workplane.
|
||||
|
||||
Computes the current selection from the UI state so it works
|
||||
whether called before or after ``_on_ok``.
|
||||
"""
|
||||
import numpy as np
|
||||
import math
|
||||
|
||||
if self._custom_radio.isChecked():
|
||||
ax = math.radians(self._angle_x.value())
|
||||
ay = math.radians(self._angle_y.value())
|
||||
n = np.array([0.0, 0.0, 1.0])
|
||||
rx = np.array([
|
||||
[1, 0, 0],
|
||||
[0, math.cos(ax), -math.sin(ax)],
|
||||
[0, math.sin(ax), math.cos(ax)],
|
||||
])
|
||||
n = rx @ n
|
||||
ry = np.array([
|
||||
[math.cos(ay), 0, math.sin(ay)],
|
||||
[0, 1, 0],
|
||||
[-math.sin(ay), 0, math.cos(ay)],
|
||||
])
|
||||
n = ry @ n
|
||||
n = n / np.linalg.norm(n)
|
||||
world_y = np.array([0.0, 1.0, 0.0])
|
||||
if abs(np.dot(n, world_y)) > 0.99:
|
||||
world_y = np.array([0.0, 0.0, 1.0])
|
||||
x = np.cross(world_y, n)
|
||||
x_norm = np.linalg.norm(x)
|
||||
if x_norm > 1e-9:
|
||||
x = x / x_norm
|
||||
else:
|
||||
x = np.array([1.0, 0.0, 0.0])
|
||||
return (
|
||||
tuple(float(v) for v in n),
|
||||
tuple(float(v) for v in x),
|
||||
self._name_input.text().strip() or "Workplane",
|
||||
)
|
||||
else:
|
||||
btn = self._preset_group.checkedButton()
|
||||
if btn is not None:
|
||||
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
|
||||
# Fallback: XY default.
|
||||
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
||||
"""3D viewer widget — wraps OCC's AIS/V3d native display for use inside Qt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
|
||||
from PySide6.QtGui import QCursor, QFont, QPainter, QPen, QColor, QBrush, QPolygonF
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Viewer3DWidget(QWidget):
|
||||
"""3D viewer widget using OCC's native AIS display."""
|
||||
|
||||
# Emitted when the user picks a planar face to sketch on.
|
||||
# Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z).
|
||||
facePicked = Signal(tuple, tuple, tuple, object)
|
||||
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
||||
pickFaceCancelled = Signal()
|
||||
|
||||
# Emitted when the user picks an entity for a connector point (assembly).
|
||||
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
|
||||
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
|
||||
# Emitted when connector pick mode is cancelled.
|
||||
connectorPickCancelled = Signal()
|
||||
# Emitted on mouse move in connector mode to show snap preview.
|
||||
# Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
|
||||
connectorHover = Signal(object)
|
||||
|
||||
# Emitted when a body is clicked in assembly move mode.
|
||||
# Payload: owner_obj_id.
|
||||
assemblyComponentActivated = Signal(str)
|
||||
# Emitted during a drag move: owner_obj_id, world dx, dy, dz.
|
||||
assemblyComponentDragged = Signal(str, float, float, float)
|
||||
# Emitted when a drag move finishes.
|
||||
assemblyMoveFinished = Signal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
||||
self.setAttribute(Qt.WA_PaintOnScreen)
|
||||
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||||
self.setAutoFillBackground(False)
|
||||
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
||||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
|
||||
# button held — required for the connector-pick hover gizmo (and any
|
||||
# status-bar hover feedback) to show under the cursor as the user
|
||||
# moves the mouse over candidate snap entities before clicking.
|
||||
self.setMouseTracking(True)
|
||||
# Try OCC renderer first; fall back to pygfx if unavailable.
|
||||
self._renderer: Any = None
|
||||
self._initialized = False
|
||||
self._meshes: Dict[str, Any] = {}
|
||||
self._selected_normal: Optional[Tuple[float, float, float]] = None
|
||||
self._centroid: Optional[Tuple[float, float, float]] = None
|
||||
self._pending_meshes: List[Tuple] = []
|
||||
# When True, a left-click picks a planar face (for sketch-on-surface)
|
||||
# instead of orbiting the camera. Set via set_pick_face_mode().
|
||||
self._pick_face_mode: bool = False
|
||||
# When True, a left-click picks an entity for a connector point
|
||||
# (assembly component connection).
|
||||
self._connector_pick_mode: bool = False
|
||||
# Current snap highlight object id (for hover during connector mode).
|
||||
self._connector_snap_id: Optional[str] = None
|
||||
# When True, left-click on a body activates assembly drag-to-move.
|
||||
self._assembly_move_mode: bool = False
|
||||
# State for ongoing assembly drag.
|
||||
self._move_drag_active: bool = False
|
||||
self._move_owner_obj_id: str = ""
|
||||
self._move_click_3d: Optional[Tuple[float, float, float]] = None
|
||||
self._move_click_screen: Optional[Any] = None
|
||||
self._move_plane_normal: Optional[Tuple[float, float, float]] = None
|
||||
self._move_initial_position: Optional[Tuple[float, float, float]] = None
|
||||
# Most recently recorded owning obj_id for the face returned by
|
||||
# ``pick_planar_face``. Stashed on each pick pass so the host can
|
||||
# pair the picked face with the body it belongs to (used to auto-
|
||||
# target a cut/union extrude against the body the sketch was
|
||||
# projected onto).
|
||||
self._last_pick_owner_obj_id: Optional[str] = None
|
||||
|
||||
def _init_renderer(self) -> None:
|
||||
"""Create the best available renderer."""
|
||||
if self._renderer is not None:
|
||||
return
|
||||
import sys as _sys
|
||||
_sys.stdout.flush()
|
||||
logger.info("Renderer: starting import...")
|
||||
from fluency.rendering.occ_renderer import OCCRenderer
|
||||
from fluency.rendering.pygfx_renderer import PygfxRenderer
|
||||
logger.info("Renderer: imports done, creating OCCRenderer...")
|
||||
occ = OCCRenderer()
|
||||
logger.info("Renderer: calling occ.initialize...")
|
||||
try:
|
||||
ok = occ.initialize(self)
|
||||
except Exception as exc:
|
||||
logger.warning(f"OCCRenderer init raised: {exc}")
|
||||
ok = False
|
||||
logger.info(f"Renderer: OCC result={ok}")
|
||||
if ok:
|
||||
self._renderer = occ
|
||||
logger.info("Using OCCRenderer (native BRep display)")
|
||||
else:
|
||||
logger.info("Falling back to PygfxRenderer")
|
||||
self._renderer = PygfxRenderer()
|
||||
logger.info("Renderer: calling pygfx initialize...")
|
||||
self._renderer.initialize(self)
|
||||
logger.info("Renderer: pygfx init done")
|
||||
self._initialized = True
|
||||
logger.info("Renderer: initialization complete")
|
||||
|
||||
def showEvent(self, event):
|
||||
logger.info("Viewer3DWidget showEvent - initializing renderer")
|
||||
if not self._initialized:
|
||||
self._init_renderer()
|
||||
logger.info(f"Renderer initialized, pending meshes: {len(self._pending_meshes)}")
|
||||
for args in self._pending_meshes:
|
||||
self.add_mesh(*args)
|
||||
self._pending_meshes.clear()
|
||||
self._renderer.render()
|
||||
|
||||
def _ensure_initialized(self):
|
||||
if not self._initialized:
|
||||
logger.debug("Ensuring renderer is initialized")
|
||||
self._init_renderer()
|
||||
|
||||
def get_renderer(self):
|
||||
self._ensure_initialized()
|
||||
return self._renderer
|
||||
|
||||
def show_shape(self, shape: Any, color=None, name=None) -> str:
|
||||
"""Display an OCC TopoDS_Shape.
|
||||
|
||||
Uses OCCRenderer.add_shape for native AIS display, or falls back to
|
||||
triangulation + add_mesh for the PygfxRenderer.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
from fluency.rendering.occ_renderer import OCCRenderer
|
||||
if isinstance(self._renderer, OCCRenderer):
|
||||
oid = self._renderer.add_shape(shape, color, name)
|
||||
self._renderer.render()
|
||||
return oid
|
||||
# Fallback: tessellate and use the mesh pipeline.
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
k = OCGeometryKernel()
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
obj = OCCGeometryObject(shape)
|
||||
verts, faces = k.get_mesh(obj)
|
||||
oid = self._renderer.add_mesh(verts, faces, color, name)
|
||||
# Edges
|
||||
try:
|
||||
e_verts, e_edges = k.get_edges(obj)
|
||||
if len(e_verts) > 0:
|
||||
self._renderer.add_wireframe(e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges")
|
||||
except Exception:
|
||||
pass
|
||||
self._renderer.render()
|
||||
return oid
|
||||
|
||||
def add_mesh(self, vertices, faces, color=None, name=None) -> str:
|
||||
logger.debug(
|
||||
f"add_mesh called: initialized={self._initialized}, vertices={len(vertices)}, faces={len(faces)}, name={name}"
|
||||
)
|
||||
if not self._initialized:
|
||||
self._pending_meshes.append((vertices, faces, color, name))
|
||||
logger.info(f"Queued pending mesh, total pending: {len(self._pending_meshes)}")
|
||||
return f"pending_{len(self._pending_meshes)}"
|
||||
|
||||
self._ensure_initialized()
|
||||
mesh_id = self._renderer.add_mesh(vertices, faces, color, name)
|
||||
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces, "name": name}
|
||||
self._renderer.render()
|
||||
logger.info(f"Added mesh: {mesh_id}, name={name}")
|
||||
return mesh_id
|
||||
|
||||
def update_mesh(self, mesh_id: str, vertices, faces):
|
||||
self._ensure_initialized()
|
||||
self._renderer.update_mesh(mesh_id, vertices, faces)
|
||||
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces}
|
||||
self._renderer.render()
|
||||
|
||||
def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
|
||||
self._ensure_initialized()
|
||||
wid = self._renderer.add_wireframe(vertices, edges, color or (0.9, 0.9, 0.9), line_width, name)
|
||||
self._renderer.render()
|
||||
return wid
|
||||
|
||||
def remove_mesh(self, mesh_id: str):
|
||||
self._ensure_initialized()
|
||||
self._renderer.remove_mesh(mesh_id)
|
||||
if mesh_id in self._meshes:
|
||||
del self._meshes[mesh_id]
|
||||
self._renderer.render()
|
||||
|
||||
def set_visibility(self, mesh_id: str, visible: bool) -> bool:
|
||||
"""Show or hide a previously-added mesh without removing it.
|
||||
|
||||
Used by the per-body visibility toggle on the body list so the
|
||||
user can quickly hide a body (e.g. to verify a cut worked on
|
||||
another body). Returns True on success, False if the mesh is
|
||||
unknown to the renderer or the renderer doesn't support it
|
||||
(e.g. the Pygfx fallback ABI).
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "set_visibility", None)
|
||||
if fn is None:
|
||||
return False
|
||||
ok = fn(mesh_id, visible)
|
||||
if ok:
|
||||
self._renderer.render()
|
||||
return ok
|
||||
|
||||
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
|
||||
"""Set a previously-added mesh's transparency (0..1).
|
||||
|
||||
Used by the live extrude preview to dim the target body so the
|
||||
previewed result reads on top of it.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "set_object_transparency", None)
|
||||
if fn is None:
|
||||
return False
|
||||
return fn(mesh_id, transparency)
|
||||
|
||||
def show_preview(self, shape: Any, color=None, transparency: float = 0.60) -> None:
|
||||
"""Display a temporary transparent preview of *shape* in the 3D view.
|
||||
|
||||
Used by the ExtrudeDialog live preview: as the user drags the
|
||||
length spinner or toggles Cut/Through-All, the host recomputes
|
||||
the operation result and shows it here. Call clear_preview()
|
||||
when the dialog closes.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "preview_shape", None)
|
||||
if fn is None:
|
||||
return
|
||||
fn(shape, color, transparency)
|
||||
|
||||
def clear_preview(self) -> None:
|
||||
"""Remove the live extrude preview shape, if any."""
|
||||
if not self._initialized or self._renderer is None:
|
||||
return
|
||||
fn = getattr(self._renderer, "clear_preview", None)
|
||||
if fn is None:
|
||||
return
|
||||
fn()
|
||||
|
||||
def clear_scene(self):
|
||||
self._ensure_initialized()
|
||||
self._renderer.clear_scene()
|
||||
self._meshes.clear()
|
||||
self._renderer.render()
|
||||
|
||||
def fit_camera(self):
|
||||
self._ensure_initialized()
|
||||
self._renderer.fit_camera()
|
||||
self._renderer.render()
|
||||
|
||||
# ─── Workplane visualization ───────────────────────────────────────────
|
||||
|
||||
def show_workplane(
|
||||
self,
|
||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||
normal: Tuple[float, float, float] = (0, 0, 1),
|
||||
x_dir: Tuple[float, float, float] = (1, 0, 0),
|
||||
size: float = 200.0,
|
||||
name: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Display a semi-transparent workplane plane in the 3D view.
|
||||
|
||||
Returns the object ID (for later removal) or None if the renderer
|
||||
doesn't support workplane planes.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "show_workplane_plane", None)
|
||||
if fn is None:
|
||||
return None
|
||||
oid = fn(origin, normal, x_dir, size, name)
|
||||
self._renderer.render()
|
||||
return oid
|
||||
|
||||
def remove_workplane(self, obj_id: str) -> bool:
|
||||
"""Remove a workplane plane visual by its ID."""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "remove_workplane_plane", None)
|
||||
if fn is None:
|
||||
return False
|
||||
ok = fn(obj_id)
|
||||
if ok:
|
||||
self._renderer.render()
|
||||
return ok
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self._ensure_initialized()
|
||||
# Face-pick mode: a left-click selects a planar face to sketch on.
|
||||
if self._pick_face_mode and event.button() == Qt.LeftButton:
|
||||
self._handle_face_pick(event)
|
||||
return
|
||||
# Connector pick mode: a left-click selects a face for a connection point.
|
||||
if self._connector_pick_mode and event.button() == Qt.LeftButton:
|
||||
self._handle_connector_pick(event)
|
||||
return
|
||||
# Assembly move mode: start dragging the clicked body.
|
||||
if self._assembly_move_mode and event.button() == Qt.LeftButton:
|
||||
self._handle_assembly_move_press(event)
|
||||
return
|
||||
self._renderer.handle_mouse_press(event)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
self._ensure_initialized()
|
||||
# In connector mode, show snap hover.
|
||||
if self._connector_pick_mode:
|
||||
self._handle_connector_hover(event)
|
||||
super().mouseMoveEvent(event)
|
||||
return
|
||||
# In face-pick mode, keep dynamic highlighting.
|
||||
if self._pick_face_mode:
|
||||
if hasattr(self._renderer, "handle_mouse_move"):
|
||||
self._renderer.handle_mouse_move(event)
|
||||
super().mouseMoveEvent(event)
|
||||
return
|
||||
# Active drag in assembly move mode.
|
||||
if self._move_drag_active:
|
||||
self._handle_assembly_move_move(event)
|
||||
super().mouseMoveEvent(event)
|
||||
return
|
||||
self._renderer.handle_mouse_move(event)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def paintEngine(self):
|
||||
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
|
||||
return None
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Empty paintEvent — OCC draws directly via OpenGL."""
|
||||
pass
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self._ensure_initialized()
|
||||
# Finish assembly drag.
|
||||
if self._move_drag_active:
|
||||
self._handle_assembly_move_release(event)
|
||||
return
|
||||
self._renderer.handle_mouse_release(event)
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
self._ensure_initialized()
|
||||
self._renderer.handle_wheel(event)
|
||||
super().wheelEvent(event)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._ensure_initialized()
|
||||
self._renderer.handle_resize(event.size().width(), event.size().height())
|
||||
|
||||
def set_camera_position(self, position, target):
|
||||
self._ensure_initialized()
|
||||
self._renderer.set_camera_position(position, target)
|
||||
self._renderer.render()
|
||||
|
||||
def get_camera_position(self):
|
||||
"""Return the current camera ``(eye, at, up)`` triple.
|
||||
|
||||
The underlying renderer's ``get_camera_position`` returns three
|
||||
``np.ndarray``s. We forward the call so callers (notably
|
||||
:meth:`MainWindow._collect_view_state`) can persist the camera.
|
||||
Returns a tuple of ``(np.zeros(3), np.zeros(3), (0,0,1))`` if the
|
||||
renderer hasn't been initialised yet (e.g. when the window is
|
||||
being constructed).
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
if hasattr(self._renderer, "get_camera_position"):
|
||||
return self._renderer.get_camera_position()
|
||||
import numpy as np
|
||||
return (
|
||||
np.zeros(3, dtype=float),
|
||||
np.zeros(3, dtype=float),
|
||||
np.array([0.0, 0.0, 1.0], dtype=float),
|
||||
)
|
||||
|
||||
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
|
||||
|
||||
def set_pick_face_mode(self, enabled: bool) -> None:
|
||||
"""Toggle face-pick mode.
|
||||
|
||||
When enabled, the cursor selects planar faces for sketch placement
|
||||
instead of orbiting the camera. Middle button still pans; wheel zooms.
|
||||
"""
|
||||
self._pick_face_mode = bool(enabled)
|
||||
if enabled:
|
||||
self.setCursor(Qt.CrossCursor)
|
||||
else:
|
||||
self.unsetCursor()
|
||||
|
||||
def is_pick_face_mode(self) -> bool:
|
||||
return self._pick_face_mode
|
||||
|
||||
def highlight_face(self, face: Any) -> None:
|
||||
"""Tint the picked face light-blue/transparent in the 3D viewer."""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "highlight_face", None)
|
||||
if fn is not None:
|
||||
fn(face)
|
||||
self._renderer.render()
|
||||
|
||||
def clear_face_highlight(self) -> None:
|
||||
"""Remove the persistent face-selection tint."""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "clear_face_highlight", None)
|
||||
if fn is not None:
|
||||
fn()
|
||||
self._renderer.render()
|
||||
|
||||
# ─── Connector pick mode (assembly) ────────────────────────────────────
|
||||
|
||||
def set_connector_pick_mode(self, enabled: bool) -> None:
|
||||
"""Toggle connector pick mode for placing connection points.
|
||||
|
||||
When enabled, clicking an entity (face, edge, vertex, hole)
|
||||
on a body in the assembly view captures its position and
|
||||
direction as a connection point for the SolveSpace solver.
|
||||
"""
|
||||
self._connector_pick_mode = bool(enabled)
|
||||
if enabled:
|
||||
self.setCursor(Qt.CrossCursor)
|
||||
elif not self._pick_face_mode:
|
||||
self.unsetCursor()
|
||||
if not enabled:
|
||||
self._clear_connector_snap()
|
||||
|
||||
def is_connector_pick_mode(self) -> bool:
|
||||
return self._connector_pick_mode
|
||||
|
||||
def _clear_connector_snap(self) -> None:
|
||||
"""Remove the hover gizmo."""
|
||||
fn = getattr(self._renderer, "clear_entity_gizmo", None)
|
||||
if fn is not None:
|
||||
fn()
|
||||
# Backwards compat: also try the old method.
|
||||
if self._connector_snap_id is not None:
|
||||
fn2 = getattr(self._renderer, "remove_highlight_snap", None)
|
||||
if fn2 is not None:
|
||||
fn2(self._connector_snap_id)
|
||||
self._connector_snap_id = None
|
||||
|
||||
def _handle_connector_hover(self, event) -> None:
|
||||
"""Update the hover snap gizmo during connector pick mode.
|
||||
|
||||
Probes a small neighbourhood around the cursor for ALL nearby snap
|
||||
candidates (vertices, edge midpoints, face centres, hole openings)
|
||||
and renders a dim marker on each plus a bright primary on the nearest
|
||||
one — the general snap indicator. Clicking then selects the
|
||||
primary's position.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
probe = getattr(self._renderer, "probe_snap_candidates", None)
|
||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||
|
||||
if probe is not None:
|
||||
candidates = probe(pos.x(), pos.y())
|
||||
if not candidates:
|
||||
self._clear_connector_snap()
|
||||
self.connectorHover.emit(None)
|
||||
return
|
||||
# Primary = the nearest candidate (probe sorts nearest-first).
|
||||
info = candidates[0]
|
||||
else:
|
||||
# Fall back to single-pixel pick on renderers without the probe.
|
||||
picker = getattr(self._renderer, "pick_entity", None)
|
||||
if picker is None:
|
||||
return
|
||||
info = picker(pos.x(), pos.y())
|
||||
candidates = [info] if info else []
|
||||
if info is None or info.get("owner_obj_id") is None:
|
||||
self._clear_connector_snap()
|
||||
self.connectorHover.emit(None)
|
||||
return
|
||||
|
||||
origin = info["position"]
|
||||
normal = info.get("normal")
|
||||
entity_type = info["type"]
|
||||
owner = info.get("owner_obj_id", "")
|
||||
|
||||
# Show smart entity gizmo — dim candidate markers + bright primary.
|
||||
self._clear_connector_snap()
|
||||
gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
|
||||
if gizmo_fn is not None:
|
||||
gizmo_fn(
|
||||
entity_type=entity_type,
|
||||
position=origin,
|
||||
normal=normal,
|
||||
x_dir=info.get("x_dir"),
|
||||
radius=info.get("radius"),
|
||||
candidates=candidates,
|
||||
)
|
||||
else:
|
||||
# Fallback to old highlight_snap.
|
||||
fn = getattr(self._renderer, "highlight_snap", None)
|
||||
if fn is not None:
|
||||
colors = {
|
||||
"planar_face": (0.0, 0.8, 1.0), # cyan
|
||||
"cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
|
||||
"edge": (0.0, 1.0, 0.4), # green
|
||||
"vertex": (1.0, 1.0, 0.0), # yellow
|
||||
}
|
||||
c = colors.get(entity_type, (1.0, 0.6, 0.0))
|
||||
self._connector_snap_id = fn(origin, color=c, size=3.0)
|
||||
|
||||
self.connectorHover.emit({
|
||||
"origin": origin,
|
||||
"normal": normal,
|
||||
"type": entity_type,
|
||||
"owner_obj_id": owner,
|
||||
})
|
||||
|
||||
def _handle_connector_pick(self, event) -> None:
|
||||
"""Detect an entity under the click and emit connectorPicked.
|
||||
|
||||
Uses the multi-pixel ``probe_snap_candidates`` so a click selects the
|
||||
PRIMARY (nearest) snap target — the same one the hover gizmo
|
||||
emphasised. Falls back to single-pixel ``pick_entity`` then to
|
||||
``pick_planar_face`` on renderers without the probe.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||
info: Optional[Dict[str, Any]] = None
|
||||
|
||||
probe = getattr(self._renderer, "probe_snap_candidates", None)
|
||||
if probe is not None:
|
||||
candidates = probe(pos.x(), pos.y())
|
||||
if candidates:
|
||||
info = candidates[0] # nearest = primary
|
||||
|
||||
if info is None:
|
||||
picker = getattr(self._renderer, "pick_entity", None)
|
||||
if picker is None:
|
||||
# Fallback to planar face only.
|
||||
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||
if picker is None:
|
||||
logger.warning("Renderer has no entity picking support")
|
||||
return
|
||||
pinfo = picker(pos.x(), pos.y())
|
||||
if pinfo is None:
|
||||
logger.info("Connector pick: no planar face under cursor")
|
||||
return
|
||||
owner_obj_id = pinfo.get("owner_obj_id", "")
|
||||
self.connectorPicked.emit(
|
||||
tuple(pinfo["origin"]),
|
||||
tuple(pinfo["normal"]),
|
||||
tuple(pinfo["x_dir"]),
|
||||
"planar_face",
|
||||
pinfo["face"],
|
||||
owner_obj_id,
|
||||
)
|
||||
return
|
||||
info = picker(pos.x(), pos.y())
|
||||
|
||||
if info is None:
|
||||
logger.info("Connector pick: no entity under cursor")
|
||||
return
|
||||
owner_obj_id = info.get("owner_obj_id", "")
|
||||
if not owner_obj_id:
|
||||
return
|
||||
|
||||
entity_type = info["type"]
|
||||
origin = info["position"]
|
||||
normal = info.get("normal") or (0.0, 0.0, 1.0)
|
||||
x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
|
||||
|
||||
# For vertices, pick a sensible normal from the parent face if possible.
|
||||
if entity_type == "vertex" and normal is None:
|
||||
normal = (0.0, 0.0, 1.0)
|
||||
|
||||
# Package the raw shape appropriately.
|
||||
raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
|
||||
|
||||
self.connectorPicked.emit(
|
||||
tuple(origin),
|
||||
tuple(normal),
|
||||
tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
|
||||
entity_type,
|
||||
raw_shape,
|
||||
owner_obj_id,
|
||||
)
|
||||
|
||||
# ─── Assembly move mode (3D drag) ─────────────────────────────────────
|
||||
|
||||
def set_assembly_move_mode(self, enabled: bool) -> None:
|
||||
"""Toggle assembly move mode.
|
||||
|
||||
When enabled, clicking on a body and dragging moves its
|
||||
assembly component in the view plane. Shift+drag moves in Z.
|
||||
"""
|
||||
self._assembly_move_mode = bool(enabled)
|
||||
if enabled:
|
||||
self.setCursor(Qt.SizeAllCursor)
|
||||
elif not self._pick_face_mode and not self._connector_pick_mode:
|
||||
self.unsetCursor()
|
||||
if not enabled:
|
||||
self._move_drag_active = False
|
||||
self._move_owner_obj_id = ""
|
||||
self._move_click_3d = None
|
||||
self._move_click_screen = None
|
||||
self._move_plane_normal = None
|
||||
self._move_initial_position = None
|
||||
|
||||
def _handle_assembly_move_press(self, event) -> None:
|
||||
"""Start a drag-to-move for the body under the cursor."""
|
||||
self._ensure_initialized()
|
||||
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||
if picker is None:
|
||||
return
|
||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||
info = picker(pos.x(), pos.y())
|
||||
if info is None:
|
||||
return
|
||||
|
||||
owner_obj_id = info.get("owner_obj_id", "")
|
||||
if not owner_obj_id or not owner_obj_id.startswith("asm_"):
|
||||
return
|
||||
|
||||
# Store drag state.
|
||||
self._move_drag_active = True
|
||||
self._move_owner_obj_id = owner_obj_id
|
||||
self._move_click_3d = tuple(info["origin"])
|
||||
self._move_click_screen = pos
|
||||
self._move_plane_normal = tuple(info["normal"])
|
||||
|
||||
# Emit activation signal so MainWindow stores initial position.
|
||||
self.assemblyComponentActivated.emit(owner_obj_id)
|
||||
|
||||
def _handle_assembly_move_move(self, event) -> None:
|
||||
"""Continue the drag: project mouse delta to world-space and emit."""
|
||||
if not self._move_drag_active or self._move_click_screen is None:
|
||||
return
|
||||
|
||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||
|
||||
# Screen delta (Qt Y is inverted vs OCC).
|
||||
dx = pos.x() - self._move_click_screen.x()
|
||||
dy = -(pos.y() - self._move_click_screen.y()) # invert Y
|
||||
|
||||
# Convert screen delta to world units using the view scale.
|
||||
# view.Scale() returns a scale factor — the smaller the value the
|
||||
# more world distance per pixel. We use an empirical conversion:
|
||||
# at scale=1.0, ~1 pixel ≈ 0.3 world units at typical depth.
|
||||
scale = self._renderer._view.Scale() if hasattr(self._renderer, "_view") else 1.0
|
||||
world_per_pixel = 2.0 / max(scale, 0.001)
|
||||
|
||||
# Get camera vectors for proper view-plane projection.
|
||||
import numpy as np
|
||||
from OCP.V3d import V3d_TypeOfOrientation
|
||||
try:
|
||||
# Get camera direction and up from the OCC view.
|
||||
camera = self._renderer._view.Camera()
|
||||
dir_ = camera.Direction()
|
||||
up_ = camera.Up()
|
||||
cam_dir = np.array([dir_.X(), dir_.Y(), dir_.Z()])
|
||||
cam_up = np.array([up_.X(), up_.Y(), up_.Z()])
|
||||
cam_right = np.cross(cam_dir, cam_up)
|
||||
cam_right = cam_right / np.linalg.norm(cam_right)
|
||||
cam_up = cam_up / np.linalg.norm(cam_up)
|
||||
except Exception:
|
||||
# Fallback: assume XY plane.
|
||||
cam_right = np.array([1.0, 0.0, 0.0])
|
||||
cam_up = np.array([0.0, 0.0, 1.0])
|
||||
|
||||
# Compute world-space delta.
|
||||
modifiers = event.modifiers()
|
||||
if modifiers & Qt.ShiftModifier:
|
||||
# Shift+drag: move along camera direction (Z-depth).
|
||||
dz_world = dx * world_per_pixel
|
||||
dx_world = 0.0
|
||||
dy_world = 0.0
|
||||
else:
|
||||
# Normal drag: move in view plane.
|
||||
dx_world = float(cam_right[0] * dx * world_per_pixel +
|
||||
cam_up[0] * dy * world_per_pixel)
|
||||
dy_world = float(cam_right[1] * dx * world_per_pixel +
|
||||
cam_up[1] * dy * world_per_pixel)
|
||||
dz_world = float(cam_right[2] * dx * world_per_pixel +
|
||||
cam_up[2] * dy * world_per_pixel)
|
||||
|
||||
self.assemblyComponentDragged.emit(
|
||||
self._move_owner_obj_id, dx_world, dy_world, dz_world
|
||||
)
|
||||
|
||||
def _handle_assembly_move_release(self, event) -> None:
|
||||
"""Finish the drag, emit final position."""
|
||||
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
|
||||
self._move_drag_active = False
|
||||
self._move_owner_obj_id = ""
|
||||
self._move_click_3d = None
|
||||
self._move_click_screen = None
|
||||
self._move_plane_normal = None
|
||||
self._move_initial_position = None
|
||||
|
||||
def _handle_face_pick(self, event) -> None:
|
||||
"""Detect a planar face under the click and emit facePicked."""
|
||||
self._ensure_initialized()
|
||||
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||
if picker is None:
|
||||
logger.warning("Renderer has no pick_planar_face support")
|
||||
return
|
||||
# Qt6: prefer position().toPoint() over deprecated pos().
|
||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||
info = picker(pos.x(), pos.y())
|
||||
if info is None:
|
||||
logger.info("Face pick: no planar face under cursor")
|
||||
return
|
||||
# Stash the owning obj_id so MainWindow._on_face_picked can pair the
|
||||
# picked face with the body it belongs to (for auto-targeted cut).
|
||||
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
||||
self.facePicked.emit(
|
||||
tuple(info["origin"]),
|
||||
tuple(info["normal"]),
|
||||
tuple(info["x_dir"]),
|
||||
info["face"],
|
||||
)
|
||||
|
||||
def set_view(self, view: str):
|
||||
# Prefer the renderer's native orientation snap (preserves target,
|
||||
# refits the scene). Falls back to absolute eye positions for
|
||||
# renderers that don't implement set_view_orientation.
|
||||
self._ensure_initialized()
|
||||
if hasattr(self._renderer, "set_view_orientation"):
|
||||
self._renderer.set_view_orientation(view)
|
||||
self._renderer.render()
|
||||
return
|
||||
positions = {
|
||||
"iso": ((100, 100, 100), (0, 0, 0)),
|
||||
"top": ((0, 0, 200), (0, 0, 0)),
|
||||
"front": ((0, -200, 0), (0, 0, 0)),
|
||||
"right": ((200, 0, 0), (0, 0, 0)),
|
||||
"back": ((0, 200, 0), (0, 0, 0)),
|
||||
"left": ((-200, 0, 0), (0, 0, 0)),
|
||||
"bottom": ((0, 0, -200), (0, 0, 0)),
|
||||
}
|
||||
if view in positions:
|
||||
pos, target = positions[view]
|
||||
self.set_camera_position(pos, target)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
# Double-click → fit all (common CAD convention).
|
||||
self._ensure_initialized()
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.fit_camera()
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
# Esc cancels face-pick mode.
|
||||
if self._pick_face_mode and event.key() == Qt.Key_Escape:
|
||||
self.set_pick_face_mode(False)
|
||||
self.pickFaceCancelled.emit()
|
||||
return
|
||||
# Esc cancels connector pick mode.
|
||||
if self._connector_pick_mode and event.key() == Qt.Key_Escape:
|
||||
self.set_connector_pick_mode(False)
|
||||
self.connectorPickCancelled.emit()
|
||||
return
|
||||
# Esc cancels assembly move mode.
|
||||
if self._assembly_move_mode and event.key() == Qt.Key_Escape:
|
||||
self.set_assembly_move_mode(False)
|
||||
return
|
||||
# Navigation shortcuts (lowercase = view presets, F = fit,
|
||||
# P/O = perspective/orthographic, R = reset).
|
||||
self._ensure_initialized()
|
||||
key = event.text().lower()
|
||||
mapping = {
|
||||
"f": "fit",
|
||||
"r": "reset",
|
||||
"1": "front",
|
||||
"2": "back",
|
||||
"3": "top",
|
||||
"4": "bottom",
|
||||
"5": "left",
|
||||
"6": "right",
|
||||
"7": "iso",
|
||||
}
|
||||
action = mapping.get(key)
|
||||
if action == "fit":
|
||||
self.fit_camera()
|
||||
return
|
||||
if action == "reset":
|
||||
if hasattr(self._renderer, "reset_camera"):
|
||||
self._renderer.reset_camera()
|
||||
self._renderer.render()
|
||||
else:
|
||||
self.set_view("iso")
|
||||
return
|
||||
if action in ("front", "back", "top", "bottom", "left", "right", "iso"):
|
||||
self.set_view(action)
|
||||
return
|
||||
if key == "p" and hasattr(self._renderer, "set_camera_perspective"):
|
||||
self._renderer.set_camera_perspective()
|
||||
self._renderer.render()
|
||||
return
|
||||
if key == "o" and hasattr(self._renderer, "set_camera_orthographic"):
|
||||
self._renderer.set_camera_orthographic()
|
||||
self._renderer.render()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user