- Added save file foramt

- Split main.py refactor
This commit is contained in:
bklronin
2026-07-05 22:16:08 +02:00
parent 3a169007f7
commit 5269c0897c
14 changed files with 9117 additions and 7371 deletions
+9 -4
View File
@@ -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">
+60 -7
View File
@@ -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>
+50 -9
View File
@@ -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))
View File
+202
View File
@@ -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
+5
View File
@@ -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"]
+716
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+240
View File
@@ -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()
+15
View File
@@ -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] = []
+494
View File
@@ -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
+811
View File
@@ -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)